Admin AI Tools page: unified design system styling, provider selector with OpenAI turd emoji, free-text model input with exact ID warning
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
"use server";
|
||||
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
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> {
|
||||
const { data, error } = await supabase
|
||||
.from("brand_ai_settings")
|
||||
.select("api_key, organization_id, base_url, model, max_tokens")
|
||||
.eq("brand_id", brandId)
|
||||
.single();
|
||||
|
||||
if (error || !data) return null;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function saveAIPreferences(
|
||||
brandId: string,
|
||||
config: AIAuthConfig
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const { error } = await supabase
|
||||
.from("brand_ai_settings")
|
||||
.upsert({
|
||||
brand_id: brandId,
|
||||
provider: config.provider,
|
||||
api_key: config.api_key || null,
|
||||
organization_id: config.organization_id || null,
|
||||
base_url: config.base_url || null,
|
||||
model: config.model || "gpt-4o-mini",
|
||||
max_tokens: config.max_tokens || 4000,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (error) return { success: false, error: error.message };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function testAIConnection(config: AIAuthConfig): Promise<{ ok: boolean; message: string }> {
|
||||
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." };
|
||||
} else if (response.status === 401) {
|
||||
return { ok: false, message: "Invalid API key. Please check and try again." };
|
||||
} else {
|
||||
return { ok: false, message: `Error: ${response.status} ${response.statusText}` };
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, message: "Could not connect. Check your network and API key." };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
// Contact imports bucket
|
||||
const CONTACTS_BUCKET_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
|
||||
export type UploadContactsResult =
|
||||
| { success: true; fileId: string; fileUrl: string; recordCount: number }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function uploadContactsToBucket(
|
||||
brandId: string,
|
||||
file: File
|
||||
): Promise<UploadContactsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
// Validate file type
|
||||
if (!file.name.endsWith(".csv")) {
|
||||
return { success: false, error: "Only CSV files are supported" };
|
||||
}
|
||||
|
||||
// For very large files (farmers with tons of data), check size
|
||||
const maxSize = 50 * 1024 * 1024; // 50MB max
|
||||
if (file.size > maxSize) {
|
||||
return { success: false, error: "File too large. Max 50MB for large imports." };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Generate unique path
|
||||
const timestamp = Date.now();
|
||||
const safeName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
|
||||
const path = `imports/${brandId}/${timestamp}-${safeName}`;
|
||||
|
||||
// Upload to bucket
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const uploadRes = await fetch(
|
||||
`${supabaseUrl}/storage/v1/object/${CONTACTS_BUCKET_ID}/${path}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Authorization": `Bearer ${supabaseKey}`,
|
||||
"Content-Type": "text/csv",
|
||||
"x-upsert": "false",
|
||||
},
|
||||
body: buffer,
|
||||
}
|
||||
);
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
||||
}
|
||||
|
||||
const fileUrl = `${supabaseUrl}/storage/v1/object/public/${CONTACTS_BUCKET_ID}/${path}`;
|
||||
const fileId = `${brandId}/${timestamp}`;
|
||||
|
||||
// Get rough row count from file size (approx 200 bytes per row)
|
||||
const estimatedRows = Math.floor(file.size / 200);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
fileId,
|
||||
fileUrl,
|
||||
recordCount: estimatedRows,
|
||||
};
|
||||
}
|
||||
|
||||
export type ProcessImportResult =
|
||||
| { success: true; created: number; updated: number; skipped: number; errors: number }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function processBucketImport(
|
||||
brandId: string,
|
||||
fileUrl: string,
|
||||
allowOptInOverride: boolean = false
|
||||
): Promise<ProcessImportResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Call RPC to process the file from bucket
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/process_contact_import_from_url`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_file_url: fileUrl,
|
||||
p_allow_opt_in_override: allowOptInOverride,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: `Processing failed: ${await response.text()}` };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
created: data.created ?? 0,
|
||||
updated: data.updated ?? 0,
|
||||
skipped: data.skipped ?? 0,
|
||||
errors: data.errors ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listImportHistory(
|
||||
brandId: string,
|
||||
limit: number = 10
|
||||
): Promise<{ success: true; imports: ImportHistoryItem[] } | { success: false; error: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// List files in the imports folder for this brand
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/storage/v1/object/list/${CONTACTS_BUCKET_ID}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prefix: `imports/${brandId}/`,
|
||||
limit: limit,
|
||||
sortBy: { column: "created_at", order: "desc" },
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: "Failed to list imports" };
|
||||
}
|
||||
|
||||
const files = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
imports: files.map((f: { name: string; metadata: { size: number }; created_at: string }) => ({
|
||||
filename: f.name.split("/").pop() ?? "",
|
||||
size: f.metadata?.size ?? 0,
|
||||
createdAt: f.created_at,
|
||||
url: `${supabaseUrl}/storage/v1/object/public/${CONTACTS_BUCKET_ID}/${f.name}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export type ImportHistoryItem = {
|
||||
filename: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
url: string;
|
||||
};
|
||||
@@ -51,6 +51,7 @@ export async function savePaymentSettings(params: {
|
||||
provider: PaymentProvider | null;
|
||||
stripePublishableKey?: string;
|
||||
stripeSecretKey?: string;
|
||||
stripeUserId?: string;
|
||||
squareAccessToken?: string;
|
||||
squareLocationId?: string;
|
||||
squareSyncEnabled?: boolean;
|
||||
@@ -84,6 +85,7 @@ export async function savePaymentSettings(params: {
|
||||
p_provider: params.provider,
|
||||
p_stripe_publishable_key: params.stripePublishableKey ?? null,
|
||||
p_stripe_secret_key: params.stripeSecretKey ?? null,
|
||||
p_stripe_user_id: params.stripeUserId ?? null,
|
||||
p_square_access_token: params.squareAccessToken ?? null,
|
||||
p_square_location_id: params.squareLocationId ?? null,
|
||||
p_square_sync_enabled: params.squareSyncEnabled ?? null,
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
// Product images bucket - UUID from Supabase
|
||||
const PRODUCT_IMAGES_BUCKET_ID = "80aa01da-ab4b-44f8-b6e7-700552457e18";
|
||||
|
||||
export type UploadProductImageResult =
|
||||
| { success: true; imageUrl: string }
|
||||
| { success: false; error: string };
|
||||
@@ -32,7 +35,7 @@ export async function uploadProductImage(
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const uploadRes = await fetch(
|
||||
`${supabaseUrl}/storage/v1/object/product-images/${path}`,
|
||||
`${supabaseUrl}/storage/v1/object/${PRODUCT_IMAGES_BUCKET_ID}/${path}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { ...svcHeaders(supabaseKey), "Authorization": `Bearer ${supabaseKey}`, "Content-Type": `image/${ext}`, "x-upsert": "true" },
|
||||
@@ -44,7 +47,7 @@ export async function uploadProductImage(
|
||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
||||
}
|
||||
|
||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/product-images/${path}`;
|
||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/${PRODUCT_IMAGES_BUCKET_ID}/${path}`;
|
||||
|
||||
// If productId is "__NEW__", we just upload and return the URL (caller handles saving it)
|
||||
if (productId === "__NEW__") {
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import Stripe from "stripe";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { is_connected: false, error: "Not authenticated" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Get brand's payment settings
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_brand_payment_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return { is_connected: false, error: "Failed to fetch payment settings" };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const stripeUserId = data?.stripe_user_id;
|
||||
|
||||
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-04-22.dahlia" as any });
|
||||
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 (err) {
|
||||
// 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;
|
||||
}> {
|
||||
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-04-22.dahlia" as any });
|
||||
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;
|
||||
}> {
|
||||
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-04-22.dahlia" as any });
|
||||
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;
|
||||
}> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Save to payment_settings via RPC
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_stripe_connect_account`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_stripe_user_id: accountId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
return { success: false, error: `Failed to save: ${error}` };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect Stripe Connect account from a brand
|
||||
*/
|
||||
export async function disconnectStripeConnect(brandId: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
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 supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/disconnect_stripe_connect`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return { success: false, error: "Failed to disconnect Stripe account" };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Stripe Express dashboard login link
|
||||
*/
|
||||
export async function createStripeDashboardLink(brandId: string): Promise<{
|
||||
success: boolean;
|
||||
url?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
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-04-22.dahlia" as any });
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -18,12 +18,15 @@ function rpcBody(body: Record<string, unknown>) {
|
||||
|
||||
export type TimeWorker = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
lang: string;
|
||||
pin: string;
|
||||
active: boolean;
|
||||
last_used_at: string | null;
|
||||
created_at: string;
|
||||
worker_number: number | null;
|
||||
};
|
||||
|
||||
export type TimeTask = {
|
||||
@@ -62,12 +65,15 @@ export async function getTimeTrackingWorkers(brandId: string): Promise<TimeWorke
|
||||
if (useMockData) {
|
||||
return mockWorkers.map(w => ({
|
||||
id: w.id,
|
||||
brand_id: brandId,
|
||||
name: w.name,
|
||||
role: w.role,
|
||||
lang: w.language,
|
||||
pin: "0000",
|
||||
active: w.is_active,
|
||||
last_used_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
worker_number: null,
|
||||
}));
|
||||
}
|
||||
const res = await fetch(
|
||||
@@ -80,7 +86,18 @@ export async function getTimeTrackingWorkers(brandId: string): Promise<TimeWorke
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data?.workers ?? [];
|
||||
return (data?.workers ?? []).map((w: Record<string, unknown>) => ({
|
||||
id: w.id as string,
|
||||
brand_id: w.brand_id as string,
|
||||
name: w.name as string,
|
||||
role: w.role as string,
|
||||
lang: w.lang as string,
|
||||
pin: w.pin as string,
|
||||
active: w.active as boolean,
|
||||
last_used_at: w.last_used_at as string | null,
|
||||
created_at: w.created_at as string,
|
||||
worker_number: w.worker_number as number | null,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createTimeWorker(
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getBrands } from "@/actions/admin/users";
|
||||
import { getStripeConnectStatus } from "@/actions/stripe-connect";
|
||||
import AdvancedSettingsClient from "@/components/admin/AdvancedSettingsClient";
|
||||
|
||||
export const metadata = {
|
||||
title: "Advanced Settings - Route Commerce Admin",
|
||||
description: "Developer settings, API integrations, and advanced configurations",
|
||||
};
|
||||
|
||||
export default async function AdvancedSettingsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
// Only admins can access advanced settings
|
||||
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const brandId = adminUser.brand_id ?? "";
|
||||
const { brands } = await getBrands();
|
||||
|
||||
// Get Stripe Connect status
|
||||
const stripeConnect = brandId ? await getStripeConnectStatus(brandId) : null;
|
||||
|
||||
return (
|
||||
<AdvancedSettingsClient
|
||||
brandId={brandId}
|
||||
brands={brands}
|
||||
stripeConnect={stripeConnect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getCampaignAnalytics } from "@/actions/harvest-reach/campaigns";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function AnalyticsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser || !adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const analytics = await getCampaignAnalytics(effectiveBrandId);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<CommunicationsPage
|
||||
campaigns={[]}
|
||||
templates={[]}
|
||||
activeTab="analytics"
|
||||
brandId={effectiveBrandId}
|
||||
initialAnalytics={analytics}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default function AnalyticsPage() {
|
||||
redirect("/admin/communications");
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getCommunicationCampaigns, getCampaignById } from "@/actions/communications/campaigns";
|
||||
import { getCommunicationTemplates } from "@/actions/communications/templates";
|
||||
import { getHarvestReachSegments } from "@/actions/harvest-reach/segments";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function CampaignEditPage({
|
||||
@@ -10,36 +11,30 @@ export default async function CampaignEditPage({
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/admin");
|
||||
if (!adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
if (!adminUser || !adminUser.can_manage_messages) {
|
||||
redirect("/admin/pickup");
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const isNew = id === "new";
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [campaignsResult, templatesResult] = await Promise.all([
|
||||
const [campaignsResult, templatesResult, segmentsResult] = await Promise.all([
|
||||
getCommunicationCampaigns(effectiveBrandId),
|
||||
getCommunicationTemplates(effectiveBrandId),
|
||||
getHarvestReachSegments(effectiveBrandId),
|
||||
]);
|
||||
|
||||
const campaign = isNew ? undefined : id ? await getCampaignById(id) : undefined;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
|
||||
</div>
|
||||
|
||||
<CommunicationsPage
|
||||
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
activeTab="campaigns"
|
||||
brandId={adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de"}
|
||||
editCampaign={campaign}
|
||||
editMode={isNew ? "new" : "edit"}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
<CommunicationsPage
|
||||
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
brandId={effectiveBrandId}
|
||||
initialSegments={segmentsResult.success ? segmentsResult.segments : []}
|
||||
editCampaign={campaign}
|
||||
editMode={isNew ? "new" : "edit"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +1,31 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getHarvestReachCampaigns } from "@/actions/harvest-reach/campaigns";
|
||||
import { getCommunicationCampaigns } from "@/actions/communications/campaigns";
|
||||
import { getCommunicationTemplates } from "@/actions/communications/templates";
|
||||
import { getHarvestReachSegments } from "@/actions/harvest-reach/segments";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function ComposePage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser || !adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
if (!adminUser || !adminUser.can_manage_messages) {
|
||||
redirect("/admin/pickup");
|
||||
}
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [campaignsResult, templatesResult, segmentsResult] = await Promise.all([
|
||||
getHarvestReachCampaigns(effectiveBrandId),
|
||||
getCommunicationCampaigns(effectiveBrandId),
|
||||
getCommunicationTemplates(effectiveBrandId),
|
||||
getHarvestReachSegments(effectiveBrandId),
|
||||
]);
|
||||
|
||||
const campaigns = campaignsResult.success ? campaignsResult.campaigns : [];
|
||||
const templates = templatesResult.success ? templatesResult.templates : [];
|
||||
const segments = segmentsResult.success ? segmentsResult.segments : [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<CommunicationsPage
|
||||
campaigns={campaigns}
|
||||
templates={templates}
|
||||
activeTab="compose"
|
||||
brandId={effectiveBrandId}
|
||||
initialSegments={segments}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CommunicationsPage
|
||||
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
brandId={effectiveBrandId}
|
||||
initialSegments={segmentsResult.success ? segmentsResult.segments : []}
|
||||
editMode="new"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getCommunicationCampaigns } from "@/actions/communications/campaigns";
|
||||
import { getCommunicationTemplates } from "@/actions/communications/templates";
|
||||
import { getContacts } from "@/actions/communications/contacts";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function ContactsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/admin");
|
||||
if (!adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const brandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [campaignsResult, templatesResult, contactsResult] = await Promise.all([
|
||||
getCommunicationCampaigns(brandId),
|
||||
getCommunicationTemplates(brandId),
|
||||
getContacts({ brandId, limit: 50 }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
|
||||
<p className="mt-2 text-stone-500">
|
||||
Manage email campaigns, templates, contacts, and message history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CommunicationsPage
|
||||
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
activeTab="contacts"
|
||||
brandId={brandId}
|
||||
initialContacts={contactsResult.success ? contactsResult.contacts : []}
|
||||
initialContactTotal={contactsResult.success ? contactsResult.total : 0}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
export default function ContactsPage() {
|
||||
redirect("/admin/communications");
|
||||
}
|
||||
@@ -1,41 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getCommunicationCampaigns } from "@/actions/communications/campaigns";
|
||||
import { getCommunicationTemplates } from "@/actions/communications/templates";
|
||||
import { getMessageLogs } from "@/actions/communications/send";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function LogsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/admin");
|
||||
if (!adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const brandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [campaignsResult, templatesResult, logsResult] = await Promise.all([
|
||||
getCommunicationCampaigns(brandId),
|
||||
getCommunicationTemplates(brandId),
|
||||
getMessageLogs({ brandId, limit: 200 }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
|
||||
<p className="mt-2 text-stone-500">
|
||||
Manage email campaigns, templates, and message history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CommunicationsPage
|
||||
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
activeTab="logs"
|
||||
brandId={brandId}
|
||||
initialLogs={logsResult.success ? logsResult.logs : []}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
export default function LogsPage() {
|
||||
redirect("/admin/communications");
|
||||
}
|
||||
@@ -8,9 +8,11 @@ import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function CommunicationsRootPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser || !adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
if (!adminUser || !adminUser.can_manage_messages) {
|
||||
redirect("/admin/pickup");
|
||||
}
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const effectiveBrandId = adminUser!.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [campaignsResult, templatesResult, segmentsResult, analyticsResult] = await Promise.all([
|
||||
getCommunicationCampaigns(effectiveBrandId),
|
||||
@@ -20,31 +22,12 @@ export default async function CommunicationsRootPage() {
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
|
||||
<a href="/admin" className="hover:text-stone-600 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-600">Harvest Reach</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
|
||||
<p className="mt-2 text-stone-500">
|
||||
Manage email campaigns, templates, and message history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CommunicationsPage
|
||||
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
activeTab="campaigns"
|
||||
brandId={effectiveBrandId}
|
||||
initialSegments={segmentsResult.success ? segmentsResult.segments : []}
|
||||
initialAnalytics={analyticsResult}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
<CommunicationsPage
|
||||
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
brandId={effectiveBrandId}
|
||||
initialSegments={segmentsResult.success ? segmentsResult.segments : []}
|
||||
initialAnalytics={analyticsResult}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getHarvestReachSegments } from "@/actions/harvest-reach/segments";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function SegmentsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser || !adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const segmentsResult = await getHarvestReachSegments(effectiveBrandId);
|
||||
const segments = segmentsResult.success ? segmentsResult.segments : [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<CommunicationsPage
|
||||
campaigns={[]}
|
||||
templates={[]}
|
||||
activeTab="segments"
|
||||
brandId={effectiveBrandId}
|
||||
initialSegments={segments}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default function SegmentsPage() {
|
||||
redirect("/admin/communications");
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getCommunicationCampaigns } from "@/actions/communications/campaigns";
|
||||
import { getCommunicationTemplates } from "@/actions/communications/templates";
|
||||
import { getCommunicationSettings } from "@/actions/communications/settings";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
import CommunicationSettingsForm from "@/components/admin/CommunicationSettingsForm";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
@@ -11,31 +9,41 @@ export default async function SettingsPage() {
|
||||
if (!adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const brandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [campaignsResult, templatesResult, settingsResult] = await Promise.all([
|
||||
getCommunicationCampaigns(brandId),
|
||||
getCommunicationTemplates(brandId),
|
||||
getCommunicationSettings(brandId),
|
||||
]);
|
||||
const settingsResult = await getCommunicationSettings(brandId);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
|
||||
<p className="mt-2 text-stone-500">
|
||||
Manage email campaigns, templates, and message history.
|
||||
</p>
|
||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
|
||||
{/* Back button */}
|
||||
<a
|
||||
href="/admin/communications"
|
||||
className="inline-flex items-center gap-2 text-sm text-stone-500 hover:text-stone-700 mb-4"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m15 18-6-6 6-6"/>
|
||||
</svg>
|
||||
Back to Harvest Reach
|
||||
</a>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
|
||||
<svg className="h-5 w-5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-black text-[var(--admin-text-primary)] tracking-tight">Harvest Reach Settings</h1>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Configure email and SMS integration</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CommunicationsPage
|
||||
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
activeTab="settings"
|
||||
brandId={brandId}
|
||||
initialSettings={settingsResult}
|
||||
/>
|
||||
{/* Settings Form */}
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-4 sm:p-6">
|
||||
<CommunicationSettingsForm settings={settingsResult} brandId={brandId} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getCommunicationTemplates, getTemplateById } from "@/actions/communications/templates";
|
||||
import { getCommunicationCampaigns } from "@/actions/communications/campaigns";
|
||||
import { getHarvestReachSegments } from "@/actions/harvest-reach/segments";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function TemplateEditPage({
|
||||
@@ -9,31 +11,30 @@ export default async function TemplateEditPage({
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/admin");
|
||||
if (!adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
if (!adminUser || !adminUser.can_manage_messages) {
|
||||
redirect("/admin/pickup");
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const isNew = id === "new";
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [campaignsResult, templatesResult, segmentsResult] = await Promise.all([
|
||||
getCommunicationCampaigns(effectiveBrandId),
|
||||
getCommunicationTemplates(effectiveBrandId),
|
||||
getHarvestReachSegments(effectiveBrandId),
|
||||
]);
|
||||
|
||||
const templatesResult = await getCommunicationTemplates();
|
||||
const template = isNew ? undefined : id ? await getTemplateById(id) : undefined;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
|
||||
</div>
|
||||
|
||||
<CommunicationsPage
|
||||
campaigns={[]}
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
activeTab="templates"
|
||||
brandId={adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de"}
|
||||
editTemplate={template}
|
||||
editMode={isNew ? "new" : "edit"}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
<CommunicationsPage
|
||||
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
brandId={effectiveBrandId}
|
||||
initialSegments={segmentsResult.success ? segmentsResult.segments : []}
|
||||
editTemplate={template}
|
||||
editMode={isNew ? "new" : "edit"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getCommunicationCampaigns } from "@/actions/communications/campaigns";
|
||||
import { getCommunicationTemplates } from "@/actions/communications/templates";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function TemplatesPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/admin");
|
||||
if (!adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const [campaignsResult, templatesResult] = await Promise.all([
|
||||
getCommunicationCampaigns(adminUser.brand_id ?? undefined),
|
||||
getCommunicationTemplates(adminUser.brand_id ?? undefined),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
|
||||
<p className="mt-2 text-stone-500">
|
||||
Manage email campaigns, templates, and message history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CommunicationsPage
|
||||
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
activeTab="templates"
|
||||
brandId={adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de"}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
export default function TemplatesPage() {
|
||||
redirect("/admin/communications");
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import AdminSidebar from "@/components/admin/AdminSidebar";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { redirect } from "next/navigation";
|
||||
import "@/styles/admin-design-system.css";
|
||||
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const adminUser = await getAdminUser();
|
||||
@@ -10,8 +11,8 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
return (
|
||||
<>
|
||||
<AdminSidebar userRole={null} />
|
||||
<div className="min-h-screen lg:pl-60" style={{ backgroundColor: "#fdfaf6" }}>
|
||||
<AdminAccessDenied message="Your account does not have admin access." />
|
||||
<div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<AdminAccessDenied message="Your account does not have admin access." />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -24,7 +25,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
return (
|
||||
<>
|
||||
<AdminSidebar userRole={adminUser.role} />
|
||||
<div className="min-h-screen lg:pl-60" style={{ backgroundColor: "#fdfaf6" }}>
|
||||
<div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,7 @@ import OrderEditForm from "@/components/admin/OrderEditForm";
|
||||
import OrderPaymentSection from "@/components/admin/OrderPaymentSection";
|
||||
import OrderPickupAction from "@/components/admin/OrderPickupAction";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
type OrderDetailPageProps = {
|
||||
@@ -12,6 +13,10 @@ type OrderDetailPageProps = {
|
||||
}>;
|
||||
};
|
||||
|
||||
function formatCurrency(amount: number) {
|
||||
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
|
||||
}
|
||||
|
||||
export default async function OrderDetailPage({ params }: OrderDetailPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
@@ -19,15 +24,17 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-12">
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h1 className="text-3xl font-bold text-red-600">Order not found</h1>
|
||||
<a
|
||||
href="/admin/orders"
|
||||
className="mt-4 inline-block text-stone-500 hover:text-stone-700"
|
||||
className="inline-flex items-center gap-2 text-sm text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Orders
|
||||
</a>
|
||||
<div className="mt-8 rounded-2xl border border-red-200 bg-red-50 p-8 text-center">
|
||||
<p className="text-lg font-semibold text-red-700">Order not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
@@ -46,58 +53,69 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
|
||||
const brandId = order.stops?.brand_id ?? null;
|
||||
const subtotal = Number(order.subtotal);
|
||||
const discount_amount = Number(order.discount_amount ?? 0);
|
||||
const total = Math.max(0, subtotal - discount_amount);
|
||||
const taxAmount = Number(order.tax_amount ?? 0);
|
||||
const total = subtotal + taxAmount - discount_amount;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<a
|
||||
href="/admin/orders"
|
||||
className="text-sm text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Orders
|
||||
</a>
|
||||
|
||||
{/* Customer info */}
|
||||
<div className="card mt-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<main className="min-h-screen px-6 py-8">
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<a
|
||||
href="/admin/orders"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl border border-stone-200 bg-white text-stone-500 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-stone-500">
|
||||
Customer
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-bold text-stone-950">
|
||||
{order.customer_name}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide ${
|
||||
order.pickup_complete
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: "bg-yellow-100 text-yellow-700"
|
||||
}`}
|
||||
>
|
||||
{order.pickup_complete ? "Picked Up" : "Pending"}
|
||||
</span>
|
||||
<OrderPickupAction
|
||||
orderId={order.id}
|
||||
brandId={brandId}
|
||||
currentlyPickedUp={order.pickup_complete}
|
||||
/>
|
||||
<h1 className="text-2xl font-bold text-stone-950 tracking-tight">Order Details</h1>
|
||||
<p className="text-sm text-stone-500">{formatDate(order.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-bold ${
|
||||
order.pickup_complete
|
||||
? "bg-green-50 text-green-700 border border-green-200"
|
||||
: "bg-amber-50 text-amber-700 border border-amber-200"
|
||||
}`}>
|
||||
{order.pickup_complete ? "✓ Picked Up" : "⏳ Pending"}
|
||||
</span>
|
||||
{order.payment_processor && (
|
||||
<span className="rounded-full bg-violet-50 px-2.5 py-0.5 text-xs font-semibold text-violet-700 border border-violet-200">
|
||||
{order.payment_processor}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{/* Customer info */}
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400">Customer</p>
|
||||
<h2 className="mt-1 text-2xl font-bold text-stone-950">{order.customer_name}</h2>
|
||||
</div>
|
||||
<OrderPickupAction
|
||||
orderId={order.id}
|
||||
brandId={brandId}
|
||||
currentlyPickedUp={order.pickup_complete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 gap-4">
|
||||
{order.customer_phone && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">Phone</p>
|
||||
<p className="mt-1 text-lg text-stone-950">{order.customer_phone}</p>
|
||||
<p className="text-xs font-semibold text-stone-400">Phone</p>
|
||||
<p className="mt-0.5 text-sm font-medium text-stone-700">{order.customer_phone}</p>
|
||||
</div>
|
||||
)}
|
||||
{order.customer_email && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">Email</p>
|
||||
<p className="mt-1 text-lg text-stone-950">{order.customer_email}</p>
|
||||
<p className="text-xs font-semibold text-stone-400">Email</p>
|
||||
<p className="mt-0.5 text-sm font-medium text-stone-700">{order.customer_email}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -105,84 +123,76 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
|
||||
|
||||
{/* Stop info */}
|
||||
{order.stops && (
|
||||
<div className="card mt-6">
|
||||
<p className="text-sm font-medium text-stone-500">Stop</p>
|
||||
<p className="mt-1 text-xl font-semibold text-stone-950">
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400">Pickup Location</p>
|
||||
<p className="mt-1 text-lg font-bold text-stone-950">
|
||||
{order.stops.city}, {order.stops.state}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-stone-500">{order.stops.date}</p>
|
||||
<p className="mt-0.5 text-sm text-stone-500">{formatDate(order.stops.date)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Order items */}
|
||||
<div className="card mt-6">
|
||||
<h2 className="text-2xl font-bold text-stone-950">Order Items</h2>
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<h3 className="text-lg font-bold text-stone-950">Order Items</h3>
|
||||
|
||||
{order.order_items && order.order_items.length > 0 ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="mt-4 divide-y divide-stone-100">
|
||||
{order.order_items.map((item: any) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between border-b border-stone-200 pb-3 last:border-0"
|
||||
className="flex items-center justify-between py-3"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-stone-950">
|
||||
<p className="font-medium text-stone-900">
|
||||
{item.products?.name ?? "Unknown Product"}
|
||||
</p>
|
||||
<p className="text-sm text-stone-500">Qty: {item.quantity}</p>
|
||||
<p className="text-xs text-stone-400">Qty: {item.quantity} × {formatCurrency(Number(item.price))}</p>
|
||||
</div>
|
||||
<p className="font-semibold text-stone-950">
|
||||
${(Number(item.price) * item.quantity).toFixed(2)}
|
||||
<p className="font-semibold text-stone-900">
|
||||
{formatCurrency(Number(item.price) * item.quantity)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-4 text-stone-500">No items found.</p>
|
||||
<p className="mt-4 text-sm text-stone-400">No items found</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6 border-t border-stone-200 pt-6 space-y-2">
|
||||
{/* Totals */}
|
||||
<div className="mt-6 border-t border-stone-100 pt-6 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-stone-500">Subtotal</span>
|
||||
<span className="text-stone-950">${subtotal.toFixed(2)}</span>
|
||||
<span className="text-stone-900">{formatCurrency(subtotal)}</span>
|
||||
</div>
|
||||
{Number(order.tax_amount ?? 0) > 0 && (
|
||||
{taxAmount > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-stone-500">Tax ({order.tax_rate ? `${(Number(order.tax_rate) * 100).toFixed(3)}%` : ""})</span>
|
||||
<span className="text-stone-950">${Number(order.tax_amount).toFixed(2)}</span>
|
||||
<span className="text-stone-500">Tax</span>
|
||||
<span className="text-stone-900">{formatCurrency(taxAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
{discount_amount > 0 && (
|
||||
<>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-stone-500">Discount</span>
|
||||
<span className="text-red-600">-${discount_amount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-stone-500">Discount</span>
|
||||
<span className="text-red-600">-{formatCurrency(discount_amount)}</span>
|
||||
{order.discount_reason && (
|
||||
<p className="text-xs text-stone-500">{order.discount_reason}</p>
|
||||
<span className="text-xs text-stone-400 ml-2">({order.discount_reason})</span>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-xl font-bold text-stone-950 pt-2 border-t border-stone-200">
|
||||
<div className="flex justify-between text-lg font-bold text-stone-950 pt-2 border-t border-stone-200">
|
||||
<span>Total</span>
|
||||
<span>${(subtotal + Number(order.tax_amount ?? 0) - discount_amount).toFixed(2)}</span>
|
||||
<span>{formatCurrency(total)}</span>
|
||||
</div>
|
||||
{order.tax_location && (
|
||||
<p className="text-xs text-stone-500">Tax sourced from: {order.tax_location}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment & Refunds */}
|
||||
<div className="card mt-6">
|
||||
<h2 className="text-2xl font-bold text-stone-950">
|
||||
Payment & Refunds
|
||||
</h2>
|
||||
<p className="mt-1 text-stone-600">
|
||||
Record payment details and manage refunds for this order.
|
||||
</p>
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<h3 className="text-lg font-bold text-stone-950">Payment & Refunds</h3>
|
||||
<p className="mt-1 text-sm text-stone-500">Record payment details and manage refunds</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="mt-5">
|
||||
<OrderPaymentSection
|
||||
orderId={order.id}
|
||||
brandId={brandId}
|
||||
@@ -198,25 +208,23 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
|
||||
</div>
|
||||
|
||||
{/* Edit form */}
|
||||
<div className="card mt-6">
|
||||
<h2 className="text-2xl font-bold text-stone-950">Edit Order</h2>
|
||||
<p className="mt-1 text-stone-600">
|
||||
Update customer details, pricing, and status.
|
||||
</p>
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<h3 className="text-lg font-bold text-stone-950">Edit Order</h3>
|
||||
<p className="mt-1 text-sm text-stone-500">Update customer details, pricing, and status</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="mt-5">
|
||||
<OrderEditForm order={order as any} brandId={brandId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Internal notes display */}
|
||||
{/* Internal notes */}
|
||||
{order.internal_notes && (
|
||||
<div className="card mt-6 border-amber-300 bg-amber-50">
|
||||
<p className="text-sm font-medium text-amber-700">Internal Notes</p>
|
||||
<p className="mt-1 text-stone-950">{order.internal_notes}</p>
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-amber-600">Internal Notes</p>
|
||||
<p className="mt-1 text-sm text-stone-700">{order.internal_notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,12 +29,34 @@ export default async function AdminOrdersPage() {
|
||||
: orders;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<AdminOrdersPanel
|
||||
initialOrders={brandOrders}
|
||||
initialStops={brandStops}
|
||||
brandId={adminUser?.brand_id ?? null}
|
||||
/>
|
||||
</main>
|
||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||
{/* Header */}
|
||||
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-xl bg-emerald-600">
|
||||
<svg className="h-5 w-5 sm:h-6 sm:w-6 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/>
|
||||
<path d="M3 6h18"/>
|
||||
<path d="M16 10a4 4 0 0 1-8 0"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-black text-[var(--admin-text-primary)] tracking-tight">Orders</h1>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Manage customer orders and pickup status</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<AdminOrdersPanel
|
||||
initialOrders={brandOrders}
|
||||
initialStops={brandStops}
|
||||
brandId={adminUser?.brand_id ?? null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+12
-288
@@ -2,136 +2,10 @@ import Link from "next/link";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import { getBrandPlanInfo } from "@/actions/billing/stripe-portal";
|
||||
import DashboardHeader from "@/components/admin/DashboardHeader";
|
||||
import DashboardClient from "@/components/admin/DashboardClient";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
type Section = {
|
||||
title: string;
|
||||
href: string;
|
||||
description: string;
|
||||
group: "operations" | "fulfillment" | "management" | "tools";
|
||||
addonKey?: string;
|
||||
upgradeText?: string;
|
||||
prominent?: boolean;
|
||||
};
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
title: "Orders",
|
||||
href: "/admin/orders",
|
||||
description: "View orders, pickup status, fulfillment, and customer details.",
|
||||
group: "operations",
|
||||
prominent: true,
|
||||
},
|
||||
{
|
||||
title: "Products",
|
||||
href: "/admin/products",
|
||||
description: "Manage products, pricing, shipping type, and availability.",
|
||||
group: "operations",
|
||||
prominent: true,
|
||||
},
|
||||
{
|
||||
title: "Tours & Stops",
|
||||
href: "/admin/stops",
|
||||
description: "Manage routes, pickup locations, dates, and cutoff times.",
|
||||
group: "fulfillment",
|
||||
},
|
||||
{
|
||||
title: "Driver Pickup",
|
||||
href: "/admin/pickup",
|
||||
description: "Mobile pickup lookup, QR scanning, and completion tools.",
|
||||
group: "fulfillment",
|
||||
},
|
||||
{
|
||||
title: "Shipping",
|
||||
href: "/admin/shipping",
|
||||
description: "FedEx integration, label creation, and shipment tracking.",
|
||||
group: "fulfillment",
|
||||
},
|
||||
{
|
||||
title: "Reports",
|
||||
href: "/admin/reports",
|
||||
description: "Sales, route, product, pickup, and customer reports.",
|
||||
group: "management",
|
||||
},
|
||||
{
|
||||
title: "Tax Dashboard",
|
||||
href: "/admin/taxes",
|
||||
description: "Sales tax collected, breakdown by state, and exportable reports.",
|
||||
group: "management",
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
href: "/admin/settings",
|
||||
description: "Users, billing, brand, integrations, payments, and shipping.",
|
||||
group: "management",
|
||||
},
|
||||
{
|
||||
title: "Harvest Reach",
|
||||
href: "/admin/communications",
|
||||
description: "Email campaigns, stop blast, templates, and audience segments.",
|
||||
group: "tools",
|
||||
addonKey: "harvest_reach",
|
||||
upgradeText: "Enable to access email & SMS marketing",
|
||||
},
|
||||
{
|
||||
title: "Wholesale Portal",
|
||||
href: "/admin/wholesale",
|
||||
description: "Standalone B2B portal with custom pricing, credit limits, and net-30.",
|
||||
group: "tools",
|
||||
addonKey: "wholesale_portal",
|
||||
upgradeText: "Enable to unlock B2B buyer portal",
|
||||
},
|
||||
{
|
||||
title: "Import Center",
|
||||
href: "/admin/import",
|
||||
description: "AI-powered import for products, orders, contacts, and stops.",
|
||||
group: "tools",
|
||||
addonKey: "ai_tools",
|
||||
upgradeText: "Enable AI import with smart column mapping",
|
||||
},
|
||||
{
|
||||
title: "AI Intelligence",
|
||||
href: "/admin/settings/ai",
|
||||
description: "Campaign writer, pricing advisor, and report explainer.",
|
||||
group: "tools",
|
||||
addonKey: "ai_tools",
|
||||
upgradeText: "Enable AI tools for marketing and pricing",
|
||||
},
|
||||
{
|
||||
title: "Time Tracking",
|
||||
href: "/admin/time-tracking",
|
||||
description: "Worker clock-in/out, hours tracking, and overtime management.",
|
||||
group: "tools",
|
||||
addonKey: "time_tracking",
|
||||
upgradeText: "Enable for field worker time tracking",
|
||||
},
|
||||
{
|
||||
title: "Water Log",
|
||||
href: "/admin/water-log",
|
||||
description: "Irrigation tracking and water usage reporting.",
|
||||
group: "tools",
|
||||
addonKey: "water_log",
|
||||
upgradeText: "Enable for agricultural water tracking",
|
||||
},
|
||||
{
|
||||
title: "Route Trace",
|
||||
href: "/admin/route-trace",
|
||||
description: "Lot tracking, QR stickers, hauling board, and supply chain traceability.",
|
||||
group: "tools",
|
||||
addonKey: "route_trace",
|
||||
upgradeText: "Enable for field-to-delivery traceability",
|
||||
},
|
||||
];
|
||||
|
||||
const groupMeta = {
|
||||
operations: { label: "Core Operations", cols: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4" },
|
||||
fulfillment: { label: "Fulfillment & Logistics", cols: "grid-cols-1 md:grid-cols-3" },
|
||||
management: { label: "Business Management", cols: "grid-cols-1 md:grid-cols-3" },
|
||||
tools: { label: "Tools & Add-ons", cols: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4" },
|
||||
} as const;
|
||||
|
||||
export default async function AdminPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
const isStoreEmployee = adminUser?.role === "store_employee";
|
||||
@@ -142,11 +16,12 @@ export default async function AdminPage() {
|
||||
adminUser?.brand_id === TUXEDO_BRAND_ID &&
|
||||
adminUser?.can_manage_water_log);
|
||||
|
||||
const enabledAddons: Record<string, boolean> = {};
|
||||
let enabledAddons: Record<string, boolean> = {};
|
||||
let planTier = "starter";
|
||||
let usage = { users: 0, stops_this_month: 0, products: 0 };
|
||||
let limits = { max_users: 1, max_stops_monthly: 10, max_products: 25 };
|
||||
let brandDisplayName = "Admin";
|
||||
|
||||
if (adminUser?.brand_id) {
|
||||
for (const key of ["harvest_reach", "wholesale_portal", "water_log", "ai_tools", "sms_campaigns", "square_sync", "route_trace"] as const) {
|
||||
enabledAddons[key] = await isFeatureEnabled(adminUser.brand_id, key);
|
||||
@@ -211,166 +86,15 @@ export default async function AdminPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const allGroups = [
|
||||
{
|
||||
key: "operations" as const,
|
||||
label: groupMeta.operations.label,
|
||||
cols: groupMeta.operations.cols,
|
||||
sections: sections.filter((s) => s.group === "operations"),
|
||||
},
|
||||
{
|
||||
key: "fulfillment" as const,
|
||||
label: groupMeta.fulfillment.label,
|
||||
cols: groupMeta.fulfillment.cols,
|
||||
sections: sections.filter((s) => s.group === "fulfillment"),
|
||||
},
|
||||
{
|
||||
key: "management" as const,
|
||||
label: groupMeta.management.label,
|
||||
cols: groupMeta.management.cols,
|
||||
sections: sections.filter((s) => s.group === "management"),
|
||||
},
|
||||
{
|
||||
key: "tools" as const,
|
||||
label: groupMeta.tools.label,
|
||||
cols: groupMeta.tools.cols,
|
||||
sections: sections.filter((s) => s.group === "tools"),
|
||||
},
|
||||
];
|
||||
|
||||
const usagePct = {
|
||||
users: limits.max_users > 0 ? (usage.users / limits.max_users) * 100 : 0,
|
||||
stops: limits.max_stops_monthly > 0 ? (usage.stops_this_month / limits.max_stops_monthly) * 100 : 0,
|
||||
products: limits.max_products > 0 ? (usage.products / limits.max_products) * 100 : 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<div className="mx-auto max-w-7xl space-y-10">
|
||||
{/* Header */}
|
||||
<DashboardHeader
|
||||
brandId={adminUser?.brand_id ?? null}
|
||||
brandName={brandDisplayName}
|
||||
planTier={planTier}
|
||||
/>
|
||||
|
||||
{/* Usage bar */}
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<div className="flex items-center gap-5 mb-5">
|
||||
<span className={`rounded-full px-4 py-1.5 text-xs font-semibold tracking-wide ${
|
||||
planTier === "enterprise" ? "bg-amber-100 text-amber-700 border border-amber-200" :
|
||||
planTier === "farm" ? "bg-emerald-100 text-emerald-700 border border-emerald-200" :
|
||||
"bg-stone-100 text-stone-600 border border-stone-200"
|
||||
}`}>
|
||||
{planTier.charAt(0).toUpperCase() + planTier.slice(1)} Plan
|
||||
</span>
|
||||
<span className="text-sm text-stone-500">{adminUser?.brand_id ? "Tuxedo Corn" : "All Brands"}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-8">
|
||||
{[
|
||||
{ label: "Users", value: `${usage.users}/${limits.max_users}`, pct: usagePct.users },
|
||||
{ label: "Stops / month", value: `${usage.stops_this_month}/${limits.max_stops_monthly}`, pct: usagePct.stops },
|
||||
{ label: "Products", value: `${usage.products}/${limits.max_products}`, pct: usagePct.products },
|
||||
].map(({ label, value, pct }) => (
|
||||
<div key={label}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-stone-600">{label}</span>
|
||||
<span className="text-sm font-semibold text-stone-900">{value}</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-stone-200 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
pct > 85 ? "bg-gradient-to-r from-amber-500 to-amber-400" : "bg-gradient-to-r from-emerald-500 to-emerald-400"
|
||||
}`}
|
||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grouped sections */}
|
||||
{allGroups.map(({ key, label, cols, sections: groupSections }) => {
|
||||
if (groupSections.length === 0) return null;
|
||||
return (
|
||||
<div key={key}>
|
||||
<div className="flex items-center gap-4 mb-5">
|
||||
<p className="text-[11px] font-bold uppercase tracking-widest text-stone-600 flex-shrink-0">{label}</p>
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-stone-300 to-transparent" />
|
||||
</div>
|
||||
<div className={`grid ${cols} gap-4`}>
|
||||
{groupSections.map((section) => {
|
||||
if (section.title === "Water Log" && !isWaterLogVisible) return null;
|
||||
if (section.title === "Route Trace" && !enabledAddons["route_trace"]) return null;
|
||||
|
||||
const isAddon = Boolean(section.addonKey);
|
||||
const isEnabled = section.addonKey ? (enabledAddons[section.addonKey] ?? false) : true;
|
||||
const isProminent = section.prominent;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={section.title}
|
||||
href={section.href}
|
||||
className={[
|
||||
"group relative flex flex-col rounded-2xl border bg-white p-5 transition-all hover:-translate-y-0.5",
|
||||
isAddon && !isEnabled
|
||||
? "border-stone-200 shadow-sm opacity-75 hover:opacity-100"
|
||||
: isProminent
|
||||
? "border-stone-200 shadow-md shadow-stone-200/50 hover:shadow-lg"
|
||||
: "border-stone-200 shadow-sm hover:shadow-md",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`flex h-9 w-9 items-center justify-center rounded-xl ${
|
||||
isAddon && !isEnabled
|
||||
? "bg-stone-100 text-stone-400"
|
||||
: isProminent
|
||||
? "bg-emerald-100 text-emerald-600"
|
||||
: "bg-violet-100 text-violet-600"
|
||||
}`}>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
{isAddon && !isEnabled && (
|
||||
<span className="text-[10px] font-semibold text-amber-800 bg-amber-100 border border-amber-200 rounded-full px-2.5 py-0.5">
|
||||
Add-on
|
||||
</span>
|
||||
)}
|
||||
{isAddon && isEnabled && (
|
||||
<span className="text-[10px] font-semibold text-emerald-800 bg-emerald-100 border border-emerald-200 rounded-full px-2.5 py-0.5">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
{isProminent && (
|
||||
<span className="text-[10px] font-semibold text-emerald-800 bg-emerald-100 border border-emerald-200 rounded-full px-2.5 py-0.5">
|
||||
Core
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-semibold leading-tight text-stone-950">
|
||||
{section.title}
|
||||
</h3>
|
||||
|
||||
<p className={`mt-2 text-sm leading-relaxed ${
|
||||
isAddon && !isEnabled ? "text-stone-500" : "text-stone-600"
|
||||
}`}>
|
||||
{section.description}
|
||||
</p>
|
||||
|
||||
{isAddon && !isEnabled && section.upgradeText && (
|
||||
<p className="mt-3 text-xs text-amber-700 font-medium">{section.upgradeText}</p>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
<DashboardClient
|
||||
brandId={adminUser?.brand_id ?? null}
|
||||
brandName={brandDisplayName}
|
||||
planTier={planTier}
|
||||
isWaterLogVisible={isWaterLogVisible}
|
||||
enabledAddons={enabledAddons}
|
||||
usage={usage}
|
||||
limits={limits}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import ProductTableClient from "@/components/admin/ProductTableClient";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getPaymentSettings } from "@/actions/payments";
|
||||
import ProductSyncBanner from "@/components/admin/ProductSyncBanner";
|
||||
import ProductsClient from "@/components/admin/ProductsClient";
|
||||
|
||||
// Icon components
|
||||
const Icons = {
|
||||
package: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m7.5 4.27 9 5.15"/>
|
||||
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/>
|
||||
<path d="m3.3 7 8.7 5 8.7-5"/>
|
||||
<path d="M12 22V12"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export default async function AdminProductsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
@@ -13,18 +21,17 @@ export default async function AdminProductsPage() {
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
|
||||
if (!adminUser.can_manage_products) {
|
||||
redirect("/admin/pickup");
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-red-600">Access Denied</h1>
|
||||
<p className="mt-2 text-sm text-stone-500">You do not have permission to manage products.</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const brandId =
|
||||
adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [settingsResult] = await Promise.all([
|
||||
getPaymentSettings(brandId),
|
||||
]);
|
||||
const hasSquareToken = !!(
|
||||
settingsResult.success && settingsResult.settings?.square_access_token
|
||||
);
|
||||
const brandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
let query = supabase
|
||||
.from("products")
|
||||
@@ -38,15 +45,12 @@ export default async function AdminProductsPage() {
|
||||
deleted_at,
|
||||
image_url,
|
||||
brand_id,
|
||||
is_taxable,
|
||||
brands (
|
||||
name
|
||||
)
|
||||
is_taxable
|
||||
`)
|
||||
.is("deleted_at", null)
|
||||
.order("name");
|
||||
|
||||
if (adminUser?.brand_id) {
|
||||
if (adminUser.brand_id) {
|
||||
query = query.eq("brand_id", adminUser.brand_id);
|
||||
}
|
||||
|
||||
@@ -54,15 +58,10 @@ export default async function AdminProductsPage() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<nav className="mb-6 flex items-center gap-2 text-xs text-stone-500">
|
||||
<Link href="/admin" className="hover:text-stone-600 transition-colors">Admin</Link>
|
||||
<span>/</span>
|
||||
<span className="text-stone-600">Products</span>
|
||||
</nav>
|
||||
<h1 className="text-3xl font-bold text-red-600">Error loading products</h1>
|
||||
<pre className="mt-4 rounded-xl bg-white border border-stone-200 p-4 text-sm text-stone-600">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-red-600">Error loading products</h1>
|
||||
<pre className="mt-4 rounded-xl bg-white border border-[var(--admin-border)] p-4 text-sm text-stone-600">
|
||||
{error.message}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -71,36 +70,26 @@ export default async function AdminProductsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-6 flex items-center gap-2 text-xs text-stone-500">
|
||||
<Link href="/admin" className="hover:text-stone-600 transition-colors">Admin</Link>
|
||||
<span>/</span>
|
||||
<span className="text-stone-600">Products</span>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-stone-950 tracking-tight">Products</h1>
|
||||
<p className="mt-2 text-sm text-stone-500">
|
||||
Manage your product catalog.
|
||||
</p>
|
||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||
{/* Header */}
|
||||
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-xl bg-emerald-600">
|
||||
{Icons.package("h-5 w-5 sm:h-6 sm:w-6 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-black text-[var(--admin-text-primary)] tracking-tight">Products</h1>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Manage your product catalog</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/admin/products/new"
|
||||
className="rounded-xl bg-emerald-600 hover:bg-emerald-500 active:bg-emerald-700 px-5 py-3 font-semibold text-white text-sm transition-colors shadow-sm"
|
||||
>
|
||||
Add Product
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 overflow-hidden rounded-2xl bg-white border border-stone-200 shadow-sm">
|
||||
<ProductSyncBanner brandId={brandId} hasSquareToken={hasSquareToken} />
|
||||
<ProductTableClient products={products ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<ProductsClient products={products ?? []} brandId={brandId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
getRecentLotEvents,
|
||||
} from "@/actions/route-trace/lots";
|
||||
|
||||
export const metadata = {
|
||||
title: "Lot Lookup | Route Trace",
|
||||
description: "Search and lookup harvest lots by lot number, bin ID, or container ID. Quick traceability search for produce distribution.",
|
||||
keywords: ["lot lookup", "search lots", "find lot", "traceability search", "lot number search", "bin lookup"],
|
||||
};
|
||||
|
||||
export default async function LookupPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import { getRouteTraceLotDetail, getLotOrders } from "@/actions/route-trace/lots";
|
||||
import LotDetailPanel from "@/components/route-trace/LotDetailPanel";
|
||||
import RouteTraceNav from "@/components/route-trace/RouteTraceNav";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const detailResult = await getRouteTraceLotDetail(id);
|
||||
const lotNumber = detailResult.success && detailResult.lot ? detailResult.lot.lot_number : null;
|
||||
|
||||
return {
|
||||
title: lotNumber ? `Lot ${lotNumber} | Route Trace` : "Lot Detail | Route Trace",
|
||||
description: lotNumber
|
||||
? `View complete traceability details for harvest lot ${lotNumber}. Track events, order fulfillment, and FSMA compliance records.`
|
||||
: "View harvest lot traceability details and order fulfillment information.",
|
||||
keywords: lotNumber ? [`lot ${lotNumber}`, "lot detail", "traceability", "harvest lot", "lot events"] : ["lot detail", "traceability", "harvest lot"],
|
||||
};
|
||||
}
|
||||
|
||||
export default async function LotDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
const isDevMode = !!devSession;
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||
const enabled = isDevMode ? true : await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
|
||||
|
||||
const [detailResult, ordersResult] = await Promise.all([
|
||||
@@ -22,11 +41,11 @@ export default async function LotDetailPage({ params }: { params: Promise<{ id:
|
||||
|
||||
if (!detailResult.success || !detailResult.lot) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 px-6 py-10">
|
||||
<div className="min-h-screen bg-stone-50 px-6 py-10">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="rounded-xl border border-red-200 bg-zinc-900 p-6 text-center">
|
||||
<div className="rounded-xl border border-red-200 bg-white p-6 text-center">
|
||||
<p className="text-red-600">Lot not found</p>
|
||||
<a href="/admin/route-trace/lots" className="mt-3 inline-block text-sm text-blue-400 hover:underline">← Back to Lots</a>
|
||||
<a href="/admin/route-trace/lots" className="mt-3 inline-block text-sm text-stone-500 hover:text-stone-700">← Back to Lots</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,10 +53,10 @@ export default async function LotDetailPage({ params }: { params: Promise<{ id:
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 px-6 py-10">
|
||||
<div className="min-h-screen bg-stone-50 px-6 py-10">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="mb-6">
|
||||
<a href="/admin/route-trace/lots" className="text-sm text-zinc-500 hover:text-zinc-300">← Back to Lots</a>
|
||||
<a href="/admin/route-trace/lots" className="text-sm text-stone-500 hover:text-stone-700">← Back to Lots</a>
|
||||
</div>
|
||||
<RouteTraceNav activeTab="lots" />
|
||||
<LotDetailPanel
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import LotCreateForm from "@/components/route-trace/LotCreateForm";
|
||||
import RouteTraceNav from "@/components/route-trace/RouteTraceNav";
|
||||
|
||||
export const metadata = {
|
||||
title: "Create New Lot | Route Trace",
|
||||
description: "Create a new harvest lot for traceability tracking. Record crop type, field location, worker, quantity, and harvest date.",
|
||||
keywords: ["create lot", "new lot", "harvest entry", "lot creation", "traceability", "harvest tracking"],
|
||||
};
|
||||
|
||||
export default async function NewLotPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
const isDevMode = !!devSession;
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||
const enabled = isDevMode ? true : await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 px-6 py-10">
|
||||
<div className="min-h-screen bg-stone-50 px-6 py-10">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="mb-6">
|
||||
<a href="/admin/route-trace/lots" className="text-sm text-zinc-500 hover:text-zinc-300">← Back to Lots</a>
|
||||
<a href="/admin/route-trace/lots" className="text-sm text-stone-500 hover:text-stone-700">← Back to Lots</a>
|
||||
</div>
|
||||
<RouteTraceNav activeTab="lots" />
|
||||
<LotCreateForm brandId={effectiveBrandId} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import { getRouteTraceLots } from "@/actions/route-trace/lots";
|
||||
@@ -11,13 +12,23 @@ import {
|
||||
getRecentLotEvents,
|
||||
} from "@/actions/route-trace/lots";
|
||||
|
||||
export const metadata = {
|
||||
title: "Harvest Lots | Route Trace",
|
||||
description: "View and manage all harvest lots. Track active lots, in-transit shipments, and completed deliveries across your produce distribution network.",
|
||||
keywords: ["harvest lots", "lot management", "lot tracking", "produce lots", "harvest tracking", "field lots"],
|
||||
};
|
||||
|
||||
export default async function LotsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
const isDevMode = !!devSession;
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||
const enabled = isDevMode ? true : await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
|
||||
|
||||
const [statsResult, lotsResult, haulingResult, yieldResult, invResult, eventsResult] = await Promise.all([
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import {
|
||||
@@ -11,13 +12,43 @@ import {
|
||||
} from "@/actions/route-trace/lots";
|
||||
import RouteTracePage from "@/components/route-trace/RouteTracePage";
|
||||
|
||||
export const metadata = {
|
||||
title: "Route Trace | Harvest Traceability Dashboard",
|
||||
description: "Track produce lots from field to delivery. Manage harvest events, hauling logistics, inventory by crop, and FSMA compliance reporting for fresh produce distribution.",
|
||||
keywords: ["route trace", "harvest traceability", "lot tracking", "farm to fork", "FSMA compliance", "food safety", "produce distribution", "haul tracking", "inventory management"],
|
||||
openGraph: {
|
||||
title: "Route Trace | Harvest Traceability",
|
||||
description: "Complete lot traceability from field to delivery with real-time tracking and FSMA compliance.",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
// Route Trace icon component
|
||||
const Icons = {
|
||||
routeTrace: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 3v18h18" />
|
||||
<path d="M7 16l4-8 4 5 5-9" />
|
||||
<circle cx="7" cy="16" r="1.5" fill="currentColor" />
|
||||
<circle cx="11" cy="8" r="1.5" fill="currentColor" />
|
||||
<circle cx="15" cy="13" r="1.5" fill="currentColor" />
|
||||
<circle cx="20" cy="4" r="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export default async function RouteTraceDashboardPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
const isDevMode = !!devSession;
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||
// Bypass feature check in dev mode (dev_session cookie present)
|
||||
const enabled = isDevMode ? true : await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
|
||||
|
||||
const [statsResult, lotsResult, haulingResult, yieldResult, invResult, eventsResult] = await Promise.all([
|
||||
@@ -40,15 +71,37 @@ export default async function RouteTraceDashboardPage() {
|
||||
const recentActivity = eventsResult.success ? eventsResult.events : [];
|
||||
|
||||
return (
|
||||
<RouteTracePage
|
||||
stats={stats}
|
||||
recentLots={recentLots}
|
||||
haulingLots={haulingLots}
|
||||
fieldYield={fieldYield}
|
||||
inventoryByCrop={inventoryByCrop}
|
||||
recentActivity={recentActivity}
|
||||
brandId={effectiveBrandId}
|
||||
lots={allLots}
|
||||
/>
|
||||
<main className="min-h-screen bg-[var(--admin-bg)]">
|
||||
{/* Header */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-start justify-between gap-4 mb-4 sm:mb-6">
|
||||
<div className="space-y-1 flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-xl bg-emerald-600">
|
||||
{Icons.routeTrace("h-5 w-5 sm:h-6 sm:w-6 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-black text-[var(--admin-text-primary)] tracking-tight">Route Trace</h1>
|
||||
<p className="text-xs sm:text-sm text-[var(--admin-text-muted)]">Field-to-delivery lot traceability</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
||||
<RouteTracePage
|
||||
stats={stats}
|
||||
recentLots={recentLots}
|
||||
haulingLots={haulingLots}
|
||||
fieldYield={fieldYield}
|
||||
inventoryByCrop={inventoryByCrop}
|
||||
recentActivity={recentActivity}
|
||||
brandId={effectiveBrandId}
|
||||
lots={allLots}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
getRecentLotEvents,
|
||||
} from "@/actions/route-trace/lots";
|
||||
|
||||
export const metadata = {
|
||||
title: "Route Trace Settings",
|
||||
description: "Configure Route Trace traceability workflow settings, defaults, and add-on features for your produce distribution operation.",
|
||||
keywords: ["route trace settings", "traceability configuration", "FSMA settings", "traceability defaults"],
|
||||
};
|
||||
|
||||
export default async function RouteTraceSettingsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,12 +2,13 @@ import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getAIProviderSettings } from "@/actions/integrations/ai-providers";
|
||||
import AIClient from "./AIClient";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
|
||||
export default async function AISettingsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
|
||||
const brandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const brandId = adminUser.brand_id ?? "";
|
||||
|
||||
const settings = await getAIProviderSettings(brandId);
|
||||
const isConnected = !!settings.apiKey;
|
||||
@@ -19,6 +20,9 @@ export default async function AISettingsPage() {
|
||||
isConnected={isConnected}
|
||||
brandId={brandId}
|
||||
brandName={brandName}
|
||||
provider={settings.provider}
|
||||
model={settings.model}
|
||||
customEndpoint={settings.customEndpoint}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,22 @@ import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { ADDON_CATALOG, isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import BrandFeatureCards from "@/components/admin/BrandFeatureCards";
|
||||
import { AdminPageHeader } from "@/components/admin/design-system";
|
||||
|
||||
export default async function AppsSettingsPage() {
|
||||
type Props = {
|
||||
searchParams: Promise<{ reason?: string }>;
|
||||
};
|
||||
|
||||
export default async function AppsSettingsPage({ searchParams }: Props) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const params = await searchParams;
|
||||
const reason = params.reason;
|
||||
|
||||
const brandId = adminUser.brand_id ?? "";
|
||||
|
||||
const enabledFeatures: Record<string, boolean> = {};
|
||||
@@ -17,30 +25,73 @@ export default async function AppsSettingsPage() {
|
||||
enabledFeatures[key] = await isFeatureEnabled(brandId, key);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
|
||||
<a href="/admin" className="hover:text-stone-800 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-800">Add-ons</span>
|
||||
</nav>
|
||||
const featureNames: Record<string, string> = {
|
||||
route_trace: "Route Trace",
|
||||
wholesale_portal: "Wholesale Portal",
|
||||
harvest_reach: "Harvest Reach",
|
||||
water_log: "Water Log",
|
||||
ai_tools: "AI Tools",
|
||||
sms_campaigns: "SMS Campaigns",
|
||||
square_sync: "Square Sync",
|
||||
};
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-stone-200 border border-stone-300">
|
||||
<svg className="h-5 w-5 text-stone-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.337-1.219 23.75 23.75 0 00-5.337-1.219 23.848 23.848 0 005.337 1.219zM10.5 2.25a2.25 2.25 0 013.165 1.453 2.25 2.25 0 00-1.453 3.165m-6.197 6.197A3.5 3.5 0 017.5 12.75v1.5a3.5 3.5 0 001.5 2.625h1.5a3.5 3.5 0 001.5-2.625V13.5m-6.197 6.197a3.5 3.5 0 001.5 2.625m0 0v1.5a3.5 3.5 0 01-1.5 2.625H5.25m0 0H3.75a2.25 2.25 0 00-2.25 2.25v1.5a2.25 2.25 0 002.25 2.25h1.5m0 0A3.5 3.5 0 0112.75 19.5v1.5a3.5 3.5 0 01-3.5 3.5H8.25m0 0H6.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-stone-950">Add-ons</h1>
|
||||
return (
|
||||
<main className="min-h-screen admin-section px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className="mx-auto max-w-5xl">
|
||||
{/* Header with icon */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div
|
||||
className="flex h-12 w-12 items-center justify-center rounded-xl"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(16, 185, 129, 0.08) 100%)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
}}
|
||||
>
|
||||
<svg className="h-6 w-6" style={{ color: '#059669' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09.542.56.94 1.11.94h2.64c.55 0 1.02-.398 1.11-.94l.213-1.999c.018-.158.04-.315.062-.472a.563.563 0 00-.122-.519l-.79-2.758A.562.562 0 0014.56 0H9.44a.563.563 0 00-.424.264l-.79 2.758a.563.563 0 00-.122.519c.022.157.044.314.062.472l.213 1.999z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-2 text-stone-500">
|
||||
Enable or disable add-on features for your brand. Changes take effect immediately.
|
||||
</p>
|
||||
<AdminPageHeader
|
||||
breadcrumb={[{ label: "Admin", href: "/admin" }, { label: "Settings" }, { label: "Add-ons" }]}
|
||||
title="Add-ons"
|
||||
description="Enable features to extend your platform capabilities"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reason banner */}
|
||||
{reason && featureNames[reason] && (
|
||||
<div
|
||||
className="mb-6 rounded-xl p-4"
|
||||
style={{
|
||||
background: 'rgba(16, 185, 129, 0.08)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(16, 185, 129, 0.15)',
|
||||
}}
|
||||
>
|
||||
<svg className="h-5 w-5" style={{ color: '#059669' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-stone-950">
|
||||
{featureNames[reason]} is not enabled
|
||||
</h2>
|
||||
<p className="text-sm text-stone-500">
|
||||
Enable the {featureNames[reason]} add-on below to access this feature.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feature cards */}
|
||||
<BrandFeatureCards brandId={brandId} initialEnabledFeatures={enabledFeatures} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -3,6 +3,11 @@ import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getAdminUsers, getBrands } from "@/actions/admin/users";
|
||||
import SettingsClient from "@/components/admin/SettingsClient";
|
||||
|
||||
export const metadata = {
|
||||
title: "Settings - Route Commerce Admin",
|
||||
description: "Manage your brand settings, workers, tasks, and user permissions",
|
||||
};
|
||||
|
||||
export default async function AdminSettingsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
@@ -14,28 +19,16 @@ export default async function AdminSettingsPage() {
|
||||
getBrands(),
|
||||
]);
|
||||
|
||||
// Breadcrumb nav (for page context)
|
||||
const breadcrumb = (
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-3">
|
||||
<a href="/admin" className="hover:text-stone-600 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-600">Settings</span>
|
||||
</nav>
|
||||
);
|
||||
|
||||
return (
|
||||
<main>
|
||||
{breadcrumb}
|
||||
<SettingsClient
|
||||
brandId={brandId}
|
||||
users={error ? [] : users}
|
||||
brands={brands}
|
||||
currentUser={{
|
||||
id: adminUser.id ?? adminUser.user_id,
|
||||
role: adminUser.role,
|
||||
can_manage_users: adminUser.can_manage_users,
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
<SettingsClient
|
||||
brandId={brandId}
|
||||
users={error ? [] : users}
|
||||
brands={brands}
|
||||
currentUser={{
|
||||
id: adminUser.id ?? adminUser.user_id,
|
||||
role: adminUser.role,
|
||||
can_manage_users: adminUser.can_manage_users,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -45,48 +45,59 @@ export default async function AdminStopsPage() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
|
||||
<a href="/admin" className="hover:text-stone-600 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-600">Tour Stops</span>
|
||||
</nav>
|
||||
<h1 className="text-3xl font-bold text-red-600">
|
||||
Error loading stops
|
||||
</h1>
|
||||
<pre className="mt-4 rounded-xl bg-white border border-stone-200 p-4 text-sm text-stone-600">
|
||||
{error.message}
|
||||
</pre>
|
||||
<main className="min-h-screen bg-[var(--admin-bg)]">
|
||||
<div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<nav className="flex items-center gap-2 text-xs sm:text-sm mb-6">
|
||||
<a href="/admin" className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]">Admin</a>
|
||||
<span className="text-[var(--admin-text-muted)]">/</span>
|
||||
<span className="text-[var(--admin-text-primary)] font-medium">Stops & Routes</span>
|
||||
</nav>
|
||||
<h1 className="text-2xl sm:text-3xl font-black text-red-600 tracking-tight">
|
||||
Error loading stops
|
||||
</h1>
|
||||
<pre className="mt-4 rounded-xl bg-white border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-secondary)]">
|
||||
{error.message}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
|
||||
<a href="/admin" className="hover:text-stone-600 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-600">Tour Stops</span>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-stone-950 tracking-tight">Tour Stops</h1>
|
||||
<p className="mt-2 text-sm text-stone-500">
|
||||
{adminUser?.brand_id
|
||||
? "Managing stops for your brand."
|
||||
: "Manage routes, pickup locations, dates, and cutoff times."}
|
||||
</p>
|
||||
<main className="min-h-screen bg-[var(--admin-bg)]">
|
||||
{/* Header */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-xl bg-emerald-600">
|
||||
<svg className="h-5 w-5 sm:h-6 sm:w-6 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/>
|
||||
<circle cx="12" cy="10" r="3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-black text-[var(--admin-text-primary)] tracking-tight">Stops & Routes</h1>
|
||||
<p className="text-xs sm:text-sm text-[var(--admin-text-muted)]">
|
||||
{adminUser?.brand_id ? "Managing stops for your brand." : "Manage routes, pickup locations, dates, and cutoff times."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StopsHeaderActions brandId={adminUser?.brand_id ?? ""} />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 overflow-hidden rounded-2xl bg-white border border-stone-200 shadow-sm">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 mt-4 text-xs sm:text-sm">
|
||||
<a href="/admin" className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]">Admin</a>
|
||||
<span className="text-[var(--admin-text-muted)]">/</span>
|
||||
<span className="text-[var(--admin-text-primary)] font-medium">Stops & Routes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-sm">
|
||||
<StopTableClient stops={stops ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import TimeTrackingAdminPanel from "@/components/admin/TimeTrackingAdminPanel";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
@@ -9,23 +10,29 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminTimeTrackingPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
// Dev session bypass for platform admin
|
||||
if (!adminUser) {
|
||||
const cookieStore = await cookies();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
if (devSession === "platform_admin" || devSession === "brand_admin") {
|
||||
// Allow access in dev mode
|
||||
} else {
|
||||
redirect("/login");
|
||||
}
|
||||
}
|
||||
|
||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
||||
const isTuxedoAdmin = adminUser.role === "brand_admin" && adminUser.brand_id === TUXEDO_BRAND_ID;
|
||||
const isIRDAdmin = adminUser.role === "brand_admin" && adminUser.brand_id === IRD_BRAND_ID;
|
||||
const isPlatformAdmin = adminUser?.role === "platform_admin";
|
||||
const isTuxedoAdmin = adminUser?.role === "brand_admin" && adminUser?.brand_id === TUXEDO_BRAND_ID;
|
||||
const isIRDAdmin = adminUser?.role === "brand_admin" && adminUser?.brand_id === IRD_BRAND_ID;
|
||||
|
||||
if (!isPlatformAdmin && !isTuxedoAdmin && !isIRDAdmin) {
|
||||
redirect("/admin/pickup");
|
||||
}
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? TUXEDO_BRAND_ID;
|
||||
const effectiveBrandId = adminUser?.brand_id ?? TUXEDO_BRAND_ID;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<TimeTrackingAdminPanel brandId={effectiveBrandId} />
|
||||
</div>
|
||||
</main>
|
||||
<TimeTrackingAdminPanel brandId={effectiveBrandId} />
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function TimeTrackingSettingsRedirect() {
|
||||
redirect("/admin/settings#workers");
|
||||
}
|
||||
redirect("/admin/time-tracking");
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const SUPABASE_PAT = process.env.SUPABASE_PAT!;
|
||||
|
||||
interface LotRow {
|
||||
lot_id: string;
|
||||
lot_number: string;
|
||||
crop_type: string;
|
||||
variety: string | null;
|
||||
harvest_date: string;
|
||||
field_location: string | null;
|
||||
field_block: string | null;
|
||||
worker_name: string | null;
|
||||
packer_name: string | null;
|
||||
quantity_lbs: number | null;
|
||||
quantity_used_lbs: number | null;
|
||||
yield_estimate_lbs: number | null;
|
||||
yield_unit: string | null;
|
||||
bin_id: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface EventRow {
|
||||
lot_id: string;
|
||||
event_type: string;
|
||||
event_time: string;
|
||||
location: string | null;
|
||||
notes: string | null;
|
||||
bin_id: string | null;
|
||||
created_by_name: string | null;
|
||||
}
|
||||
|
||||
async function adminFetchJson(endpoint: string, body: unknown) {
|
||||
const res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(SUPABASE_PAT),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function assessCompliance(lot: LotRow, eventCount: number): {
|
||||
compliance_status: "compliant" | "non_compliant" | "pending";
|
||||
issues: string[];
|
||||
} {
|
||||
const issues: string[] = [];
|
||||
|
||||
// Check for required traceability fields
|
||||
if (!lot.harvest_date) {
|
||||
issues.push("Missing harvest date");
|
||||
}
|
||||
if (!lot.field_location) {
|
||||
issues.push("Missing field location");
|
||||
}
|
||||
if (!lot.quantity_lbs || lot.quantity_lbs <= 0) {
|
||||
issues.push("Missing quantity");
|
||||
}
|
||||
|
||||
// Check traceability chain
|
||||
if (eventCount === 0) {
|
||||
issues.push("No trace events");
|
||||
}
|
||||
|
||||
// Check for worker/packer info
|
||||
if (!lot.worker_name && !lot.packer_name) {
|
||||
issues.push("No worker/packer info");
|
||||
}
|
||||
|
||||
// Check yield variance
|
||||
if (lot.yield_estimate_lbs && lot.yield_estimate_lbs > 0 && lot.quantity_lbs) {
|
||||
const variance = Math.abs((lot.quantity_lbs - lot.yield_estimate_lbs) / lot.yield_estimate_lbs);
|
||||
if (variance > 0.2) {
|
||||
issues.push("High yield variance");
|
||||
}
|
||||
}
|
||||
|
||||
// Determine compliance status
|
||||
let compliance_status: "compliant" | "non_compliant" | "pending";
|
||||
if (issues.length === 0) {
|
||||
compliance_status = "compliant";
|
||||
} else if (issues.some(i => i === "No trace events" || i === "Missing harvest date")) {
|
||||
compliance_status = "non_compliant";
|
||||
} else {
|
||||
compliance_status = "pending";
|
||||
}
|
||||
|
||||
return { compliance_status, issues };
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const brandId = searchParams.get("brandId");
|
||||
const startDate = searchParams.get("startDate");
|
||||
const endDate = searchParams.get("endDate");
|
||||
|
||||
if (!brandId || !startDate || !endDate) {
|
||||
return new Response(JSON.stringify({ error: "Missing brandId, startDate, or endDate" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch all lots for the brand
|
||||
const allLots = await adminFetchJson("get_harvest_lots", {
|
||||
p_brand_id: brandId,
|
||||
p_status: null,
|
||||
});
|
||||
|
||||
const lots: LotRow[] = (allLots ?? []) as LotRow[];
|
||||
|
||||
// Filter by date range
|
||||
const filteredLots = lots.filter((lot) => {
|
||||
const harvestDate = lot.harvest_date;
|
||||
if (!harvestDate) return false;
|
||||
return harvestDate >= startDate && harvestDate <= endDate;
|
||||
});
|
||||
|
||||
if (filteredLots.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
lots: [],
|
||||
summary: {
|
||||
total_lots: 0,
|
||||
compliant: 0,
|
||||
non_compliant: 0,
|
||||
pending: 0,
|
||||
total_weight: 0,
|
||||
used_weight: 0,
|
||||
remaining_weight: 0,
|
||||
crops: [],
|
||||
},
|
||||
}),
|
||||
{ headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch events for all lots
|
||||
const lotIds = filteredLots.map((l) => l.lot_id);
|
||||
const eventsRaw = await adminFetchJson("get_events_for_lots", { p_lot_ids: lotIds });
|
||||
const allEvents: EventRow[] = (eventsRaw ?? []) as EventRow[];
|
||||
|
||||
// Group events by lot
|
||||
const eventsByLot: Record<string, EventRow[]> = {};
|
||||
for (const evt of allEvents) {
|
||||
if (!eventsByLot[evt.lot_id]) eventsByLot[evt.lot_id] = [];
|
||||
eventsByLot[evt.lot_id].push(evt);
|
||||
}
|
||||
|
||||
// Process each lot for compliance
|
||||
const complianceLots = filteredLots.map((lot) => {
|
||||
const events = eventsByLot[lot.lot_id] ?? [];
|
||||
const { compliance_status, issues } = assessCompliance(lot, events.length);
|
||||
|
||||
return {
|
||||
lot_id: lot.lot_id,
|
||||
lot_number: lot.lot_number,
|
||||
crop_type: lot.crop_type,
|
||||
variety: lot.variety,
|
||||
harvest_date: lot.harvest_date,
|
||||
field_location: lot.field_location,
|
||||
field_block: lot.field_block,
|
||||
worker_name: lot.worker_name,
|
||||
packer_name: lot.packer_name,
|
||||
quantity_lbs: lot.quantity_lbs,
|
||||
quantity_used_lbs: lot.quantity_used_lbs,
|
||||
yield_estimate_lbs: lot.yield_estimate_lbs,
|
||||
yield_unit: lot.yield_unit,
|
||||
bin_id: lot.bin_id,
|
||||
status: lot.status,
|
||||
event_count: events.length,
|
||||
has_traceability: events.length > 0,
|
||||
compliance_status,
|
||||
issues,
|
||||
};
|
||||
});
|
||||
|
||||
// Calculate summary
|
||||
const summary = {
|
||||
total_lots: complianceLots.length,
|
||||
compliant: complianceLots.filter((l) => l.compliance_status === "compliant").length,
|
||||
non_compliant: complianceLots.filter((l) => l.compliance_status === "non_compliant").length,
|
||||
pending: complianceLots.filter((l) => l.compliance_status === "pending").length,
|
||||
total_weight: filteredLots.reduce((s, l) => s + (l.quantity_lbs ?? 0), 0),
|
||||
used_weight: filteredLots.reduce((s, l) => s + (l.quantity_used_lbs ?? 0), 0),
|
||||
remaining_weight: filteredLots.reduce(
|
||||
(s, l) => s + Math.max(0, (l.quantity_lbs ?? 0) - (l.quantity_used_lbs ?? 0)),
|
||||
0
|
||||
),
|
||||
crops: [...new Set(filteredLots.map((l) => l.crop_type))],
|
||||
};
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
lots: complianceLots,
|
||||
summary,
|
||||
}),
|
||||
{ headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
@@ -54,18 +54,25 @@ export async function GET(request: Request) {
|
||||
return new Response("Missing brandId, startDate, or endDate", { status: 400 });
|
||||
}
|
||||
|
||||
const lotsRaw = await adminFetchJson("get_lots_for_period", {
|
||||
// Use existing get_harvest_lots RPC and filter by date in JavaScript
|
||||
const allLots = await adminFetchJson("get_harvest_lots", {
|
||||
p_brand_id: brandId,
|
||||
p_start_date: startDate,
|
||||
p_end_date: endDate,
|
||||
p_status: null,
|
||||
});
|
||||
|
||||
const lots: LotRow[] = (lotsRaw ?? []) as LotRow[];
|
||||
if (!lots || lots.length === 0) {
|
||||
const lots: LotRow[] = (allLots ?? []) as LotRow[];
|
||||
// Filter by date range
|
||||
const filteredLots = lots.filter(lot => {
|
||||
const harvestDate = lot.harvest_date;
|
||||
if (!harvestDate) return false;
|
||||
return harvestDate >= startDate && harvestDate <= endDate;
|
||||
});
|
||||
|
||||
if (filteredLots.length === 0) {
|
||||
return new Response("No lots found for this period", { status: 404 });
|
||||
}
|
||||
|
||||
const lotIds = lots.map((l) => l.lot_id);
|
||||
const lotIds = filteredLots.map((l) => l.lot_id);
|
||||
const eventsRaw = await adminFetchJson("get_events_for_lots", { p_lot_ids: lotIds });
|
||||
const allEvents: EventRow[] = (eventsRaw ?? []) as EventRow[];
|
||||
|
||||
@@ -81,16 +88,16 @@ export async function GET(request: Request) {
|
||||
lines.push(`Brand ID,${brandId}`);
|
||||
lines.push(`Report Period,${startDate} to ${endDate}`);
|
||||
lines.push(`Generated,${new Date().toLocaleString("en-US")}`);
|
||||
lines.push(`Total Lots,${lots.length}`);
|
||||
lines.push(`Total Lots,${filteredLots.length}`);
|
||||
lines.push("");
|
||||
|
||||
const totalQty = lots.reduce((s, l) => s + (l.quantity_lbs ?? 0), 0);
|
||||
const totalUsed = lots.reduce((s, l) => s + (l.quantity_used_lbs ?? 0), 0);
|
||||
const crops = [...new Set(lots.map((l) => l.crop_type))];
|
||||
const units = [...new Set(lots.map((l) => l.yield_unit ?? "lbs"))];
|
||||
const totalQty = filteredLots.reduce((s, l) => s + (l.quantity_lbs ?? 0), 0);
|
||||
const totalUsed = filteredLots.reduce((s, l) => s + (l.quantity_used_lbs ?? 0), 0);
|
||||
const crops = [...new Set(filteredLots.map((l) => l.crop_type))];
|
||||
const units = [...new Set(filteredLots.map((l) => l.yield_unit ?? "lbs"))];
|
||||
const primaryUnit = units.length === 1 ? units[0] : "lbs";
|
||||
lines.push("SUMMARY");
|
||||
lines.push(`Total Lots Harvested,${lots.length}`);
|
||||
lines.push(`Total Lots Harvested,${filteredLots.length}`);
|
||||
lines.push(`Total Weight (${primaryUnit}),${totalQty.toLocaleString()}`);
|
||||
lines.push(`Total Used (${primaryUnit}),${totalUsed.toLocaleString()}`);
|
||||
lines.push(`Remaining (${primaryUnit}),${(totalQty - totalUsed).toLocaleString()}`);
|
||||
@@ -102,7 +109,7 @@ export async function GET(request: Request) {
|
||||
lines.push("ONE-UP / ONE-DOWN TRACEABILITY");
|
||||
lines.push("Lot Number,Crop,Variety,Harvest Date,Field,Block,Worker,Packer,Qty (lbs),Used (lbs),Remaining (lbs),Yield Est. (lbs),Yield Unit,Yield Variance %,Bin ID,Status,Event,Event Time,Location,Bin ID,Notes,Recorded By");
|
||||
|
||||
for (const lot of lots) {
|
||||
for (const lot of filteredLots) {
|
||||
const evts = eventsByLot[lot.lot_id] ?? [];
|
||||
const remaining = (lot.quantity_lbs ?? 0) - (lot.quantity_used_lbs ?? 0);
|
||||
if (evts.length === 0) {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { savePaymentSettings } from "@/actions/payments";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser || !adminUser.brand_id) {
|
||||
return NextResponse.redirect(new URL("/admin/settings/payments?error=unauthorized", req.url));
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const code = url.searchParams.get("code");
|
||||
const error = url.searchParams.get("error");
|
||||
const state = url.searchParams.get("state");
|
||||
|
||||
// Handle error from Stripe
|
||||
if (error) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`/admin/settings/payments?stripe_error=${encodeURIComponent(error)}`, req.url)
|
||||
);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/admin/settings/payments?stripe_error=no_code", req.url)
|
||||
);
|
||||
}
|
||||
|
||||
// Decode state to get brandId
|
||||
let brandId = adminUser.brand_id;
|
||||
if (state) {
|
||||
try {
|
||||
const decoded = JSON.parse(Buffer.from(state, "base64").toString());
|
||||
if (decoded.brandId) {
|
||||
brandId = decoded.brandId;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Exchange code for access token
|
||||
const clientId = process.env.STRIPE_CLIENT_ID;
|
||||
const clientSecret = process.env.STRIPE_CLIENT_SECRET;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/admin/settings/payments?stripe_error=oauth_not_configured", req.url)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Exchange authorization code for access token
|
||||
const tokenResponse = await fetch("https://connect.stripe.com/oauth/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
const tokenData = await tokenResponse.json();
|
||||
|
||||
if (tokenData.error) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`/admin/settings/payments?stripe_error=${encodeURIComponent(tokenData.error_description || tokenData.error)}`, req.url)
|
||||
);
|
||||
}
|
||||
|
||||
// Save the Stripe credentials
|
||||
const stripeUserId = tokenData.stripe_user_id; // This is the connected account ID
|
||||
const accessToken = tokenData.access_token;
|
||||
const publishableKey = tokenData.stripe_publishable_key;
|
||||
|
||||
// Save to database via server action
|
||||
const result = await savePaymentSettings({
|
||||
brandId,
|
||||
provider: "stripe",
|
||||
stripePublishableKey: publishableKey || undefined,
|
||||
stripeSecretKey: accessToken, // Store access token as secret key
|
||||
stripeUserId, // Store the connected account ID
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/admin/settings/payments?stripe_connected=true", req.url)
|
||||
);
|
||||
} else {
|
||||
return NextResponse.redirect(
|
||||
new URL(`/admin/settings/payments?stripe_error=${encodeURIComponent(result.error || "Failed to save")}`, req.url)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`/admin/settings/payments?stripe_error=${encodeURIComponent("Failed to complete OAuth")}`, req.url)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser || !adminUser.brand_id) {
|
||||
return NextResponse.redirect(new URL("/admin/settings/payments?error=unauthorized", req.url));
|
||||
}
|
||||
if (!adminUser.can_manage_settings) {
|
||||
return NextResponse.redirect(new URL("/admin/settings/payments?error=forbidden", req.url));
|
||||
}
|
||||
|
||||
const clientId = process.env.STRIPE_CLIENT_ID;
|
||||
const clientSecret = process.env.STRIPE_CLIENT_SECRET;
|
||||
const env = process.env.STRIPE_ENVIRONMENT ?? "test";
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/admin/settings/payments?error=stripe_oauth_not_configured", req.url)
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = env === "production"
|
||||
? "https://connect.stripe.com"
|
||||
: "https://connect.stripe.com/oauth/authorize";
|
||||
|
||||
const origin = new URL(req.url).origin;
|
||||
const redirectUri = `${origin}/api/stripe/oauth/callback`;
|
||||
|
||||
// Encode brand_id in state so callback knows which brand to associate
|
||||
const state = Buffer.from(JSON.stringify({ brandId: adminUser.brand_id })).toString("base64");
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: clientId,
|
||||
scope: "read_only", // Use read_only for safety - can upgrade to full later
|
||||
redirect_uri: redirectUri,
|
||||
state,
|
||||
});
|
||||
|
||||
return NextResponse.redirect(`${baseUrl}?${params}`);
|
||||
}
|
||||
@@ -3,6 +3,21 @@ import { getTraceChain } from "@/actions/route-trace/lots";
|
||||
import ShareTraceButton from "@/components/route-trace/ShareTraceButton";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ lotNumber: string }> }) {
|
||||
const { lotNumber } = await params;
|
||||
|
||||
return {
|
||||
title: `Lot ${lotNumber} | Fresh Produce Traceability`,
|
||||
description: `View the complete traceability record for harvest lot ${lotNumber}. Track fresh produce from field to delivery with full supply chain transparency.`,
|
||||
keywords: [`lot ${lotNumber}`, "produce traceability", "farm to fork", "harvest lot", "fresh produce", "food safety", "supply chain"],
|
||||
openGraph: {
|
||||
title: `Lot ${lotNumber} | Fresh Produce Traceability`,
|
||||
description: `Complete farm-to-fork traceability record. View harvest details, supply chain events, and delivery information for lot ${lotNumber}.`,
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const EVENT_ICONS: Record<string, string> = {
|
||||
harvested: "🌱",
|
||||
field_packed: "📦",
|
||||
|
||||
@@ -11,12 +11,83 @@ const PROVIDER_MODELS: Record<Exclude<AIProvider, "custom">, string[]> = {
|
||||
xai: ["grok-2", "grok-2-mini", "grok-1.5"],
|
||||
};
|
||||
|
||||
const PROVIDERS: { id: AIProvider; name: string; icon: string; description: string; website: string }[] = [
|
||||
{ id: "openai", name: "OpenAI", icon: "🤖", description: "GPT-4o, GPT-4o-mini, GPT-4 Turbo. Industry standard for general AI tasks.", website: "openai.com" },
|
||||
{ id: "anthropic", name: "Anthropic Claude", icon: "🧠", description: "Claude 3.5 Sonnet, Claude 3 Opus. Best for long-form reasoning and analysis.", website: "anthropic.com" },
|
||||
{ id: "google", name: "Google Gemini", icon: "🔶", description: "Gemini 2.0 Flash, Gemini 1.5 Pro. Fast, cost-effective, strong multimodal.", website: "ai.google" },
|
||||
{ id: "xai", name: "xAI Grok", icon: "🌪️", description: "Grok-2, Grok-1.5. Real-time data, humor, unconventional reasoning.", website: "x.ai" },
|
||||
{ id: "custom", name: "Custom / Other", icon: "🔧", description: "Connect any OpenAI-compatible API or custom LLM endpoint.", website: "" },
|
||||
// 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.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"/>
|
||||
</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 PROVIDERS: { id: AIProvider; name: string; icon: React.ReactNode; description: string; website: string; color: string }[] = [
|
||||
{ id: "openai", name: "OpenAI", icon: <RobotIcon className="h-5 w-5" />, description: "GPT-4o, GPT-4o-mini, GPT-4 Turbo. Industry standard.", website: "openai.com", color: "bg-emerald-500" },
|
||||
{ id: "anthropic", name: "Anthropic", icon: <BrainIcon className="h-5 w-5" />, description: "Claude 3.5 Sonnet, Claude 3 Opus. Best for reasoning.", website: "anthropic.com", color: "bg-amber-500" },
|
||||
{ id: "google", name: "Google", icon: <CircleIcon className="h-5 w-5" />, description: "Gemini 2.0 Flash, 1.5 Pro. Fast, cost-effective.", website: "ai.google", color: "bg-blue-500" },
|
||||
{ id: "xai", name: "xAI", icon: <TornadoIcon className="h-5 w-5" />, description: "Grok-2, Grok-1.5. Real-time data, humor.", website: "x.ai", color: "bg-orange-500" },
|
||||
{ id: "custom", name: "Custom", icon: <WrenchIcon className="h-5 w-5" />, description: "Any OpenAI-compatible API endpoint.", website: "", color: "bg-stone-500" },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
@@ -92,37 +163,33 @@ export default function AIProviderPanel({ brandId }: Props) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-zinc-900 p-6 shadow-black/20 ring-1 ring-zinc-700 animate-pulse">
|
||||
<div className="h-6 bg-slate-200 rounded w-1/3 mb-4" />
|
||||
<div className="h-4 bg-slate-200 rounded w-2/3 mb-6" />
|
||||
<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/3 mb-4" />
|
||||
<div className="h-4 bg-stone-200 rounded w-2/3 mb-6" />
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{[1,2,3,4,5].map(i => <div key={i} className="h-16 bg-zinc-950 rounded-xl" />)}
|
||||
{[1,2,3,4,5].map(i => <div key={i} className="h-16 bg-stone-100 rounded-xl" />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-zinc-900 shadow-black/20 ring-1 ring-violet-200 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-violet-600 to-indigo-600 px-6 py-5 flex items-center gap-4">
|
||||
<span className="text-2xl">🤖</span>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-white">AI Provider</h2>
|
||||
<p className="text-xs text-violet-200 mt-0.5">Choose your AI model provider for all AI tools</p>
|
||||
<div className="space-y-5">
|
||||
{/* 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>
|
||||
<span className="ml-auto text-xs text-violet-200 bg-zinc-900/20 rounded-full px-3 py-1">
|
||||
{PROVIDERS.find(p => p.id === provider)?.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Provider selector */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-zinc-500 mb-3">
|
||||
Select Provider
|
||||
</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 sm:gap-3">
|
||||
{PROVIDERS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
@@ -132,26 +199,33 @@ export default function AIProviderPanel({ brandId }: Props) {
|
||||
}}
|
||||
className={`rounded-xl p-3 text-center transition-all border-2 ${
|
||||
provider === p.id
|
||||
? "border-violet-500 bg-violet-50 shadow-black/20"
|
||||
: "border-zinc-800 hover:border-violet-200 hover:bg-zinc-800"
|
||||
? "border-violet-500 bg-violet-50"
|
||||
: "border-[var(--admin-border)] hover:border-violet-200 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl block mb-1">{p.icon}</span>
|
||||
<p className="text-xs font-semibold text-slate-800">{p.name}</p>
|
||||
<div className={`flex h-10 w-10 mx-auto items-center justify-center rounded-xl mb-2 ${p.color}`}>
|
||||
<span className="text-white">{p.icon}</span>
|
||||
</div>
|
||||
<p className="text-xs font-semibold text-[var(--admin-text-primary)]">{p.name}</p>
|
||||
{p.website && (
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">{p.website}</p>
|
||||
<p className="text-[10px] text-[var(--admin-text-muted)] mt-0.5">{p.website}</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Credentials — only for non-custom */}
|
||||
{provider !== "custom" && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Credentials Card */}
|
||||
{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">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Credentials</h3>
|
||||
</div>
|
||||
<div className="p-4 sm:p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-zinc-400 mb-1">
|
||||
API Key {provider === "anthropic" ? "(Anthropic)" : provider === "google" ? "(Google AI)" : provider === "xai" ? "(xAI)" : "(OpenAI)"}
|
||||
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
|
||||
API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
@@ -159,77 +233,83 @@ export default function AIProviderPanel({ brandId }: Props) {
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={provider === "anthropic" ? "sk-ant-..." : provider === "google" ? "AIza..." : provider === "xai" ? "xai-..." : "sk-..."}
|
||||
className="w-full rounded-xl border border-zinc-600 px-4 py-2.5 text-sm pr-20 outline-none focus:border-violet-500"
|
||||
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={() => setShowKey(!showKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-zinc-400"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600"
|
||||
>
|
||||
{showKey ? "Hide" : "Show"}
|
||||
{showKey ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{provider === "openai" && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-zinc-400 mb-1">Organization ID (optional)</label>
|
||||
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
|
||||
Organization ID (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgId}
|
||||
onChange={(e) => setOrgId(e.target.value)}
|
||||
placeholder="org-..."
|
||||
className="w-full rounded-xl border border-zinc-600 px-4 py-2.5 text-sm outline-none focus:border-violet-500"
|
||||
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 fields */}
|
||||
{provider === "custom" && (
|
||||
<div className="space-y-4 rounded-xl bg-slate-50 border border-zinc-800 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-lg mt-0.5">🔧</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-zinc-300">Custom API Endpoint</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">Connect any OpenAI-compatible API (Ollama, LM Studio, custom proxy, etc.)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Custom Endpoint Card */}
|
||||
{provider === "custom" && (
|
||||
<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>
|
||||
<label className="block text-xs font-semibold text-zinc-400 mb-1">API Key</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showKey ? "text" : "password"}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
className="w-full rounded-xl border border-zinc-600 px-4 py-2.5 text-sm pr-20 outline-none focus:border-violet-500"
|
||||
/>
|
||||
<button type="button" onClick={() => setShowKey(!showKey)} className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-zinc-400">
|
||||
{showKey ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-zinc-400 mb-1">Base URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customEndpoint}
|
||||
onChange={(e) => setCustomEndpoint(e.target.value)}
|
||||
placeholder="https://api.openai.com/v1 or http://localhost:11434/v1"
|
||||
className="w-full rounded-xl border border-zinc-600 px-4 py-2.5 text-sm outline-none focus:border-violet-500"
|
||||
/>
|
||||
<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 className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">API Key</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showKey ? "text" : "password"}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(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={() => setShowKey(!showKey)} className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600">
|
||||
{showKey ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Base URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customEndpoint}
|
||||
onChange={(e) => setCustomEndpoint(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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model selector */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-zinc-500 mb-2">
|
||||
Model
|
||||
</label>
|
||||
{/* 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">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Model</h3>
|
||||
</div>
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{models.map((m) => (
|
||||
<button
|
||||
@@ -238,7 +318,7 @@ export default function AIProviderPanel({ brandId }: Props) {
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
model === m
|
||||
? "bg-violet-600 text-white"
|
||||
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
|
||||
: "bg-stone-100 text-[var(--admin-text-secondary)] hover:bg-stone-200"
|
||||
}`}
|
||||
>
|
||||
{m}
|
||||
@@ -246,34 +326,36 @@ export default function AIProviderPanel({ brandId }: Props) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test result */}
|
||||
{testResult && (
|
||||
<div className={`rounded-xl px-4 py-3 text-sm flex items-center gap-2 ${
|
||||
testResult.ok ? "bg-green-900/30 text-green-400 border border-green-200" : "bg-red-900/30 text-red-400 border border-red-200"
|
||||
}`}>
|
||||
<span>{testResult.ok ? "✓" : "✗"}</span>
|
||||
<span>{testResult.message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2 border-t border-slate-100">
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={saving}
|
||||
className="rounded-xl border border-zinc-600 px-5 py-2.5 text-sm font-medium text-zinc-400 hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
Test Connection
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex-1 rounded-xl bg-violet-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-violet-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Provider Settings"}
|
||||
</button>
|
||||
{/* Test Result */}
|
||||
{testResult && (
|
||||
<div className={`rounded-xl px-4 py-3 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-3">
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={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"
|
||||
>
|
||||
Test Connection
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
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..." : "Save Provider Settings"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { createStop } from "@/actions/stops/create-stop";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import GlassModal from "@/components/admin/GlassModal";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
@@ -25,18 +25,16 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Form state
|
||||
const [city, setCity] = useState(duplicateFrom?.city ?? "");
|
||||
const [state, setState] = useState(duplicateFrom?.state ?? "");
|
||||
const [location, setLocation] = useState(duplicateFrom?.location ?? "");
|
||||
const [date, setDate] = useState(duplicateFrom?.date ?? "");
|
||||
const [time, setTime] = useState(duplicateFrom?.time ?? "");
|
||||
const [address, setAddress] = useState(duplicateFrom?.address ?? "");
|
||||
const [zip, setZip] = useState(duplicateFrom?.zip ?? "");
|
||||
const [cutoffTime, setCutoffTime] = useState(duplicateFrom?.cutoff_time ?? "");
|
||||
const [city, setCity] = useState("");
|
||||
const [state, setState] = 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");
|
||||
|
||||
// Reset form when modal opens/closes or duplicate changes
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setCity(duplicateFrom?.city ?? "");
|
||||
@@ -52,27 +50,6 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
|
||||
}
|
||||
}, [isOpen, duplicateFrom]);
|
||||
|
||||
// Lock body scroll
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, [isOpen]);
|
||||
|
||||
// Escape key handler
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
if (isOpen) {
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
@@ -109,248 +86,235 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
|
||||
}
|
||||
}, [brandId, city, state, location, date, time, address, zip, cutoffTime, status, onSuccess, onClose]);
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
const isDuplicate = Boolean(duplicateFrom);
|
||||
const title = isDuplicate ? "Duplicate Stop" : "Add New Stop";
|
||||
const subtitle = isDuplicate && duplicateFrom
|
||||
? `From ${duplicateFrom.city}, ${duplicateFrom.state}`
|
||||
: "Create a new tour stop for your route.";
|
||||
|
||||
const inputStyle = {
|
||||
background: 'rgba(0, 0, 0, 0.02)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.06)',
|
||||
outline: 'none',
|
||||
};
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
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)';
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
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';
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const isDuplicate = Boolean(duplicateFrom);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="relative w-full max-w-lg max-h-[90vh] overflow-hidden rounded-2xl bg-white shadow-2xl border border-stone-200">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-stone-100 px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-stone-950">
|
||||
{isDuplicate ? "Duplicate Stop" : "Add New Stop"}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-sm text-stone-500">
|
||||
{isDuplicate && duplicateFrom
|
||||
? `Pre-filled from ${duplicateFrom.city}, ${duplicateFrom.state}`
|
||||
: "Create a new tour stop for your route."}
|
||||
</p>
|
||||
<GlassModal title={title} subtitle={subtitle} onClose={onClose}>
|
||||
<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="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
|
||||
<input
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
placeholder="Denver"
|
||||
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 className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
|
||||
<input
|
||||
type="text"
|
||||
value={state}
|
||||
onChange={(e) => setState(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}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl text-stone-400 hover:text-stone-600 hover:bg-stone-100 transition-colors"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="overflow-y-auto max-h-[calc(90vh-140px)]">
|
||||
<div className="p-6 space-y-5">
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Location</label>
|
||||
<input
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="Whole Foods Market"
|
||||
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>
|
||||
|
||||
{/* Location row */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1.5">
|
||||
City <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
placeholder="Denver"
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1.5">
|
||||
State <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={state}
|
||||
onChange={(e) => setState(e.target.value.toUpperCase())}
|
||||
placeholder="CO"
|
||||
maxLength={2}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1.5">
|
||||
Location Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="Whole Foods Market"
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date & Time row */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1.5">
|
||||
Date <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm text-stone-900 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1.5">
|
||||
Pickup Time
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm text-stone-900 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address & ZIP */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1.5">
|
||||
Street Address
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="123 Main St"
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1.5">
|
||||
ZIP Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={zip}
|
||||
onChange={(e) => setZip(e.target.value)}
|
||||
placeholder="80202"
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cutoff time */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1.5">
|
||||
Order Cutoff Time
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={cutoffTime}
|
||||
onChange={(e) => setCutoffTime(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm text-stone-900 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-stone-400">
|
||||
Customers can only order until this time on the day before pickup.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status toggle */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">
|
||||
Status
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<label className={`flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-3 cursor-pointer transition-all ${
|
||||
status === "draft"
|
||||
? "border-amber-300 bg-amber-50 text-amber-800"
|
||||
: "border-stone-200 bg-white text-stone-500 hover:border-stone-300"
|
||||
}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
value="draft"
|
||||
checked={status === "draft"}
|
||||
onChange={() => setStatus("draft")}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
|
||||
status === "draft" ? "bg-amber-200 text-amber-900" : "bg-stone-100 text-stone-500"
|
||||
}`}>
|
||||
Draft
|
||||
</span>
|
||||
<span className="text-sm font-medium">Save as draft</span>
|
||||
</label>
|
||||
<label className={`flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-3 cursor-pointer transition-all ${
|
||||
status === "active"
|
||||
? "border-emerald-300 bg-emerald-50 text-emerald-800"
|
||||
: "border-stone-200 bg-white text-stone-500 hover:border-stone-300"
|
||||
}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
value="active"
|
||||
checked={status === "active"}
|
||||
onChange={() => setStatus("active")}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
|
||||
status === "active" ? "bg-emerald-200 text-emerald-900" : "bg-stone-100 text-stone-500"
|
||||
}`}>
|
||||
Active
|
||||
</span>
|
||||
<span className="text-sm font-medium">Publish now</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(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 className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Pickup Time</label>
|
||||
<input
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => setTime(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>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between border-t border-stone-100 px-6 py-4 bg-stone-50">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="123 Main St"
|
||||
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 className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
|
||||
<input
|
||||
type="text"
|
||||
value={zip}
|
||||
onChange={(e) => setZip(e.target.value)}
|
||||
placeholder="80202"
|
||||
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 className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Cutoff Time</label>
|
||||
<input
|
||||
type="time"
|
||||
value={cutoffTime}
|
||||
onChange={(e) => setCutoffTime(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}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-stone-400 ml-1">Orders close this time the day before pickup</p>
|
||||
</div>
|
||||
|
||||
{/* Status toggle */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Status</label>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm font-medium text-stone-600 hover:bg-stone-100 transition-colors"
|
||||
onClick={() => setStatus("draft")}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-3 transition-all"
|
||||
style={{
|
||||
border: status === "draft" ? '1px solid rgba(245, 158, 11, 0.4)' : '1px solid rgba(0, 0, 0, 0.08)',
|
||||
background: status === "draft" ? 'rgba(245, 158, 11, 0.08)' : 'rgba(0, 0, 0, 0.02)',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
<span className="rounded-full px-2 py-0.5 text-xs font-semibold" style={{
|
||||
background: status === "draft" ? 'rgba(245, 158, 11, 0.2)' : 'rgba(0, 0, 0, 0.05)',
|
||||
color: status === "draft" ? '#b45309' : 'rgba(0, 0, 0, 0.4)',
|
||||
}}>Draft</span>
|
||||
<span className="text-sm font-medium" style={{ color: status === "draft" ? '#92400e' : 'rgba(0, 0, 0, 0.4)' }}>Save as draft</span>
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded-xl bg-emerald-600 hover:bg-emerald-500 active:bg-emerald-700 px-6 py-2.5 text-sm font-semibold text-white disabled:opacity-50 transition-colors shadow-sm shadow-emerald-200"
|
||||
type="button"
|
||||
onClick={() => setStatus("active")}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-3 transition-all"
|
||||
style={{
|
||||
border: status === "active" ? '1px solid rgba(16, 185, 129, 0.4)' : '1px solid rgba(0, 0, 0, 0.08)',
|
||||
background: status === "active" ? 'rgba(16, 185, 129, 0.08)' : 'rgba(0, 0, 0, 0.02)',
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
) : (
|
||||
isDuplicate ? "Duplicate Stop" : "Create Stop"
|
||||
)}
|
||||
<span className="rounded-full px-2 py-0.5 text-xs font-semibold" style={{
|
||||
background: status === "active" ? 'rgba(16, 185, 129, 0.2)' : 'rgba(0, 0, 0, 0.05)',
|
||||
color: status === "active" ? '#047857' : 'rgba(0, 0, 0, 0.4)',
|
||||
}}>Active</span>
|
||||
<span className="text-sm font-medium" style={{ color: status === "active" ? '#065f46' : 'rgba(0, 0, 0, 0.4)' }}>Publish now</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<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={onClose}
|
||||
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"
|
||||
>
|
||||
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>
|
||||
) : (
|
||||
isDuplicate ? "Duplicate Stop" : "Create Stop"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</GlassModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { markPickupComplete } from "@/actions/pickup";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
@@ -56,13 +57,58 @@ function shortId(id: string) {
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
|
||||
}
|
||||
|
||||
export { formatDate };
|
||||
// Icons
|
||||
const Icons = {
|
||||
search: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.3-4.3"/>
|
||||
</svg>
|
||||
),
|
||||
mapPin: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||
<path d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0z"/>
|
||||
</svg>
|
||||
),
|
||||
check: (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>
|
||||
),
|
||||
x: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
),
|
||||
chevronDown: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m6 9 6 6 6-6"/>
|
||||
</svg>
|
||||
),
|
||||
chevronLeft: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m15 18-6-6 6-6"/>
|
||||
</svg>
|
||||
),
|
||||
chevronRight: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m9 18 6-6-6-6"/>
|
||||
</svg>
|
||||
),
|
||||
package: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16.5 9.4 7.55 4.24"/>
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.29 7 12 12 20.71 7"/>
|
||||
<line x1="12" y1="22" x2="12" y2="12"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export default function AdminOrdersPanel({
|
||||
initialOrders,
|
||||
@@ -80,67 +126,38 @@ export default function AdminOrdersPanel({
|
||||
const [pickupToast, setPickupToast] = useState<string | null>(null);
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
// Filter orders based on all criteria
|
||||
const filteredOrders = useMemo(() => {
|
||||
return orders.filter((order) => {
|
||||
// Status filter
|
||||
if (activeTab === "pending" && order.pickup_complete) return false;
|
||||
if (activeTab === "picked_up" && !order.pickup_complete) return false;
|
||||
|
||||
// Stop filter
|
||||
if (selectedStops.length > 0 && order.stop_id && !selectedStops.includes(order.stop_id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (selectedStops.length > 0 && order.stop_id && !selectedStops.includes(order.stop_id)) return false;
|
||||
if (search.trim()) {
|
||||
const searchLower = search.toLowerCase();
|
||||
const matchesName = order.customer_name.toLowerCase().includes(searchLower);
|
||||
const matchesPhone = (order.customer_phone ?? "").toLowerCase().includes(searchLower);
|
||||
const matchesId = order.id.toLowerCase().includes(searchLower);
|
||||
if (!matchesName && !matchesPhone && !matchesId) return false;
|
||||
const s = search.toLowerCase();
|
||||
if (!order.customer_name.toLowerCase().includes(s) &&
|
||||
!(order.customer_phone ?? "").toLowerCase().includes(s) &&
|
||||
!order.id.toLowerCase().includes(s)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [orders, activeTab, selectedStops, search]);
|
||||
|
||||
// Pagination
|
||||
const totalFiltered = filteredOrders.length;
|
||||
const totalPages = Math.ceil(totalFiltered / PAGE_SIZE);
|
||||
const totalPages = Math.ceil(filteredOrders.length / PAGE_SIZE);
|
||||
const paginatedOrders = filteredOrders.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
// Stats
|
||||
const pendingCount = orders.filter((o) => !o.pickup_complete).length;
|
||||
const pickedUpCount = orders.filter((o) => o.pickup_complete).length;
|
||||
|
||||
// Toggle stop selection
|
||||
const toggleStop = useCallback((stopId: string) => {
|
||||
function toggleStop(stopId: string) {
|
||||
setSelectedStops((prev) =>
|
||||
prev.includes(stopId) ? prev.filter((id) => id !== stopId) : [...prev, stopId]
|
||||
);
|
||||
setPage(0);
|
||||
}, []);
|
||||
}
|
||||
|
||||
// Clear all stop filters
|
||||
const clearStopFilters = useCallback(() => {
|
||||
function clearStops() {
|
||||
setSelectedStops([]);
|
||||
setPage(0);
|
||||
}, []);
|
||||
}
|
||||
|
||||
// Handle tab change
|
||||
const handleTabChange = useCallback((tab: StatusTab) => {
|
||||
setActiveTab(tab);
|
||||
setPage(0);
|
||||
}, []);
|
||||
|
||||
// Handle search
|
||||
const handleSearch = useCallback((value: string) => {
|
||||
setSearch(value);
|
||||
setPage(0);
|
||||
}, []);
|
||||
|
||||
// Handle pickup
|
||||
async function handleMarkPickup(orderId: string) {
|
||||
setPickingUp(orderId);
|
||||
const result = await markPickupComplete(orderId, brandId);
|
||||
@@ -150,12 +167,7 @@ export default function AdminOrdersPanel({
|
||||
setOrders((prev) =>
|
||||
prev.map((o) =>
|
||||
o.id === orderId
|
||||
? {
|
||||
...o,
|
||||
pickup_complete: true,
|
||||
pickup_completed_at: result.pickup_completed_at,
|
||||
pickup_completed_by: result.pickup_completed_by,
|
||||
}
|
||||
? { ...o, pickup_complete: true, pickup_completed_at: result.pickup_completed_at, pickup_completed_by: result.pickup_completed_by }
|
||||
: o
|
||||
)
|
||||
);
|
||||
@@ -164,347 +176,245 @@ export default function AdminOrdersPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50 px-6 py-8">
|
||||
<div className="mx-auto max-w-7xl space-y-6">
|
||||
{/* Header */}
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-stone-950">Orders</h1>
|
||||
<div className="mt-2 flex items-center gap-4">
|
||||
<span className="text-sm text-stone-500">
|
||||
<span className="font-medium text-stone-700">{pendingCount}</span> pending
|
||||
</span>
|
||||
<span className="text-sm text-stone-500">
|
||||
<span className="font-medium text-stone-700">{pickedUpCount}</span> picked up
|
||||
</span>
|
||||
{brandId && (
|
||||
<span className="rounded-full bg-violet-50 border border-violet-200 px-2.5 py-0.5 text-xs font-medium text-violet-700">
|
||||
Brand scoped
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
|
||||
{Icons.package("h-5 w-5 text-white")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{/* Status Tabs */}
|
||||
<div className="flex items-center gap-1 rounded-xl border border-stone-200 bg-stone-50 p-1">
|
||||
{(["all", "pending", "picked_up"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => handleTabChange(tab)}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition-all ${
|
||||
activeTab === tab
|
||||
? "bg-white text-emerald-700 shadow-sm border border-emerald-200"
|
||||
: "text-stone-600 hover:text-stone-900 hover:bg-white/50"
|
||||
}`}
|
||||
>
|
||||
{tab === "all" ? "All" : tab === "pending" ? "Pending" : "Picked Up"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-64">
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-stone-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search by name, phone, or order #..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white pl-10 pr-4 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stop Multi-Select */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowStopDropdown(!showStopDropdown)}
|
||||
className={`flex items-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-medium transition-all ${
|
||||
selectedStops.length > 0
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-700"
|
||||
: "border-stone-200 bg-white text-stone-600 hover:border-stone-300"
|
||||
}`}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
<span>Stops</span>
|
||||
{selectedStops.length > 0 && (
|
||||
<span className="rounded-full bg-emerald-600 text-white px-1.5 py-0.5 text-xs font-semibold">
|
||||
{selectedStops.length}
|
||||
</span>
|
||||
)}
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showStopDropdown && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setShowStopDropdown(false)}
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-2 z-20 w-72 rounded-xl border border-stone-200 bg-white shadow-lg">
|
||||
<div className="flex items-center justify-between border-b border-stone-100 px-4 py-3">
|
||||
<span className="text-sm font-semibold text-stone-700">Filter by Stop</span>
|
||||
<button
|
||||
onClick={clearStopFilters}
|
||||
className="text-xs text-emerald-600 hover:text-emerald-700 font-medium"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto p-2">
|
||||
{stops.map((stop) => (
|
||||
<label
|
||||
key={stop.id}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-stone-50 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedStops.includes(stop.id)}
|
||||
onChange={() => toggleStop(stop.id)}
|
||||
className="h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-medium text-stone-700">
|
||||
{stop.city}, {stop.state}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-stone-400">
|
||||
{formatDate(stop.date)}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
<div className="ml-auto text-sm text-stone-500">
|
||||
{totalFiltered} order{totalFiltered !== 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected stop chips */}
|
||||
{selectedStops.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-stone-500">Selected:</span>
|
||||
{selectedStops.map((stopId) => {
|
||||
const stop = stops.find((s) => s.id === stopId);
|
||||
if (!stop) return null;
|
||||
return (
|
||||
<span
|
||||
key={stopId}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-emerald-100 border border-emerald-200 px-3 py-1 text-xs font-medium text-emerald-700"
|
||||
>
|
||||
{stop.city}, {stop.state}
|
||||
<button
|
||||
onClick={() => toggleStop(stopId)}
|
||||
className="ml-1 hover:text-emerald-900"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Orders Table */}
|
||||
{paginatedOrders.length === 0 ? (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white py-16 text-center shadow-sm">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-stone-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.801 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="mt-4 text-sm font-medium text-stone-500">No orders found</p>
|
||||
<p className="mt-1 text-xs text-stone-400">Try adjusting your filters</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-2xl border border-stone-200 bg-white shadow-sm">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-stone-100 bg-stone-50">
|
||||
<th className="px-5 py-4 text-left text-xs font-semibold uppercase tracking-wide text-stone-500">
|
||||
Order
|
||||
</th>
|
||||
<th className="px-5 py-4 text-left text-xs font-semibold uppercase tracking-wide text-stone-500">
|
||||
Customer
|
||||
</th>
|
||||
<th className="px-5 py-4 text-left text-xs font-semibold uppercase tracking-wide text-stone-500 hidden md:table-cell">
|
||||
Stop
|
||||
</th>
|
||||
<th className="px-5 py-4 text-left text-xs font-semibold uppercase tracking-wide text-stone-500 hidden lg:table-cell">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-5 py-4 text-center text-xs font-semibold uppercase tracking-wide text-stone-500">
|
||||
Items
|
||||
</th>
|
||||
<th className="px-5 py-4 text-right text-xs font-semibold uppercase tracking-wide text-stone-500">
|
||||
Total
|
||||
</th>
|
||||
<th className="px-5 py-4 text-center text-xs font-semibold uppercase tracking-wide text-stone-500">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-100">
|
||||
{paginatedOrders.map((order) => (
|
||||
<tr
|
||||
key={order.id}
|
||||
className="hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
{/* Order ID */}
|
||||
<td className="px-5 py-4">
|
||||
<span className="font-mono text-sm text-stone-600">{shortId(order.id)}</span>
|
||||
</td>
|
||||
|
||||
{/* Customer */}
|
||||
<td className="px-5 py-4">
|
||||
<div className="font-medium text-stone-900">{order.customer_name}</div>
|
||||
{order.customer_phone && (
|
||||
<div className="font-mono text-xs text-stone-400">{order.customer_phone}</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Stop */}
|
||||
<td className="px-5 py-4 hidden md:table-cell">
|
||||
{order.stops ? (
|
||||
<span className="text-sm text-stone-600">
|
||||
{order.stops.city}, {order.stops.state}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-stone-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Date */}
|
||||
<td className="px-5 py-4 hidden lg:table-cell">
|
||||
<span className="text-sm text-stone-500">{formatDate(order.created_at)}</span>
|
||||
</td>
|
||||
|
||||
{/* Items */}
|
||||
<td className="px-5 py-4 text-center">
|
||||
<span className="font-mono text-sm text-stone-600">
|
||||
{order.order_items?.length ?? 0}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Total */}
|
||||
<td className="px-5 py-4 text-right">
|
||||
<span className="font-mono font-semibold text-stone-900">
|
||||
{formatCurrency(order.subtotal)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Status & Actions */}
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs font-semibold ${
|
||||
order.pickup_complete
|
||||
? "bg-emerald-100 text-emerald-800 border border-emerald-200"
|
||||
: "bg-amber-100 text-amber-800 border border-amber-200"
|
||||
}`}
|
||||
>
|
||||
{order.pickup_complete ? "Picked Up" : "Pending"}
|
||||
</span>
|
||||
{order.payment_processor === "square" && (
|
||||
<span className="rounded-full bg-violet-100 border border-violet-200 px-1.5 py-0.5 text-xs font-semibold text-violet-700">
|
||||
Square
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-stone-500">
|
||||
Showing {(page * PAGE_SIZE) + 1} to {Math.min((page + 1) * PAGE_SIZE, totalFiltered)} of {totalFiltered}
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Orders</h2>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
|
||||
{filteredOrders.length} order{filteredOrders.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl border border-stone-200 text-stone-500 hover:border-stone-300 hover:text-stone-700 hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed transition-all"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="px-3 text-sm font-medium text-stone-700">
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl border border-stone-200 text-stone-500 hover:border-stone-300 hover:text-stone-700 hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed transition-all"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pickup Toast */}
|
||||
{pickupToast && (
|
||||
<div className="fixed bottom-6 right-6 z-50 rounded-xl border border-emerald-200 bg-emerald-50 px-5 py-3 shadow-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-100">
|
||||
<svg className="h-4 w-4 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-medium text-emerald-800">Pickup confirmed!</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{brandId && (
|
||||
<span className="rounded-full bg-violet-50 border border-violet-200 px-2.5 py-1 text-xs font-medium text-violet-700">Brand scoped</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-6">
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1">{orders.length}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Pending</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-amber-600 mt-1">{pendingCount}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Picked Up</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-emerald-600 mt-1">{pickedUpCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
{/* Status Tabs */}
|
||||
<div className="flex rounded-xl border border-[var(--admin-border)] bg-white p-1">
|
||||
{(["all", "pending", "picked_up"] as StatusTab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => { setActiveTab(tab); setPage(0); }}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab
|
||||
? "bg-emerald-600 text-white"
|
||||
: "text-stone-500 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{tab === "all" ? "All" : tab === "pending" ? "Pending" : "Picked Up"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
{Icons.search("absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-stone-400")}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search name, phone, or order #..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
className="w-full pl-10 pr-4 py-2.5 text-sm border border-[var(--admin-border)] rounded-xl bg-white text-[var(--admin-text-primary)] placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stop Filter */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowStopDropdown(!showStopDropdown)}
|
||||
className={`flex items-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||
selectedStops.length > 0
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-700"
|
||||
: "border-[var(--admin-border)] bg-white text-stone-600 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{Icons.mapPin("h-4 w-4")}
|
||||
<span>Stops</span>
|
||||
{selectedStops.length > 0 && (
|
||||
<span className="rounded-full bg-emerald-600 text-white px-1.5 py-0.5 text-xs font-semibold">{selectedStops.length}</span>
|
||||
)}
|
||||
{Icons.chevronDown("h-4 w-4")}
|
||||
</button>
|
||||
|
||||
{showStopDropdown && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setShowStopDropdown(false)} />
|
||||
<div className="absolute right-0 top-full mt-2 z-20 w-72 rounded-xl border border-[var(--admin-border)] bg-white shadow-lg">
|
||||
<div className="flex items-center justify-between border-b border-stone-100 px-4 py-3">
|
||||
<span className="text-sm font-semibold text-stone-700">Filter by Stop</span>
|
||||
<button onClick={clearStops} className="text-xs text-emerald-600 hover:text-emerald-700 font-medium">Clear all</button>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto p-2">
|
||||
{stops.map((stop) => (
|
||||
<label key={stop.id} className="flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-stone-50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedStops.includes(stop.id)}
|
||||
onChange={() => toggleStop(stop.id)}
|
||||
className="h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-medium text-stone-700">{stop.city}, {stop.state}</span>
|
||||
<span className="ml-2 text-xs text-stone-400">{formatDate(stop.date)}</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected stop chips */}
|
||||
{selectedStops.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
{selectedStops.map((stopId) => {
|
||||
const stop = stops.find((s) => s.id === stopId);
|
||||
if (!stop) return null;
|
||||
return (
|
||||
<span key={stopId} className="inline-flex items-center gap-1 rounded-full bg-emerald-50 border border-emerald-200 px-3 py-1 text-xs font-medium text-emerald-700">
|
||||
{stop.city}, {stop.state}
|
||||
<button onClick={() => toggleStop(stopId)} className="ml-1 hover:text-emerald-900">
|
||||
{Icons.x("h-3 w-3")}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Orders Table */}
|
||||
{paginatedOrders.length === 0 ? (
|
||||
<div className="text-center py-12 rounded-xl border border-[var(--admin-border)] bg-white">
|
||||
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
|
||||
{Icons.package("h-8 w-8 text-stone-400")}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-stone-600">No orders found</p>
|
||||
<p className="text-xs text-stone-400 mt-1">Try adjusting your filters</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-[var(--admin-border)] bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-stone-50">
|
||||
<tr className="border-b border-[var(--admin-border)]">
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Order</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Customer</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs hidden md:table-cell">Stop</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs hidden lg:table-cell">Date</th>
|
||||
<th className="text-center px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Items</th>
|
||||
<th className="text-right px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Total</th>
|
||||
<th className="text-center px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border)]">
|
||||
{paginatedOrders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-stone-50 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/admin/orders/${order.id}`} className="font-mono text-xs text-emerald-600 hover:text-emerald-700">
|
||||
{shortId(order.id)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-[var(--admin-text-primary)]">{order.customer_name}</div>
|
||||
{order.customer_phone && <div className="font-mono text-xs text-stone-400">{order.customer_phone}</div>}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden md:table-cell">
|
||||
{order.stops ? (
|
||||
<span className="text-sm text-[var(--admin-text-primary)]">{order.stops.city}, {order.stops.state}</span>
|
||||
) : (
|
||||
<span className="text-stone-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden lg:table-cell">
|
||||
<span className="text-sm text-stone-500">{formatDate(order.created_at)}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="font-mono text-sm text-stone-600">{order.order_items?.length ?? 0}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className="font-mono font-semibold text-[var(--admin-text-primary)]">{formatCurrency(order.subtotal)}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{order.pickup_complete ? (
|
||||
<span className="rounded-full bg-emerald-100 text-emerald-700 px-2.5 py-0.5 text-[10px] font-semibold">Picked Up</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-amber-100 text-amber-700 px-2.5 py-0.5 text-[10px] font-semibold">Pending</span>
|
||||
)}
|
||||
{order.payment_processor === "square" && (
|
||||
<span className="rounded-full bg-violet-100 text-violet-700 px-1.5 py-0.5 text-[10px] font-semibold">Square</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-[var(--admin-border)]">
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
Showing {(page * PAGE_SIZE) + 1} to {Math.min((page + 1) * PAGE_SIZE, filteredOrders.length)} of {filteredOrders.length}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-[var(--admin-border)] text-stone-500 hover:bg-stone-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{Icons.chevronLeft("h-4 w-4")}
|
||||
</button>
|
||||
<span className="px-3 text-sm font-medium text-stone-700">{page + 1} / {totalPages}</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-[var(--admin-border)] text-stone-500 hover:bg-stone-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{Icons.chevronRight("h-4 w-4")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toast */}
|
||||
{pickupToast && (
|
||||
<div className="fixed bottom-6 right-6 z-50 rounded-xl border border-emerald-200 bg-emerald-50 px-5 py-3 shadow-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-100">
|
||||
{Icons.check("h-4 w-4 text-emerald-600")}
|
||||
</div>
|
||||
<span className="font-medium text-emerald-800">Pickup confirmed!</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import { usePathname } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
// Warm earth-tone dark sidebar design
|
||||
// Colors: stone-900 bg, stone-300 text, emerald accent
|
||||
// Elegant warm sidebar design
|
||||
// Colors: parchment 100 bg, soft linen text, powder petal accent
|
||||
|
||||
const TOP_LINKS = [
|
||||
{ href: "/admin", label: "Dashboard", icon: "grid" },
|
||||
@@ -17,15 +17,11 @@ const TOP_LINKS = [
|
||||
{ href: "/admin/route-trace", label: "Route Trace", icon: "clipboard" },
|
||||
{ href: "/admin/time-tracking", label: "Time Tracking", icon: "clock" },
|
||||
{ href: "/admin/communications", label: "Communications", icon: "mail" },
|
||||
{ href: "/admin/settings", label: "Settings", icon: "settings" },
|
||||
{ href: "/admin/advanced", label: "Advanced", icon: "advanced" },
|
||||
];
|
||||
|
||||
const SETTINGS_SUB_LINKS = [
|
||||
{ href: "/admin/settings", label: "General" },
|
||||
{ href: "/admin/settings#workers", label: "Workers & PINs" },
|
||||
{ href: "/admin/settings#tasks", label: "Tasks" },
|
||||
{ href: "/admin/settings#users", label: "Users & Permissions" },
|
||||
{ href: "/admin/settings#integrations", label: "Integrations" },
|
||||
];
|
||||
const SETTINGS_SUB_LINKS: never[] = [];
|
||||
|
||||
function GridIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
@@ -84,6 +80,14 @@ function MailIcon({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SparkleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsCogIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg className={`w-4 h-4 transition-transform ${open ? "rotate-45" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
@@ -116,6 +120,8 @@ const ICON_MAP: Record<string, React.ReactNode> = {
|
||||
clipboard: <ClipboardIcon />,
|
||||
clock: <ClockIcon />,
|
||||
mail: <MailIcon />,
|
||||
settings: <SettingsCogIcon open={false} />,
|
||||
advanced: <SparkleIcon />,
|
||||
};
|
||||
|
||||
type SidebarProps = {
|
||||
@@ -126,7 +132,6 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
|
||||
: userRole === "brand_admin" ? "Brand Admin"
|
||||
@@ -139,8 +144,6 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
|
||||
return pathname.startsWith(href);
|
||||
};
|
||||
|
||||
const isSettingsPage = pathname.startsWith("/admin/settings");
|
||||
|
||||
async function handleLogout() {
|
||||
document.cookie = "dev_session=;path=/;max-age=0";
|
||||
await supabase.auth.signOut();
|
||||
@@ -157,7 +160,7 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
|
||||
className="fixed top-4 left-4 z-50 lg:hidden"
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white shadow-md border border-stone-200">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white shadow-md border border-[var(--admin-border)]">
|
||||
<HamburgerIcon />
|
||||
</div>
|
||||
</button>
|
||||
@@ -170,37 +173,37 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar panel - Warm earth-tone dark */}
|
||||
{/* Sidebar panel - Elegant warm dark */}
|
||||
<aside className={[
|
||||
"fixed top-0 left-0 h-full w-60 z-50",
|
||||
"bg-stone-900 border-r border-stone-800",
|
||||
"border-r border-[var(--admin-sidebar-bg)]",
|
||||
"flex flex-col transition-transform duration-300 lg:translate-x-0",
|
||||
mobileOpen ? "translate-x-0" : "-translate-x-full"
|
||||
].join(" ")}>
|
||||
].join(" ")}
|
||||
style={{ backgroundColor: "var(--admin-sidebar-bg)" }}>
|
||||
|
||||
{/* Logo row */}
|
||||
<div className="flex items-center h-16 px-5 border-b border-stone-800 flex-shrink-0">
|
||||
<Link
|
||||
href="/admin"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="flex items-center gap-3 text-stone-100 hover:text-emerald-400 transition-colors"
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-emerald-600 shadow-sm">
|
||||
<svg className="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center h-16 px-5 border-b flex-shrink-0" style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/admin"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="flex items-center gap-3 text-white hover:opacity-90 transition-colors"
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl shadow-sm" style={{ backgroundColor: "var(--admin-accent)" }}>
|
||||
<svg className="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-semibold tracking-tight">Admin</span>
|
||||
<a
|
||||
href="/"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-xs text-stone-500 hover:text-stone-300 transition-colors ml-2"
|
||||
>
|
||||
← Back to Site
|
||||
</a>
|
||||
</div>
|
||||
</Link>
|
||||
</Link>
|
||||
<a
|
||||
href="/"
|
||||
className="text-xs transition-colors" style={{ color: "var(--admin-sidebar-text)" }}
|
||||
>
|
||||
← Back to Site
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav links */}
|
||||
@@ -215,81 +218,47 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
|
||||
className={[
|
||||
"group flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200",
|
||||
active
|
||||
? "bg-emerald-900/20 text-emerald-400 border-l-2 border-emerald-500"
|
||||
: "text-stone-300 hover:text-stone-100 hover:bg-stone-800/50 border-l-2 border-transparent",
|
||||
? "border-l-2"
|
||||
: "border-l-2 border-transparent hover:border-l-2",
|
||||
].join(" ")}
|
||||
style={active ? {
|
||||
backgroundColor: "rgba(202, 117, 67, 0.15)",
|
||||
color: "#dea889",
|
||||
borderColor: "var(--admin-accent)"
|
||||
} : {
|
||||
color: "var(--admin-sidebar-text)",
|
||||
borderColor: "transparent"
|
||||
}}
|
||||
>
|
||||
<span className={[
|
||||
"flex-shrink-0 transition-colors",
|
||||
active ? "text-emerald-400" : "text-stone-500 group-hover:text-stone-300"
|
||||
].join(" ")}>
|
||||
<span className="flex-shrink-0 transition-colors" style={{
|
||||
color: active ? "var(--admin-accent)" : "rgba(208, 203, 180, 0.6)"
|
||||
}}>
|
||||
{ICON_MAP[item.icon]}
|
||||
</span>
|
||||
{item.label}
|
||||
{active && (
|
||||
<span className="ml-auto w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
<span className="ml-auto w-1.5 h-1.5 rounded-full" style={{ backgroundColor: "var(--admin-accent)" }} />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Settings collapsible section */}
|
||||
<div className="pt-4 pb-1">
|
||||
<div className="h-px bg-stone-800 mb-4" />
|
||||
<button
|
||||
onClick={() => setSettingsOpen(v => !v)}
|
||||
className={[
|
||||
"w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200",
|
||||
isSettingsPage
|
||||
? "bg-emerald-900/20 text-emerald-400 border-l-2 border-emerald-500"
|
||||
: "text-stone-300 hover:text-stone-100 hover:bg-stone-800/50 border-l-2 border-transparent",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<span className={isSettingsPage ? "text-emerald-400" : "text-stone-500"}>
|
||||
<SettingsCogIcon open={settingsOpen} />
|
||||
</span>
|
||||
Settings
|
||||
</span>
|
||||
<ChevronIcon open={settingsOpen} />
|
||||
</button>
|
||||
|
||||
{settingsOpen && (
|
||||
<div className="mt-2 ml-2 space-y-0.5 border-l border-stone-700 pl-3">
|
||||
{SETTINGS_SUB_LINKS.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={[
|
||||
"flex items-center px-3 py-2 rounded-lg text-sm transition-all duration-200",
|
||||
active
|
||||
? "bg-emerald-900/20 text-emerald-400"
|
||||
: "text-stone-400 hover:text-stone-200 hover:bg-stone-800/50",
|
||||
].join(" ")}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Bottom: role + sign out */}
|
||||
<div className="px-4 py-5 border-t border-stone-800 flex-shrink-0">
|
||||
<div className="px-4 py-5 border-t flex-shrink-0" style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}>
|
||||
{roleLabel && (
|
||||
<div className="px-3 py-2 mb-3 rounded-lg bg-stone-800 border border-stone-700">
|
||||
<p className="text-[10px] text-stone-500 font-medium uppercase tracking-widest mb-0.5">{roleLabel}</p>
|
||||
<p className="text-xs text-stone-400">Signed in</p>
|
||||
<div className="px-3 py-2 mb-3 rounded-lg border" style={{
|
||||
backgroundColor: "rgba(208, 203, 180, 0.1)",
|
||||
borderColor: "rgba(208, 203, 180, 0.2)"
|
||||
}}>
|
||||
<p className="text-[10px] font-medium uppercase tracking-widest mb-0.5" style={{ color: "rgba(195, 195, 193, 0.6)" }}>{roleLabel}</p>
|
||||
<p className="text-xs" style={{ color: "rgba(195, 195, 193, 0.8)" }}>Signed in</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full px-3 py-2 rounded-lg text-sm font-medium text-stone-400 hover:text-stone-200 hover:bg-stone-800 transition-all border border-transparent"
|
||||
className="w-full px-3 py-2 rounded-lg text-sm font-medium transition-all border border-transparent"
|
||||
style={{ color: "rgba(195, 195, 193, 0.7)" }}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
|
||||
import AddStopModal from "@/components/admin/AddStopModal";
|
||||
import { publishStop } from "@/actions/stops";
|
||||
|
||||
type Stop = {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
active: boolean;
|
||||
deleted_at?: string | null;
|
||||
brand_id: string;
|
||||
status?: string;
|
||||
brands: { name: string } | { name: string }[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
stops: Stop[];
|
||||
brandId: string;
|
||||
};
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
try {
|
||||
const d = new Date(dateStr + "T00:00:00");
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(timeStr: string) {
|
||||
if (!timeStr) return "—";
|
||||
try {
|
||||
const [h, m] = timeStr.split(":");
|
||||
const hour = parseInt(h, 10);
|
||||
const ampm = hour >= 12 ? "PM" : "AM";
|
||||
const hour12 = hour % 12 || 12;
|
||||
return `${hour12}:${m} ${ampm}`;
|
||||
} catch {
|
||||
return timeStr;
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminStopsPanel({ stops, brandId }: Props) {
|
||||
const router = useRouter();
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive" | "draft">("all");
|
||||
const [page, setPage] = useState(0);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [publishingId, setPublishingId] = useState<string | null>(null);
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const filtered = stops.filter((s) => {
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
s.city.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.location.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "active" && s.active && s.status !== "draft") ||
|
||||
(statusFilter === "inactive" && !s.active && s.status !== "draft") ||
|
||||
(statusFilter === "draft" && s.status === "draft");
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
||||
const paginatedStops = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
async function handlePublish(stopId: string) {
|
||||
setPublishingId(stopId);
|
||||
setOpenMenu(null);
|
||||
await publishStop(stopId, brandId);
|
||||
setPublishingId(null);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function handleDelete(stopId: string) {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/delete_stop`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_stop_id: stopId, p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
setConfirmDelete(null);
|
||||
setOpenMenu(null);
|
||||
if (data.success) {
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
const activeCount = stops.filter((s) => s.active && s.status !== "draft").length;
|
||||
const draftCount = stops.filter((s) => s.status === "draft").length;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
{/* Header */}
|
||||
<header className="border-b border-stone-200 bg-white">
|
||||
<div className="mx-auto max-w-7xl px-6 py-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-stone-950 tracking-tight">Tour Stops</h1>
|
||||
<div className="mt-2 flex items-center gap-4">
|
||||
<span className="text-sm text-stone-500">
|
||||
<span className="font-medium text-stone-700">{activeCount}</span> active
|
||||
</span>
|
||||
{draftCount > 0 && (
|
||||
<span className="text-sm text-stone-500">
|
||||
<span className="font-medium text-stone-700">{draftCount}</span> drafts
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowImport(true)}
|
||||
className="flex items-center gap-2 rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm font-medium text-stone-600 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
Upload Schedule
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAdd(true)}
|
||||
className="flex items-center gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 px-4 py-2.5 text-sm font-semibold text-white transition-colors"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-7xl px-6 py-6">
|
||||
{/* Filters */}
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-4 shadow-sm mb-5">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-64">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search city or location..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white pl-10 pr-4 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status tabs */}
|
||||
<div className="flex items-center gap-1 rounded-xl border border-stone-200 bg-stone-50 p-1">
|
||||
{(["all", "active", "draft", "inactive"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => { setStatusFilter(f); setPage(0); }}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition-all ${
|
||||
statusFilter === f
|
||||
? "bg-white text-emerald-700 shadow-sm border border-emerald-200"
|
||||
: "text-stone-600 hover:text-stone-900 hover:bg-white/50"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? "All" : f.charAt(0).toUpperCase() + f.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Count */}
|
||||
<span className="ml-auto text-sm text-stone-500">{filtered.length} stops</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-hidden rounded-2xl border border-stone-200 bg-white shadow-sm">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<div className="mx-auto mb-3 h-12 w-12 rounded-full bg-stone-100 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-lg font-medium text-stone-600">No stops found</p>
|
||||
<p className="mt-1 text-sm text-stone-400">Create a stop or adjust your filters</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-stone-100 bg-stone-50">
|
||||
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500">City</th>
|
||||
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500">Location</th>
|
||||
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500">Date</th>
|
||||
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500">Time</th>
|
||||
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500 hidden md:table-cell">Brand</th>
|
||||
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500">Status</th>
|
||||
<th className="px-5 py-4" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-100">
|
||||
{paginatedStops.map((stop) => (
|
||||
<tr key={stop.id} className="hover:bg-stone-50 transition-colors">
|
||||
<td className="px-5 py-4">
|
||||
<Link href={`/admin/stops/${stop.id}`} className="font-medium text-stone-900 hover:text-emerald-600 transition-colors">
|
||||
{stop.city}, {stop.state}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-stone-600">{stop.location}</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className="font-mono text-xs text-stone-500">{formatDate(stop.date)}</span>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className="font-mono text-xs text-stone-500">{formatTime(stop.time)}</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 hidden md:table-cell">
|
||||
<span className="text-stone-600">
|
||||
{Array.isArray(stop.brands) ? stop.brands[0]?.name : stop.brands?.name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${
|
||||
stop.status === "draft"
|
||||
? "bg-amber-100 text-amber-700 border border-amber-200"
|
||||
: stop.active
|
||||
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
|
||||
: "bg-stone-100 text-stone-600 border border-stone-200"
|
||||
}`}>
|
||||
{stop.status === "draft" ? "Draft" : stop.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="relative inline-flex items-center gap-2">
|
||||
<Link
|
||||
href={`/admin/stops/${stop.id}`}
|
||||
className="rounded-lg px-3 py-1.5 text-xs font-medium text-stone-600 hover:text-stone-900 hover:bg-stone-100 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setOpenMenu(openMenu === stop.id ? null : stop.id)}
|
||||
className="rounded-lg px-2 py-1.5 text-stone-400 hover:text-stone-600 hover:bg-stone-100 transition-colors"
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
|
||||
{openMenu === stop.id && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => { setOpenMenu(null); setConfirmDelete(null); }} />
|
||||
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-stone-200 shadow-lg">
|
||||
{stop.status === "draft" && (
|
||||
<button
|
||||
onClick={() => handlePublish(stop.id)}
|
||||
disabled={publishingId === stop.id}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-emerald-600 hover:bg-emerald-50 disabled:opacity-50"
|
||||
>
|
||||
{publishingId === stop.id ? "Publishing..." : "Publish"}
|
||||
</button>
|
||||
)}
|
||||
<a href={`/admin/stops/new?duplicate=${stop.id}`} className="block px-4 py-2.5 text-sm text-stone-600 hover:bg-stone-50">
|
||||
Duplicate
|
||||
</a>
|
||||
<button
|
||||
onClick={() => { setOpenMenu(null); setConfirmDelete(stop.id); }}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{confirmDelete === stop.id && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-30" onClick={() => setConfirmDelete(null)} />
|
||||
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-stone-200 shadow-lg p-4">
|
||||
<p className="text-sm font-semibold text-stone-900">Delete "{stop.city}, {stop.state}"?</p>
|
||||
<p className="mt-1.5 text-xs text-stone-500">This cannot be undone.</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
className="flex-1 rounded-lg border border-stone-200 px-3 py-2 text-xs font-medium text-stone-700 hover:bg-stone-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(stop.id)}
|
||||
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-medium text-white hover:bg-red-500"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
<p className="text-sm text-stone-500">
|
||||
Showing {page * PAGE_SIZE + 1} to {Math.min((page + 1) * PAGE_SIZE, filtered.length)} of {filtered.length}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl border border-stone-200 text-stone-500 hover:border-stone-300 hover:text-stone-700 hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="px-3 text-sm font-medium text-stone-700">{page + 1} / {totalPages}</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl border border-stone-200 text-stone-500 hover:border-stone-300 hover:text-stone-700 hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Modals */}
|
||||
<ScheduleImportModal brandId={brandId} onClose={() => setShowImport(false)} onComplete={() => router.refresh()} />
|
||||
<AddStopModal isOpen={showAdd} onClose={() => setShowAdd(false)} brandId={brandId} onSuccess={() => router.refresh()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
// 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
|
||||
useEffect(() => {
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const res = await fetch(`/api/integrations/ai-provider?brandId=${encodeURIComponent(brandId)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSettings({
|
||||
provider: data.provider ?? "openai",
|
||||
apiKey: data.apiKey ?? "",
|
||||
orgId: data.orgId ?? "",
|
||||
model: data.model ?? "gpt-4o-mini",
|
||||
customEndpoint: data.customEndpoint ?? "",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load AI settings:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadSettings();
|
||||
}, [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
|
||||
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 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
|
||||
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 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
|
||||
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 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
|
||||
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 className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
|
||||
Base URL <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
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
|
||||
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
|
||||
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
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
"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) {
|
||||
const [selectedBrandId, setSelectedBrandId] = useState(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 className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">API Key</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
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 className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">From Email</label>
|
||||
<input
|
||||
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 className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">From Name</label>
|
||||
<input
|
||||
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
|
||||
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
|
||||
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 className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Account SID</label>
|
||||
<input
|
||||
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 className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Auth Token</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
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 className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Phone Number</label>
|
||||
<input
|
||||
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
|
||||
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
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
"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
|
||||
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
|
||||
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
|
||||
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
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import AdvancedIntegrations from "@/components/admin/AdvancedIntegrations";
|
||||
import AdvancedAIPanel from "@/components/admin/AdvancedAIPanel";
|
||||
import AdvancedSquareSync from "@/components/admin/AdvancedSquareSync";
|
||||
import AdvancedShipping from "@/components/admin/AdvancedShipping";
|
||||
import AdvancedPayments from "@/components/admin/AdvancedPayments";
|
||||
|
||||
type Tab = "integrations" | "ai" | "square" | "shipping" | "webhooks" | "payments";
|
||||
|
||||
const TABS: { id: Tab; label: string; icon: string }[] = [
|
||||
{ id: "integrations", label: "Integrations", icon: "plug" },
|
||||
{ id: "ai", label: "AI Tools", icon: "sparkles" },
|
||||
{ id: "square", label: "Square Sync", icon: "square" },
|
||||
{ id: "shipping", label: "Shipping", icon: "truck" },
|
||||
{ id: "webhooks", label: "Webhooks", icon: "webhook" },
|
||||
{ id: "payments", label: "Payments", icon: "credit-card" },
|
||||
];
|
||||
|
||||
// Icon components
|
||||
const Icons = {
|
||||
plug: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/>
|
||||
</svg>
|
||||
),
|
||||
sparkles: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"/>
|
||||
</svg>
|
||||
),
|
||||
square: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<path d="M9 12h6M12 9v6"/>
|
||||
</svg>
|
||||
),
|
||||
truck: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"/>
|
||||
</svg>
|
||||
),
|
||||
webhook: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
<path d="M8.25 8.25l-.75.75M14.25 14.25l-.75.75M8.25 14.25l.75-.75M14.25 8.25l.75.75"/>
|
||||
</svg>
|
||||
),
|
||||
"credit-card": (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="5" width="20" height="14" rx="2"/>
|
||||
<path d="M2 10h20"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
brands: { id: string; name: string }[];
|
||||
stripeConnect?: {
|
||||
is_connected: boolean;
|
||||
account_id?: string;
|
||||
charges_enabled?: boolean;
|
||||
payouts_enabled?: boolean;
|
||||
details_submitted?: boolean;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export default function AdvancedSettingsClient({ brandId, brands, stripeConnect }: Props) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("integrations");
|
||||
|
||||
// Handle Stripe Connect callback params
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get("stripe_connected") === "true") {
|
||||
// Successfully connected - could show a toast or refresh
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
} else if (params.get("stripe_refresh") === "true") {
|
||||
// Link expired, need to refresh
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||
{/* Header */}
|
||||
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-xl bg-violet-600">
|
||||
{Icons.sparkles("h-5 w-5 sm:h-6 sm:w-6 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-black text-[var(--admin-text-primary)] tracking-tight">Advanced</h1>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Developer settings, APIs, and integrations</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab navigation */}
|
||||
<nav className="grid grid-cols-6 gap-1 p-1.5 rounded-xl bg-white border border-stone-200">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`relative flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 rounded-lg px-1 sm:px-3 py-2 text-[9px] sm:text-xs font-semibold transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "bg-violet-600 text-white"
|
||||
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{tab.icon === "plug" && Icons.plug("h-4 w-4")}
|
||||
{tab.icon === "sparkles" && Icons.sparkles("h-4 w-4")}
|
||||
{tab.icon === "square" && Icons.square("h-4 w-4")}
|
||||
{tab.icon === "truck" && Icons.truck("h-4 w-4")}
|
||||
{tab.icon === "webhook" && Icons.webhook("h-4 w-4")}
|
||||
{tab.icon === "credit-card" && Icons["credit-card"]("h-4 w-4")}
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
<span className="sm:hidden">{tab.label.substring(0, 3)}</span>
|
||||
{activeTab === tab.id && (
|
||||
<div className="absolute bottom-1 sm:bottom-1.5 left-1/2 -translate-x-1/2 h-0.5 w-6 sm:w-8 bg-white rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
||||
{activeTab === "integrations" && (
|
||||
<AdvancedIntegrations brandId={brandId} brands={brands} />
|
||||
)}
|
||||
|
||||
{activeTab === "ai" && (
|
||||
<AdvancedAIPanel brandId={brandId} />
|
||||
)}
|
||||
|
||||
{activeTab === "square" && (
|
||||
<AdvancedSquareSync brandId={brandId} />
|
||||
)}
|
||||
|
||||
{activeTab === "shipping" && (
|
||||
<AdvancedShipping brandId={brandId} />
|
||||
)}
|
||||
|
||||
{activeTab === "webhooks" && (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-stone-100">
|
||||
{Icons.webhook("h-6 w-6 text-stone-600")}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">Webhooks</h2>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
||||
Configure webhook endpoints to receive real-time notifications for order events, payments, and inventory updates.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coming Soon Card */}
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<div className="bg-stone-50/50 px-6 py-4 border-b border-[var(--admin-border)]">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Available Webhook Events</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-50">
|
||||
<svg className="h-5 w-5 text-amber-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 6v6l4 2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--admin-text-primary)]">Coming Soon</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Webhook configuration will be available shortly</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-6">
|
||||
{[
|
||||
{ icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4", label: "Order Status Changes", desc: "Track order lifecycle events" },
|
||||
{ icon: "M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z", label: "Payment Confirmations", desc: "Get notified on successful payments" },
|
||||
{ icon: "M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4", label: "Inventory Updates", desc: "Real-time stock changes" },
|
||||
{ icon: "M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1", label: "Custom Webhook URLs", desc: "Send events to your endpoint" },
|
||||
].map((event, i) => (
|
||||
<div key={i} className="flex items-center gap-3 p-3 rounded-lg bg-stone-50/50 border border-stone-100">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-white border border-stone-200">
|
||||
<svg className="h-4 w-4 text-stone-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d={event.icon}/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--admin-text-primary)]">{event.label}</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">{event.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 rounded-xl bg-gradient-to-r from-stone-50 to-stone-100/50 border border-stone-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-emerald-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span className="text-sm text-stone-600">Webhook configuration will allow you to receive events via HTTP POST to your custom endpoint.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-between pt-4 border-t border-stone-100">
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
Need webhooks now?{" "}
|
||||
<button className="text-emerald-600 hover:text-emerald-700 font-medium">
|
||||
Contact support
|
||||
</button>
|
||||
</p>
|
||||
<button className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700 transition-colors">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
|
||||
</svg>
|
||||
Notify Me
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical Info Card */}
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6">
|
||||
<h4 className="text-sm font-semibold text-[var(--admin-text-primary)] mb-3">Webhook Payload Format</h4>
|
||||
<div className="bg-stone-900 rounded-lg p-4 overflow-x-auto">
|
||||
<pre className="text-xs text-stone-300 font-mono leading-relaxed">
|
||||
{`{
|
||||
"event": "order.status_changed",
|
||||
"timestamp": "2024-01-15T10:30:00Z",
|
||||
"data": {
|
||||
"order_id": "...",
|
||||
"status": "delivered"
|
||||
}
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "payments" && (
|
||||
<AdvancedPayments brandId={brandId} initialStatus={stripeConnect ?? null} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
"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 className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Carrier</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 className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Account Number</label>
|
||||
<input
|
||||
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 className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">API Key / Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
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
|
||||
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"
|
||||
>
|
||||
Test Connection
|
||||
</button>
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"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 className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Application ID</label>
|
||||
<input
|
||||
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 className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Access Token</label>
|
||||
<input
|
||||
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 className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Location ID</label>
|
||||
<input
|
||||
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
|
||||
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"
|
||||
>
|
||||
Test Connection
|
||||
</button>
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ADDON_CATALOG, type BrandFeatureKey } from "@/lib/feature-flags";
|
||||
import { toggleBrandFeature } from "@/actions/settings/features";
|
||||
import GlassModal from "./GlassModal";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
@@ -15,24 +15,25 @@ export default function BrandFeatureCards({ brandId, initialEnabledFeatures }: P
|
||||
const router = useRouter();
|
||||
const [enabledFeatures, setEnabledFeatures] = useState<Record<string, boolean>>(initialEnabledFeatures);
|
||||
const [toggling, setToggling] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||
const [showEnableModal, setShowEnableModal] = useState<BrandFeatureKey | null>(null);
|
||||
|
||||
const showToast = (msg: string) => {
|
||||
setToast(msg);
|
||||
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
|
||||
setToast({ message, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
|
||||
const handleToggle = async (key: string) => {
|
||||
const newEnabled = !enabledFeatures[key];
|
||||
const handleToggle = async (key: BrandFeatureKey, enable: boolean) => {
|
||||
setToggling(key);
|
||||
setEnabledFeatures((prev) => ({ ...prev, [key]: newEnabled }));
|
||||
const result = await toggleBrandFeature(brandId, key as BrandFeatureKey, newEnabled);
|
||||
setShowEnableModal(null);
|
||||
setEnabledFeatures((prev) => ({ ...prev, [key]: enable }));
|
||||
const result = await toggleBrandFeature(brandId, key, enable);
|
||||
if (result.success) {
|
||||
showToast(newEnabled ? `${ADDON_CATALOG[key as BrandFeatureKey].name} enabled` : `${ADDON_CATALOG[key as BrandFeatureKey].name} disabled`);
|
||||
showToast(enable ? `${ADDON_CATALOG[key].name} enabled` : `${ADDON_CATALOG[key].name} disabled`);
|
||||
router.refresh();
|
||||
} else {
|
||||
setEnabledFeatures((prev) => ({ ...prev, [key]: !newEnabled }));
|
||||
showToast(`Error: ${result.error}`);
|
||||
setEnabledFeatures((prev) => ({ ...prev, [key]: !enable }));
|
||||
showToast(`Error: ${result.error}`, 'error');
|
||||
}
|
||||
setToggling(null);
|
||||
};
|
||||
@@ -42,10 +43,17 @@ export default function BrandFeatureCards({ brandId, initialEnabledFeatures }: P
|
||||
return (
|
||||
<>
|
||||
{toast && (
|
||||
<div className="fixed bottom-6 right-6 z-50 rounded-xl bg-stone-900 px-4 py-3 text-sm font-medium text-white shadow-lg">
|
||||
{toast}
|
||||
<div
|
||||
className="fixed bottom-6 right-6 z-50 rounded-xl px-5 py-3 text-sm font-medium shadow-lg backdrop-blur-sm"
|
||||
style={{
|
||||
background: toast.type === 'error' ? 'rgba(239, 68, 68, 0.9)' : 'rgba(15, 118, 110, 0.95)',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{keys.map((key) => {
|
||||
const addon = ADDON_CATALOG[key];
|
||||
@@ -55,63 +63,127 @@ export default function BrandFeatureCards({ brandId, initialEnabledFeatures }: P
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`rounded-2xl p-6 shadow-black/20 ring-1 ${
|
||||
enabled
|
||||
? "bg-gradient-to-br from-white to-green-50 ring-green-200"
|
||||
: "bg-zinc-900 ring-stone-200"
|
||||
}`}
|
||||
className="rounded-2xl p-6 transition-all"
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.7)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: enabled ? '1px solid rgba(16, 185, 129, 0.3)' : '1px solid rgba(0, 0, 0, 0.06)',
|
||||
boxShadow: enabled
|
||||
? '0 8px 24px rgba(16, 185, 129, 0.1), inset 0 1px 0 rgba(255,255,255,0.8)'
|
||||
: '0 4px 12px rgba(0, 0, 0, 0.04)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="text-3xl mt-0.5">{addon.icon}</span>
|
||||
<div
|
||||
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl"
|
||||
style={{
|
||||
background: enabled
|
||||
? 'linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(16, 185, 129, 0.08) 100%)'
|
||||
: 'rgba(0, 0, 0, 0.04)',
|
||||
border: enabled ? '1px solid rgba(16, 185, 129, 0.2)' : '1px solid rgba(0, 0, 0, 0.06)',
|
||||
}}
|
||||
>
|
||||
<span className="text-2xl">{addon.icon}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-1">
|
||||
<h3 className="text-base font-semibold text-zinc-100">{addon.name}</h3>
|
||||
<h3 className="text-base font-semibold text-stone-950">{addon.name}</h3>
|
||||
<span
|
||||
className={`text-xs font-medium rounded-full px-2 py-0.5 ${
|
||||
enabled
|
||||
? "bg-green-900/40 text-green-400"
|
||||
: "bg-stone-100 text-stone-500"
|
||||
}`}
|
||||
className="text-xs font-medium rounded-full px-2.5 py-0.5"
|
||||
style={{
|
||||
background: enabled ? 'rgba(16, 185, 129, 0.15)' : 'rgba(0, 0, 0, 0.04)',
|
||||
color: enabled ? '#047857' : 'rgba(0, 0, 0, 0.4)',
|
||||
border: enabled ? '1px solid rgba(16, 185, 129, 0.2)' : '1px solid rgba(0, 0, 0, 0.06)',
|
||||
}}
|
||||
>
|
||||
{enabled ? "Enabled" : "Disabled"}
|
||||
{enabled ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-stone-700 leading-relaxed">
|
||||
<p className="text-sm text-stone-500 leading-relaxed">
|
||||
{addon.description}
|
||||
</p>
|
||||
{addon.addOnPrice && !enabled && (
|
||||
<p className="mt-2 text-xs font-medium text-amber-400">
|
||||
Requires: {addon.addOnPrice}
|
||||
{addon.addOnPrice && (
|
||||
<p className="mt-2 text-xs font-medium" style={{ color: '#b45309' }}>
|
||||
{addon.addOnPrice}/month
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center gap-3 justify-end">
|
||||
{enabled && (
|
||||
<Link
|
||||
href={addon.adminRoute}
|
||||
className="rounded-xl border border-zinc-800 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-zinc-950 transition-colors"
|
||||
>
|
||||
Open Module →
|
||||
</Link>
|
||||
)}
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => handleToggle(key)}
|
||||
onClick={() => handleToggle(key, !enabled)}
|
||||
disabled={busy}
|
||||
className={`rounded-xl px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50 ${
|
||||
enabled
|
||||
? "border border-red-200 text-red-400 hover:bg-red-900/30"
|
||||
: "bg-green-600 text-white hover:bg-green-700"
|
||||
}`}
|
||||
className="rounded-xl px-5 py-2.5 text-sm font-semibold transition-all"
|
||||
style={{
|
||||
background: enabled
|
||||
? 'rgba(239, 68, 68, 0.1)'
|
||||
: 'linear-gradient(135deg, #059669 0%, #10b981 100%)',
|
||||
color: enabled ? '#dc2626' : 'white',
|
||||
border: enabled ? '1px solid rgba(239, 68, 68, 0.2)' : 'none',
|
||||
boxShadow: enabled ? 'none' : '0 2px 8px rgba(16, 185, 129, 0.25)',
|
||||
opacity: busy ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{busy ? "..." : enabled ? "Disable" : "Enable"}
|
||||
</button>
|
||||
|
||||
{enabled && addon.adminRoute && (
|
||||
<a
|
||||
href={addon.adminRoute}
|
||||
className="rounded-xl px-4 py-2 text-sm font-medium transition-all"
|
||||
style={{
|
||||
background: 'rgba(0, 0, 0, 0.02)',
|
||||
color: 'rgba(0, 0, 0, 0.6)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.08)',
|
||||
}}
|
||||
>
|
||||
Open →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Enable confirmation modal */}
|
||||
{showEnableModal && (
|
||||
<GlassModal
|
||||
title={`Enable ${ADDON_CATALOG[showEnableModal].name}?`}
|
||||
subtitle={`This will activate the add-on feature.`}
|
||||
onClose={() => setShowEnableModal(null)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-stone-500">
|
||||
{ADDON_CATALOG[showEnableModal].description}
|
||||
</p>
|
||||
{ADDON_CATALOG[showEnableModal].addOnPrice && (
|
||||
<p className="text-sm font-medium" style={{ color: '#b45309' }}>
|
||||
Price: {ADDON_CATALOG[showEnableModal].addOnPrice}/month
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-3 pt-4" style={{ borderTop: '1px solid rgba(0, 0, 0, 0.04)' }}>
|
||||
<button
|
||||
onClick={() => setShowEnableModal(null)}
|
||||
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 hover:bg-stone-100 transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggle(showEnableModal, true)}
|
||||
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #059669 0%, #10b981 100%)',
|
||||
boxShadow: '0 2px 8px rgba(16, 185, 129, 0.25)',
|
||||
}}
|
||||
>
|
||||
Enable
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,8 +11,6 @@ import { upsertCampaign, deleteCampaign } from "@/actions/communications/campaig
|
||||
import { getCommunicationTemplates } from "@/actions/communications/templates";
|
||||
import { getCommunicationSegments, type Segment } from "@/actions/communications/segments";
|
||||
|
||||
const BRAND_ID = process.env.NEXT_PUBLIC_TUXEDO_BRAND_ID ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const CAMPAIGN_TYPES: { value: CampaignType; label: string }[] = [
|
||||
{ value: "operational", label: "Operational" },
|
||||
{ value: "marketing", label: "Marketing" },
|
||||
@@ -20,18 +18,191 @@ const CAMPAIGN_TYPES: { value: CampaignType; label: string }[] = [
|
||||
];
|
||||
|
||||
const STATUS_COLORS: Record<CampaignStatus, string> = {
|
||||
draft: "bg-zinc-950 text-zinc-300",
|
||||
scheduled: "bg-blue-900/40 text-blue-700",
|
||||
draft: "bg-stone-100 text-stone-600",
|
||||
scheduled: "bg-blue-100 text-blue-700",
|
||||
sending: "bg-yellow-100 text-yellow-700",
|
||||
sent: "bg-green-900/40 text-green-400",
|
||||
canceled: "bg-red-900/40 text-red-400",
|
||||
sent: "bg-emerald-100 text-emerald-700",
|
||||
canceled: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
export default function CampaignListPanel({ initialCampaigns }: { initialCampaigns: Campaign[] }) {
|
||||
const TYPE_COLORS: Record<CampaignType, string> = {
|
||||
operational: "bg-purple-100 text-purple-700",
|
||||
marketing: "bg-amber-100 text-amber-700",
|
||||
transactional: "bg-sky-100 text-sky-700",
|
||||
};
|
||||
|
||||
// Icon components
|
||||
const Icons = {
|
||||
plus: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
),
|
||||
x: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
),
|
||||
mail: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"/>
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
|
||||
</svg>
|
||||
),
|
||||
arrowRight: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 12h14"/>
|
||||
<path d="m12 5 7 7-7 7"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
// New Campaign Modal
|
||||
function NewCampaignModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
brandId
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
brandId: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [campaignType, setCampaignType] = useState<CampaignType>("operational");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) {
|
||||
setError("Campaign name is required");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError("");
|
||||
|
||||
const result = await upsertCampaign({
|
||||
brand_id: brandId,
|
||||
name: name.trim(),
|
||||
subject: "",
|
||||
body_text: "",
|
||||
campaign_type: campaignType,
|
||||
status: "draft",
|
||||
audience_rules: { target: "all_customers" },
|
||||
});
|
||||
|
||||
setSaving(false);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to create campaign");
|
||||
return;
|
||||
}
|
||||
|
||||
setName("");
|
||||
setCampaignType("operational");
|
||||
onSuccess();
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl border border-[var(--admin-border)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--admin-border)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
|
||||
{Icons.mail("h-5 w-5 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">New Campaign</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Create a new email campaign</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-[var(--admin-card-hover)] transition-colors"
|
||||
>
|
||||
{Icons.x("h-5 w-5 text-[var(--admin-text-muted)]")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
|
||||
Campaign Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||||
placeholder="e.g. Weekly Pickup Reminder"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
|
||||
Campaign Type *
|
||||
</label>
|
||||
<select
|
||||
value={campaignType}
|
||||
onChange={(e) => setCampaignType(e.target.value as CampaignType)}
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||||
>
|
||||
{CAMPAIGN_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--admin-border)]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={saving || !name.trim()}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? "Creating..." : "Create Campaign"}
|
||||
{!saving && Icons.arrowRight("h-4 w-4")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CampaignListPanel({ initialCampaigns, brandId = "64294306-5f42-463d-a5e8-2ad6c81a96de" }: { initialCampaigns: Campaign[], brandId?: string }) {
|
||||
const router = useRouter();
|
||||
const [campaigns, setCampaigns] = useState(initialCampaigns);
|
||||
const [filterType, setFilterType] = useState<CampaignType | "all">("all");
|
||||
const [filterStatus, setFilterStatus] = useState<CampaignStatus | "all">("all");
|
||||
const [showNewModal, setShowNewModal] = useState(false);
|
||||
|
||||
const filtered = campaigns.filter((c) => {
|
||||
if (filterType !== "all" && c.campaign_type !== filterType) return false;
|
||||
@@ -40,23 +211,41 @@ export default function CampaignListPanel({ initialCampaigns }: { initialCampaig
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="p-4 sm:p-6">
|
||||
<NewCampaignModal
|
||||
isOpen={showNewModal}
|
||||
onClose={() => setShowNewModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowNewModal(false);
|
||||
router.refresh();
|
||||
}}
|
||||
brandId={brandId}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-zinc-100">Campaigns</h2>
|
||||
<p className="text-sm text-zinc-500">{filtered.length} campaign{filtered.length !== 1 ? "s" : ""}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-text-primary)]">
|
||||
{Icons.mail("w-4 h-4 text-[var(--admin-bg)]")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Campaigns</h2>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">{filtered.length} campaign{filtered.length !== 1 ? "s" : ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/admin/communications/campaigns/new"
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
<button
|
||||
onClick={() => setShowNewModal(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3 sm:px-4 py-2 text-xs sm:text-sm font-semibold text-white hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
+ New Campaign
|
||||
</a>
|
||||
{Icons.plus("h-3.5 w-3.5 sm:h-4 sm:w-4")}
|
||||
<span>New Campaign</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mb-4">
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3 mb-4">
|
||||
<select
|
||||
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
|
||||
className="text-xs sm:text-sm border border-[var(--admin-border)] rounded-lg px-3 py-2 bg-white text-[var(--admin-text-primary)]"
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value as CampaignType | "all")}
|
||||
>
|
||||
@@ -66,7 +255,7 @@ export default function CampaignListPanel({ initialCampaigns }: { initialCampaig
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
|
||||
className="text-xs sm:text-sm border border-[var(--admin-border)] rounded-lg px-3 py-2 bg-white text-[var(--admin-text-primary)]"
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value as CampaignStatus | "all")}
|
||||
>
|
||||
@@ -78,40 +267,47 @@ export default function CampaignListPanel({ initialCampaigns }: { initialCampaig
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">No campaigns found</div>
|
||||
<div className="text-center py-12 text-[var(--admin-text-muted)]">
|
||||
No campaigns found
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-zinc-800">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Name</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Type</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Status</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Created</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Sent</th>
|
||||
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
||||
<table className="w-full text-xs sm:text-sm">
|
||||
<thead className="bg-[var(--admin-card)]">
|
||||
<tr className="border-b border-[var(--admin-border)]">
|
||||
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Name</th>
|
||||
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Type</th>
|
||||
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Status</th>
|
||||
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Created</th>
|
||||
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Sent</th>
|
||||
<th className="text-right px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
<tbody className="divide-y divide-[var(--admin-border)]">
|
||||
{filtered.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-zinc-800 cursor-pointer" onClick={() => router.push(`/admin/communications/campaigns/${c.id}`)}>
|
||||
<td className="px-4 py-3 font-medium text-zinc-100">{c.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
c.campaign_type === "operational" ? "bg-purple-100 text-purple-700" :
|
||||
c.campaign_type === "marketing" ? "bg-orange-100 text-orange-700" :
|
||||
"bg-zinc-950 text-zinc-400"
|
||||
}`}>
|
||||
<tr
|
||||
key={c.id}
|
||||
className="hover:bg-[var(--admin-card-hover)] cursor-pointer transition-colors"
|
||||
onClick={() => router.push(`/admin/communications/campaigns/${c.id}`)}
|
||||
>
|
||||
<td className="px-3 sm:px-4 py-3 font-medium text-[var(--admin-text-primary)]">{c.name}</td>
|
||||
<td className="px-3 sm:px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] sm:text-xs font-medium ${TYPE_COLORS[c.campaign_type]}`}>
|
||||
{c.campaign_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[c.status]}`}>
|
||||
<td className="px-3 sm:px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] sm:text-xs font-medium ${STATUS_COLORS[c.status]}`}>
|
||||
{c.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-500">{formatDate(new Date(c.created_at))}</td>
|
||||
<td className="px-4 py-3 text-zinc-500">{c.sent_at ? formatDate(new Date(c.sent_at)) : "—"}</td>
|
||||
<td className="px-3 sm:px-4 py-3 text-[var(--admin-text-muted)]">{formatDate(new Date(c.created_at))}</td>
|
||||
<td className="px-3 sm:px-4 py-3 text-[var(--admin-text-muted)]">{c.sent_at ? formatDate(new Date(c.sent_at)) : "—"}</td>
|
||||
<td className="px-3 sm:px-4 py-3 text-right">
|
||||
{Icons.arrowRight("h-4 w-4 text-[var(--admin-text-muted)]")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -167,7 +363,7 @@ export function CampaignEditPanel({
|
||||
getCommunicationSegments(brandId).then((result) => {
|
||||
if (result.success) setSegments(result.segments);
|
||||
});
|
||||
}, []);
|
||||
}, [brandId]);
|
||||
|
||||
// When a segment is selected, populate audience fields
|
||||
function applySegment(segmentId: string) {
|
||||
@@ -256,37 +452,49 @@ export function CampaignEditPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="p-4 sm:p-6 max-w-3xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-zinc-100">
|
||||
{mode === "new" ? "New Campaign" : "Edit Campaign"}
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-text-primary)]">
|
||||
{Icons.mail("w-4 h-4 text-[var(--admin-bg)]")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">
|
||||
{mode === "new" ? "New Campaign" : "Edit Campaign"}
|
||||
</h2>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
|
||||
{mode === "new" ? "Create a new email campaign" : "Update campaign settings"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* Basic info */}
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-6 space-y-4">
|
||||
<h3 className="text-xs sm:text-sm font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide">Basic Info</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Campaign Name *</label>
|
||||
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Campaign Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
|
||||
placeholder="e.g. Tuxedo Stop 5/15 Reminder"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Campaign Type *</label>
|
||||
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Campaign Type *</label>
|
||||
<select
|
||||
value={campaignType}
|
||||
onChange={(e) => setCampaignType(e.target.value as CampaignType)}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
|
||||
>
|
||||
{CAMPAIGN_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
@@ -294,7 +502,7 @@ export function CampaignEditPanel({
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Template</label>
|
||||
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Template</label>
|
||||
<select
|
||||
value={templateId}
|
||||
onChange={(e) => {
|
||||
@@ -302,7 +510,7 @@ export function CampaignEditPanel({
|
||||
const t = templates.find((t) => t.id === e.target.value);
|
||||
if (t) applyTemplate(t);
|
||||
}}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
|
||||
>
|
||||
<option value="">No template</option>
|
||||
{templates.map((t) => (
|
||||
@@ -314,16 +522,16 @@ export function CampaignEditPanel({
|
||||
</div>
|
||||
|
||||
{/* Audience builder */}
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Audience</h3>
|
||||
<h3 className="text-xs sm:text-sm font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide">Audience</h3>
|
||||
{segments.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-zinc-500">Saved segment:</span>
|
||||
<span className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Saved segment:</span>
|
||||
<select
|
||||
value={selectedSegmentId}
|
||||
onChange={(e) => applySegment(e.target.value)}
|
||||
className="text-sm border border-zinc-600 rounded-lg px-3 py-1.5"
|
||||
className="text-xs sm:text-sm border border-[var(--admin-border)] rounded-lg px-3 py-1.5 bg-white"
|
||||
>
|
||||
<option value="">— None —</option>
|
||||
{segments.map((s) => (
|
||||
@@ -334,14 +542,14 @@ export function CampaignEditPanel({
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Target By</label>
|
||||
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Target By</label>
|
||||
<select
|
||||
value={audienceTarget}
|
||||
onChange={(e) => {
|
||||
setAudienceTarget(e.target.value);
|
||||
setSelectedSegmentId("");
|
||||
}}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
|
||||
>
|
||||
<option value="stop">Stop / Date Range</option>
|
||||
<option value="zip_code">ZIP Code</option>
|
||||
@@ -352,31 +560,31 @@ export function CampaignEditPanel({
|
||||
{audienceTarget === "stop" && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Stop ID</label>
|
||||
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Stop ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={stopId}
|
||||
onChange={(e) => setStopId(e.target.value)}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
|
||||
placeholder="UUID"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">From</label>
|
||||
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">From</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">To</label>
|
||||
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">To</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -385,15 +593,15 @@ export function CampaignEditPanel({
|
||||
type="button"
|
||||
onClick={loadPreview}
|
||||
disabled={previewLoading}
|
||||
className="text-sm text-blue-400 hover:text-blue-700 font-medium"
|
||||
className="text-xs sm:text-sm text-emerald-600 hover:text-emerald-700 font-semibold"
|
||||
>
|
||||
{previewLoading ? "Counting..." : "Preview audience count"}
|
||||
</button>
|
||||
{preview && (
|
||||
<div className="rounded-lg bg-blue-900/30 border border-blue-200 px-4 py-3 text-sm">
|
||||
<span className="font-semibold text-blue-700">{preview.count}</span> customers match this audience
|
||||
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm">
|
||||
<span className="font-semibold text-emerald-700">{preview.count}</span> customers match this audience
|
||||
{preview.sample_customers.length > 0 && (
|
||||
<div className="mt-2 text-xs text-blue-400">
|
||||
<div className="mt-2 text-xs text-emerald-600">
|
||||
Sample: {preview.sample_customers.slice(0, 5).map((c) => c.email).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
@@ -402,61 +610,61 @@ export function CampaignEditPanel({
|
||||
</div>
|
||||
|
||||
{/* Message content */}
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Message Content</h3>
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-6 space-y-4">
|
||||
<h3 className="text-xs sm:text-sm font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide">Message Content</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Subject</label>
|
||||
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Subject</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
|
||||
placeholder="Email subject line"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Body (Plain Text)</label>
|
||||
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Body (Plain Text)</label>
|
||||
<textarea
|
||||
value={bodyText}
|
||||
onChange={(e) => setBodyText(e.target.value)}
|
||||
rows={6}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
|
||||
placeholder="Message content..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scheduling */}
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Scheduling</h3>
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-6 space-y-4">
|
||||
<h3 className="text-xs sm:text-sm font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide">Scheduling</h3>
|
||||
<div className="flex items-center gap-4 sm:gap-6">
|
||||
<label className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
checked={scheduleMode === "now"}
|
||||
onChange={() => setScheduleMode("now")}
|
||||
className="text-blue-400"
|
||||
className="text-emerald-600"
|
||||
/>
|
||||
<span className="text-zinc-300">Send immediately</span>
|
||||
<span className="text-[var(--admin-text-primary)]">Send immediately</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<label className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
checked={scheduleMode === "later"}
|
||||
onChange={() => setScheduleMode("later")}
|
||||
className="text-blue-400"
|
||||
className="text-emerald-600"
|
||||
/>
|
||||
<span className="text-zinc-300">Schedule for later</span>
|
||||
<span className="text-[var(--admin-text-primary)]">Schedule for later</span>
|
||||
</label>
|
||||
</div>
|
||||
{scheduleMode === "later" && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Send date & time</label>
|
||||
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Send date & time</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={scheduledAt}
|
||||
onChange={(e) => setScheduledAt(e.target.value)}
|
||||
className="border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
|
||||
min={new Date().toISOString().slice(0, 16)}
|
||||
/>
|
||||
</div>
|
||||
@@ -469,7 +677,7 @@ export function CampaignEditPanel({
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !name || (scheduleMode === "later" && !scheduledAt)}
|
||||
className="inline-flex items-center rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
className="inline-flex items-center rounded-lg bg-emerald-600 px-4 sm:px-5 py-2 text-xs sm:text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? "Saving..." : scheduleMode === "later" ? "Schedule Campaign" : "Save Draft"}
|
||||
</button>
|
||||
@@ -478,17 +686,17 @@ export function CampaignEditPanel({
|
||||
type="button"
|
||||
onClick={handleSend}
|
||||
disabled={sending}
|
||||
className="inline-flex items-center rounded-lg bg-green-600 px-5 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
|
||||
className="inline-flex items-center rounded-lg bg-emerald-700 px-4 sm:px-5 py-2 text-xs sm:text-sm font-semibold text-white hover:bg-emerald-800 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{sending ? "Sending..." : "Send Campaign"}
|
||||
</button>
|
||||
)}
|
||||
{campaign?.id && scheduleMode === "later" && (
|
||||
<span className="text-sm text-blue-400 font-medium">
|
||||
<span className="text-xs sm:text-sm text-emerald-600 font-medium">
|
||||
Scheduled for {scheduledAt ? new Date(scheduledAt).toLocaleString() : "—"}
|
||||
</span>
|
||||
)}
|
||||
<a href="/admin/communications" className="text-sm text-zinc-500 hover:text-zinc-300 ml-4">
|
||||
<a href="/admin/communications" className="text-xs sm:text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] ml-4">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,43 +1,118 @@
|
||||
"use client";
|
||||
|
||||
const TABS = [
|
||||
{ id: "campaigns", label: "Campaigns", href: "/admin/communications" },
|
||||
{ id: "compose", label: "Compose", href: "/admin/communications/compose" },
|
||||
{ id: "segments", label: "Segments", href: "/admin/communications/segments" },
|
||||
{ id: "analytics", label: "Analytics", href: "/admin/communications/analytics" },
|
||||
{ id: "templates", label: "Templates", href: "/admin/communications/templates" },
|
||||
{ id: "contacts", label: "Contacts", href: "/admin/communications/contacts" },
|
||||
{ id: "logs", label: "Message Logs", href: "/admin/communications/logs" },
|
||||
{ id: "settings", label: "Settings", href: "/admin/communications/settings" },
|
||||
{ id: "campaigns", label: "Campaigns" },
|
||||
{ id: "compose", label: "Compose" },
|
||||
{ id: "segments", label: "Segments" },
|
||||
{ id: "analytics", label: "Analytics" },
|
||||
{ id: "templates", label: "Templates" },
|
||||
{ id: "contacts", label: "Contacts" },
|
||||
{ id: "logs", label: "Logs" },
|
||||
];
|
||||
|
||||
// Icon components
|
||||
const Icons = {
|
||||
mail: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"/>
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
|
||||
</svg>
|
||||
),
|
||||
send: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m22 2-11 11"/>
|
||||
<path d="M22 2 15 22l-4-9-9-4 20-7z"/>
|
||||
</svg>
|
||||
),
|
||||
users: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
),
|
||||
chart: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" x2="12" y1="20" y2="10"/>
|
||||
<line x1="18" x2="18" y1="20" y2="4"/>
|
||||
<line x1="6" x2="6" y1="20" y2="16"/>
|
||||
</svg>
|
||||
),
|
||||
fileText: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" x2="8" y1="13" y2="13"/>
|
||||
<line x1="16" x2="8" y1="17" y2="17"/>
|
||||
<line x1="10" x2="8" y1="9" y2="9"/>
|
||||
</svg>
|
||||
),
|
||||
userCheck: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<polyline points="16 11 18 13 22 9"/>
|
||||
</svg>
|
||||
),
|
||||
list: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="8" x2="21" y1="6" y2="6"/>
|
||||
<line x1="8" x2="21" y1="12" y2="12"/>
|
||||
<line x1="8" x2="21" y1="18" y2="18"/>
|
||||
<line x1="3" x2="3.01" y1="6" y2="6"/>
|
||||
<line x1="3" x2="3.01" y1="12" y2="12"/>
|
||||
<line x1="3" x2="3.01" y1="18" y2="18"/>
|
||||
</svg>
|
||||
),
|
||||
settings: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
),
|
||||
filter: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export default function CommunicationsNav({
|
||||
activeTab,
|
||||
}: {
|
||||
activeTab: "campaigns" | "templates" | "contacts" | "logs" | "settings" | "segments" | "analytics" | "compose";
|
||||
activeTab: "campaigns" | "templates" | "contacts" | "logs" | "segments" | "analytics" | "compose";
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-zinc-800 mb-6">
|
||||
<nav className="flex gap-1 -mb-px">
|
||||
{TABS.map((tab) => {
|
||||
const isActive = tab.id === activeTab;
|
||||
return (
|
||||
<a
|
||||
key={tab.id}
|
||||
href={tab.href}
|
||||
className={`px-1 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
isActive
|
||||
? "border-blue-600 text-blue-400"
|
||||
: "border-transparent text-zinc-500 hover:text-zinc-300 hover:border-zinc-600"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
<nav className="grid grid-cols-4 sm:grid-cols-7 gap-1 p-1.5 rounded-xl bg-white border border-stone-200">
|
||||
{TABS.map((tab) => {
|
||||
const isActive = tab.id === activeTab;
|
||||
return (
|
||||
<a
|
||||
key={tab.id}
|
||||
href={`/admin/communications/${tab.id}`}
|
||||
className={`relative flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 rounded-lg px-2 sm:px-3 py-2.5 text-[10px] sm:text-xs font-semibold transition-colors ${
|
||||
isActive
|
||||
? "bg-emerald-600 text-white"
|
||||
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{tab.id === "campaigns" && Icons.send("h-3.5 w-3.5 sm:h-4 sm:w-4")}
|
||||
{tab.id === "compose" && Icons.mail("h-3.5 w-3.5 sm:h-4 sm:w-4")}
|
||||
{tab.id === "segments" && Icons.users("h-3.5 w-3.5 sm:h-4 sm:w-4")}
|
||||
{tab.id === "analytics" && Icons.chart("h-3.5 w-3.5 sm:h-4 sm:w-4")}
|
||||
{tab.id === "templates" && Icons.fileText("h-3.5 w-3.5 sm:h-4 sm:w-4")}
|
||||
{tab.id === "contacts" && Icons.userCheck("h-3.5 w-3.5 sm:h-4 sm:w-4")}
|
||||
{tab.id === "logs" && Icons.list("h-3.5 w-3.5 sm:h-4 sm:w-4")}
|
||||
<span>{tab.label}</span>
|
||||
{isActive && (
|
||||
<div className="absolute bottom-1 sm:bottom-1.5 left-1/2 -translate-x-1/2 h-0.5 w-4 sm:w-8 bg-white rounded-full" />
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export { TABS };
|
||||
export { TABS, Icons };
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import CommunicationsNav from "./CommunicationsNav";
|
||||
import { useState } from "react";
|
||||
import CampaignListPanel, { CampaignEditPanel } from "./CampaignListPanel";
|
||||
import { TemplateListPanel, TemplateEditForm } from "./TemplateEditForm";
|
||||
import MessageLogPanel from "./MessageLogPanel";
|
||||
import CommunicationSettingsForm from "./CommunicationSettingsForm";
|
||||
import ContactListPanel from "./ContactListPanel";
|
||||
import ContactImportForm from "./ContactImportForm";
|
||||
import SegmentBuilderPage from "@/components/admin/HarvestReach/SegmentBuilderPage";
|
||||
@@ -12,22 +11,79 @@ import AnalyticsDashboard from "@/components/admin/HarvestReach/AnalyticsDashboa
|
||||
import CampaignComposerPage from "@/components/admin/HarvestReach/CampaignComposerPage";
|
||||
import type { Campaign } from "@/actions/communications/campaigns";
|
||||
import type { Template } from "@/actions/communications/templates";
|
||||
import type { MessageLogEntry } from "@/actions/communications/send";
|
||||
import type { CommunicationSettings } from "@/actions/communications/settings";
|
||||
import type { Contact } from "@/actions/communications/contacts";
|
||||
import type { Segment } from "@/actions/harvest-reach/segments";
|
||||
import type { CampaignAnalytics } from "@/actions/harvest-reach/campaigns";
|
||||
|
||||
type Tab = "campaigns" | "templates" | "contacts" | "segments" | "logs" | "analytics";
|
||||
|
||||
const TABS: { id: Tab; label: string; icon: string }[] = [
|
||||
{ id: "campaigns", label: "Campaigns", icon: "mail" },
|
||||
{ id: "templates", label: "Templates", icon: "file-text" },
|
||||
{ id: "contacts", label: "Contacts", icon: "users" },
|
||||
{ id: "segments", label: "Segments", icon: "layers" },
|
||||
{ id: "logs", label: "Logs", icon: "list" },
|
||||
{ id: "analytics", label: "Analytics", icon: "chart" },
|
||||
];
|
||||
|
||||
// Icon components
|
||||
const Icons = {
|
||||
mail: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"/>
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
|
||||
</svg>
|
||||
),
|
||||
fileText: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
),
|
||||
users: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
),
|
||||
layers: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
|
||||
<polyline points="2 17 12 22 22 17"/>
|
||||
<polyline points="2 12 12 17 22 12"/>
|
||||
</svg>
|
||||
),
|
||||
list: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="8" y1="6" x2="21" y2="6"/>
|
||||
<line x1="8" y1="12" x2="21" y2="12"/>
|
||||
<line x1="8" y1="18" x2="21" y2="18"/>
|
||||
<line x1="3" y1="6" x2="3.01" y2="6"/>
|
||||
<line x1="3" y1="12" x2="3.01" y2="12"/>
|
||||
<line x1="3" y1="18" x2="3.01" y2="18"/>
|
||||
</svg>
|
||||
),
|
||||
chart: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export default function CommunicationsPage({
|
||||
campaigns,
|
||||
templates,
|
||||
activeTab,
|
||||
brandId,
|
||||
editCampaign,
|
||||
editMode,
|
||||
editTemplate,
|
||||
initialLogs = [],
|
||||
initialSettings = null,
|
||||
initialContacts = [],
|
||||
initialContactTotal = 0,
|
||||
initialSegments = [],
|
||||
@@ -36,80 +92,146 @@ export default function CommunicationsPage({
|
||||
}: {
|
||||
campaigns: Campaign[];
|
||||
templates: Template[];
|
||||
activeTab: "campaigns" | "templates" | "contacts" | "logs" | "settings" | "segments" | "analytics" | "compose";
|
||||
brandId: string;
|
||||
editCampaign?: Campaign | null;
|
||||
editMode?: "edit" | "new";
|
||||
editTemplate?: Template | null;
|
||||
initialLogs?: MessageLogEntry[];
|
||||
initialSettings?: CommunicationSettings | null;
|
||||
initialContacts?: Contact[];
|
||||
initialContactTotal?: number;
|
||||
initialSegments?: Segment[];
|
||||
initialAnalytics?: CampaignAnalytics[];
|
||||
editCampaignId?: string;
|
||||
}) {
|
||||
const [currentTab, setCurrentTab] = useState<Tab>("campaigns");
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CommunicationsNav activeTab={activeTab} />
|
||||
|
||||
{activeTab === "campaigns" && (
|
||||
editCampaign !== undefined || editMode === "new" ? (
|
||||
<CampaignEditPanel
|
||||
campaign={editCampaign ?? undefined}
|
||||
templates={templates}
|
||||
mode={editMode ?? "edit"}
|
||||
brandId={brandId}
|
||||
/>
|
||||
) : (
|
||||
<CampaignListPanel initialCampaigns={campaigns} />
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === "templates" && (
|
||||
editTemplate !== undefined || editMode === "new" ? (
|
||||
<TemplateEditForm
|
||||
template={editTemplate ?? undefined}
|
||||
mode={editMode ?? "edit"}
|
||||
brandId={brandId}
|
||||
/>
|
||||
) : (
|
||||
<TemplateListPanel templates={templates} />
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === "contacts" && (
|
||||
<div className="space-y-6">
|
||||
<ContactListPanel initialContacts={initialContacts} initialTotal={initialContactTotal} brandId={brandId} />
|
||||
<ContactImportForm brandId={brandId} />
|
||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||
{/* Header */}
|
||||
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
|
||||
{/* Title row */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-xl bg-emerald-600">
|
||||
{Icons.mail("h-5 w-5 sm:h-6 sm:w-6 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-black text-[var(--admin-text-primary)] tracking-tight">Harvest Reach</h1>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Email campaigns, templates, and contacts</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "logs" && (
|
||||
<MessageLogPanel initialLogs={initialLogs} />
|
||||
)}
|
||||
{/* Tab navigation */}
|
||||
<nav className="grid grid-cols-3 sm:grid-cols-6 gap-1 p-1.5 rounded-xl bg-white border border-stone-200">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => {
|
||||
setCurrentTab(tab.id);
|
||||
setIsComposing(false);
|
||||
}}
|
||||
className={`relative flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 rounded-lg px-2 sm:px-4 py-2.5 sm:py-3 text-[10px] sm:text-sm font-semibold transition-colors ${
|
||||
currentTab === tab.id
|
||||
? "bg-emerald-600 text-white"
|
||||
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{tab.icon === "mail" && Icons.mail("h-4 w-4")}
|
||||
{tab.icon === "file-text" && Icons.fileText("h-4 w-4")}
|
||||
{tab.icon === "users" && Icons.users("h-4 w-4")}
|
||||
{tab.icon === "layers" && Icons.layers("h-4 w-4")}
|
||||
{tab.icon === "list" && Icons.list("h-4 w-4")}
|
||||
{tab.icon === "chart" && Icons.chart("h-4 w-4")}
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
<span className="sm:hidden">{tab.label.substring(0, 3)}</span>
|
||||
{currentTab === tab.id && (
|
||||
<div className="absolute bottom-1 sm:bottom-1.5 left-1/2 -translate-x-1/2 h-0.5 w-6 sm:w-12 bg-white rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{activeTab === "settings" && (
|
||||
<CommunicationSettingsForm settings={initialSettings} brandId={brandId} />
|
||||
)}
|
||||
{/* Content */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
||||
{/* Campaigns Tab */}
|
||||
{currentTab === "campaigns" && !isComposing && (
|
||||
editCampaign !== undefined || editMode === "new" ? (
|
||||
<CampaignEditPanel
|
||||
campaign={editCampaign ?? undefined}
|
||||
templates={templates}
|
||||
mode={editMode ?? "edit"}
|
||||
brandId={brandId}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<CampaignListPanel initialCampaigns={campaigns} brandId={brandId} />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === "segments" && (
|
||||
<SegmentBuilderPage brandId={brandId} initialSegments={initialSegments} />
|
||||
)}
|
||||
{/* Templates Tab */}
|
||||
{currentTab === "templates" && !isComposing && (
|
||||
editTemplate !== undefined || editMode === "new" ? (
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-4 sm:p-6">
|
||||
<TemplateEditForm
|
||||
template={editTemplate ?? undefined}
|
||||
mode={editMode ?? "edit"}
|
||||
brandId={brandId}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<TemplateListPanel templates={templates} brandId={brandId} />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === "analytics" && (
|
||||
<AnalyticsDashboard analytics={initialAnalytics} />
|
||||
)}
|
||||
{/* Contacts Tab */}
|
||||
{currentTab === "contacts" && !isComposing && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<ContactListPanel initialContacts={initialContacts} initialTotal={initialContactTotal} brandId={brandId} />
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-4 sm:p-6">
|
||||
<ContactImportForm brandId={brandId} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(activeTab === "compose" || (activeTab === "campaigns" && editCampaignId)) && (
|
||||
<CampaignComposerPage
|
||||
brandId={brandId}
|
||||
campaigns={campaigns}
|
||||
templates={templates}
|
||||
segments={initialSegments}
|
||||
editCampaignId={editCampaignId}
|
||||
/>
|
||||
)}
|
||||
{/* Segments Tab */}
|
||||
{currentTab === "segments" && !isComposing && (
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<SegmentBuilderPage brandId={brandId} initialSegments={initialSegments} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logs Tab */}
|
||||
{currentTab === "logs" && !isComposing && (
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<MessageLogPanel brandId={brandId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analytics Tab */}
|
||||
{currentTab === "analytics" && !isComposing && (
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<AnalyticsDashboard analytics={initialAnalytics} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compose Mode */}
|
||||
{(isComposing || editCampaignId) && (
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white">
|
||||
<CampaignComposerPage
|
||||
brandId={brandId}
|
||||
campaigns={campaigns}
|
||||
templates={templates}
|
||||
segments={initialSegments}
|
||||
editCampaignId={editCampaignId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type ColumnMapping,
|
||||
type ImportField,
|
||||
} from "@/actions/communications/contacts";
|
||||
import { uploadContactsToBucket, processBucketImport, listImportHistory } from "@/actions/communications/import-contacts";
|
||||
|
||||
type Step = "idle" | "preview" | "confirming" | "importing" | "result";
|
||||
|
||||
@@ -33,6 +34,57 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
external_id: "External ID",
|
||||
};
|
||||
|
||||
// Icon components
|
||||
const Icons = {
|
||||
upload: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
),
|
||||
uploadCloud: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="16 16 12 12 8 16"/>
|
||||
<line x1="12" y1="12" x2="12" y2="21"/>
|
||||
<path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"/>
|
||||
<polyline points="16 16 12 12 8 16"/>
|
||||
</svg>
|
||||
),
|
||||
file: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
</svg>
|
||||
),
|
||||
check: (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>
|
||||
),
|
||||
spinner: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" strokeOpacity="0.25"/>
|
||||
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round">
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite"/>
|
||||
</path>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
// Helper for stat colors
|
||||
function getStatColor(highlight: boolean, warn: boolean) {
|
||||
if (highlight) return "bg-emerald-50 border-emerald-200 text-emerald-700";
|
||||
if (warn) return "bg-amber-50 border-amber-200 text-amber-700";
|
||||
return "bg-[var(--admin-card)] border-[var(--admin-border)] text-[var(--admin-text-primary)]";
|
||||
}
|
||||
|
||||
function getStatValueColor(highlight: boolean, warn: boolean) {
|
||||
if (highlight) return "text-emerald-600";
|
||||
if (warn) return "text-amber-600";
|
||||
return "text-[var(--admin-text-muted)]";
|
||||
}
|
||||
|
||||
export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
const [step, setStep] = useState<Step>("idle");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
@@ -46,8 +98,14 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
const [overridingMappings, setOverridingMappings] = useState<
|
||||
Record<string, ImportField>
|
||||
>({});
|
||||
const [useBucket, setUseBucket] = useState(false); // Toggle for bucket upload
|
||||
const [uploadProgress, setUploadProgress] = useState<string>("");
|
||||
const [importHistory, setImportHistory] = useState<{ filename: string; size: number; createdAt: string }[]>([]);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// For large file detection (farmers with lots of data)
|
||||
const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
// ── CSV parsing + preview ──────────────────────────────────────────────────
|
||||
|
||||
const handleFile = useCallback(
|
||||
@@ -57,6 +115,55 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
setGlobalError("");
|
||||
setOverridingMappings({});
|
||||
|
||||
// Check if file is large enough to warrant bucket upload
|
||||
const isLargeFile = f.size > LARGE_FILE_THRESHOLD;
|
||||
|
||||
if (isLargeFile) {
|
||||
// Use bucket upload for large files
|
||||
setUploadProgress("Uploading file to storage...");
|
||||
setUseBucket(true);
|
||||
|
||||
const uploadResult = await uploadContactsToBucket(brandId, f);
|
||||
|
||||
if (!uploadResult.success) {
|
||||
setGlobalError(uploadResult.error);
|
||||
setStep("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadProgress(`Processing ${uploadResult.recordCount.toLocaleString()} records...`);
|
||||
|
||||
// Process from bucket
|
||||
const processResult = await processBucketImport(brandId, uploadResult.fileUrl);
|
||||
|
||||
setImporting(false);
|
||||
setUploadProgress("");
|
||||
|
||||
if (!processResult.success) {
|
||||
setGlobalError(processResult.error);
|
||||
setStep("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
setResult({
|
||||
created: processResult.created,
|
||||
updated: processResult.updated,
|
||||
skipped: processResult.skipped,
|
||||
errors: [],
|
||||
});
|
||||
setStep("result");
|
||||
|
||||
// Load import history
|
||||
const historyResult = await listImportHistory(brandId);
|
||||
if (historyResult.success) {
|
||||
setImportHistory(historyResult.imports);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Browser-based processing for smaller files
|
||||
setUseBucket(false);
|
||||
const text = await f.text();
|
||||
const previewResult = await previewContactImport(text);
|
||||
|
||||
@@ -75,7 +182,7 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
setPreview(previewResult.preview);
|
||||
setStep("preview");
|
||||
},
|
||||
[]
|
||||
[brandId, LARGE_FILE_THRESHOLD]
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
@@ -255,24 +362,24 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs border border-zinc-800 rounded-lg">
|
||||
<thead className="bg-zinc-900">
|
||||
<table className="w-full text-xs border border-[var(--admin-border)] rounded-lg">
|
||||
<thead className="bg-[var(--admin-card)]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-zinc-400 font-medium">
|
||||
<th className="px-3 py-2 text-left text-[var(--admin-text-muted)] font-semibold">
|
||||
CSV Column
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-zinc-400 font-medium">
|
||||
<th className="px-3 py-2 text-left text-[var(--admin-text-muted)] font-semibold">
|
||||
Maps To
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-zinc-400 font-medium">
|
||||
<th className="px-3 py-2 text-left text-[var(--admin-text-muted)] font-semibold">
|
||||
Sample Values
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.mappings.map((m) => (
|
||||
<tr key={m.csvColumn} className="border-t border-slate-100">
|
||||
<td className="px-3 py-2 text-slate-800 font-mono text-xs">
|
||||
<tr key={m.csvColumn} className="border-t border-[var(--admin-border)]">
|
||||
<td className="px-3 py-2 text-[var(--admin-text-primary)] font-mono text-xs">
|
||||
{m.csvColumn}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
@@ -290,7 +397,7 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
: (val as ImportField),
|
||||
}));
|
||||
}}
|
||||
className="text-xs border border-zinc-600 rounded px-2 py-1"
|
||||
className="text-xs border border-[var(--admin-border)] rounded px-2 py-1 bg-white text-[var(--admin-text-primary)]"
|
||||
>
|
||||
<option value="ignore">— ignore —</option>
|
||||
{Object.entries(FIELD_LABELS).map(([k, label]) => (
|
||||
@@ -300,7 +407,7 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-zinc-500 text-xs">
|
||||
<td className="px-3 py-2 text-[var(--admin-text-muted)] text-xs">
|
||||
{m.sampleValues.length > 0
|
||||
? m.sampleValues.join(", ")
|
||||
: "—"}
|
||||
@@ -312,7 +419,7 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
</div>
|
||||
|
||||
{preview.ignoredColumns.length > 0 && (
|
||||
<p className="text-xs text-zinc-500">
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
Extra columns (stored in metadata):
|
||||
<span className="font-mono">
|
||||
{" "}
|
||||
@@ -348,44 +455,44 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
if (!preview) return null;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs border border-zinc-800 rounded-lg">
|
||||
<thead className="bg-zinc-900">
|
||||
<table className="w-full text-xs border border-[var(--admin-border)] rounded-lg">
|
||||
<thead className="bg-[var(--admin-card)]">
|
||||
<tr>
|
||||
<th className="px-2 py-1 text-left text-zinc-400">#</th>
|
||||
<th className="px-2 py-1 text-left text-zinc-400">email</th>
|
||||
<th className="px-2 py-1 text-left text-zinc-400">phone</th>
|
||||
<th className="px-2 py-1 text-left text-zinc-400">first_name</th>
|
||||
<th className="px-2 py-1 text-left text-zinc-400">last_name</th>
|
||||
<th className="px-2 py-1 text-left text-zinc-400">full_name</th>
|
||||
<th className="px-2 py-1 text-left text-zinc-400">email_opt_in</th>
|
||||
<th className="px-2 py-1 text-left text-zinc-400">sms_opt_in</th>
|
||||
<th className="px-2 py-1 text-left text-zinc-400">tags</th>
|
||||
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">#</th>
|
||||
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">email</th>
|
||||
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">phone</th>
|
||||
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">first_name</th>
|
||||
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">last_name</th>
|
||||
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">full_name</th>
|
||||
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">email_opt_in</th>
|
||||
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">sms_opt_in</th>
|
||||
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.sampleRows.map((row, i) => (
|
||||
<tr key={i} className="border-t border-slate-100">
|
||||
<td className="px-2 py-1 text-slate-400">{i + 1}</td>
|
||||
<td className="px-2 py-1">{row.email ?? ""}</td>
|
||||
<td className="px-2 py-1">{row.phone ?? ""}</td>
|
||||
<td className="px-2 py-1">{row.first_name ?? ""}</td>
|
||||
<td className="px-2 py-1">{row.last_name ?? ""}</td>
|
||||
<td className="px-2 py-1">{row.full_name ?? ""}</td>
|
||||
<td className="px-2 py-1">
|
||||
<tr key={i} className="border-t border-[var(--admin-border)] hover:bg-[var(--admin-card-hover)]">
|
||||
<td className="px-2 py-1 text-[var(--admin-text-muted)]">{i + 1}</td>
|
||||
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.email ?? ""}</td>
|
||||
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.phone ?? ""}</td>
|
||||
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.first_name ?? ""}</td>
|
||||
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.last_name ?? ""}</td>
|
||||
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.full_name ?? ""}</td>
|
||||
<td className="px-2 py-1 text-[var(--admin-text-muted)]">
|
||||
{row.email_opt_in === undefined
|
||||
? ""
|
||||
: row.email_opt_in
|
||||
? "true"
|
||||
: "false"}
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<td className="px-2 py-1 text-[var(--admin-text-muted)]">
|
||||
{row.sms_opt_in === undefined
|
||||
? ""
|
||||
: row.sms_opt_in
|
||||
? "true"
|
||||
: "false"}
|
||||
</td>
|
||||
<td className="px-2 py-1">{row.tags?.join("; ") ?? ""}</td>
|
||||
<td className="px-2 py-1 text-[var(--admin-text-muted)]">{row.tags?.join("; ") ?? ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -397,21 +504,50 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
// ── Main render ────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
|
||||
<div className="p-4 sm:p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Import Contacts</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded bg-[var(--admin-text-primary)]">
|
||||
{Icons.uploadCloud("w-3 h-3 text-[var(--admin-bg)]")}
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Import Contacts</h3>
|
||||
</div>
|
||||
{step !== "idle" && (
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-300"
|
||||
className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
|
||||
>
|
||||
← Start over
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Large file banner */}
|
||||
{file && file.size > LARGE_FILE_THRESHOLD && (
|
||||
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-100">
|
||||
{Icons.check("h-4 w-4 text-emerald-600")}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-emerald-700">Large file detected</p>
|
||||
<p className="text-xs text-emerald-600">File will be uploaded to cloud storage for processing</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload progress */}
|
||||
{uploadProgress && (
|
||||
<div className="rounded-lg bg-blue-50 border border-blue-200 px-4 py-4 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
{Icons.spinner("h-5 w-5 text-blue-600 animate-spin")}
|
||||
<span className="text-blue-700 font-medium">{uploadProgress}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{globalError && (
|
||||
<div className="rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
|
||||
{globalError}
|
||||
</div>
|
||||
)}
|
||||
@@ -420,7 +556,7 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
{step === "idle" && (
|
||||
<>
|
||||
<div
|
||||
className="border-2 border-dashed border-zinc-600 rounded-xl p-8 text-center"
|
||||
className="border-2 border-dashed border-[var(--admin-border)] rounded-xl p-8 text-center hover:border-emerald-400 transition-colors"
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
@@ -435,16 +571,19 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
id="csv-upload"
|
||||
/>
|
||||
<label htmlFor="csv-upload" className="cursor-pointer">
|
||||
<div className="text-zinc-400 text-sm">
|
||||
<span className="font-medium text-blue-400 hover:text-blue-700">
|
||||
<div className="text-[var(--admin-text-muted)] text-sm">
|
||||
<span className="font-semibold text-emerald-600 hover:text-emerald-700">
|
||||
Click to upload
|
||||
</span>
|
||||
{" "}or drag and drop a CSV file
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mt-1">
|
||||
Works with Mailchimp, Square, Shopify, WooCommerce, QuickBooks, and
|
||||
generic spreadsheets
|
||||
</p>
|
||||
<p className="text-[10px] text-[var(--admin-text-muted)] mt-2 text-emerald-600">
|
||||
Large files (5MB+) automatically uploaded to cloud storage
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
@@ -456,14 +595,14 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
{preview.warnings.map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-lg bg-amber-900/30 border border-amber-200 px-4 py-3 text-sm text-amber-700"
|
||||
className="rounded-lg bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700"
|
||||
>
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="rounded-lg bg-blue-900/30 border border-blue-200 px-4 py-3 text-sm text-blue-700">
|
||||
<p className="font-medium mb-1">{file?.name}</p>
|
||||
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm text-emerald-700">
|
||||
<p className="font-semibold mb-1">{file?.name}</p>
|
||||
<p>
|
||||
Column mappings detected — review below, adjust if needed, then
|
||||
confirm import.
|
||||
@@ -475,14 +614,14 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-2 font-medium">
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-semibold uppercase tracking-wide">
|
||||
Import Summary
|
||||
</p>
|
||||
{renderStats()}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-2 font-medium">
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-semibold uppercase tracking-wide">
|
||||
Sample rows (first {preview.sampleRows.length})
|
||||
</p>
|
||||
{renderSampleRows()}
|
||||
@@ -490,17 +629,17 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
|
||||
{preview.skippedReasons.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-1 font-medium">
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-1 font-semibold">
|
||||
Skipped rows ({preview.skippedReasons.length})
|
||||
</p>
|
||||
<div className="max-h-32 overflow-y-auto text-xs text-zinc-500 space-y-1">
|
||||
<div className="max-h-32 overflow-y-auto text-xs text-[var(--admin-text-muted)] space-y-1">
|
||||
{preview.skippedReasons.slice(0, 10).map((s) => (
|
||||
<p key={s.rowIndex}>
|
||||
Row {s.rowIndex + 1}: {s.reason}
|
||||
</p>
|
||||
))}
|
||||
{preview.skippedReasons.length > 10 && (
|
||||
<p className="text-slate-400">
|
||||
<p className="text-[var(--admin-text-muted)]">
|
||||
...and{" "}
|
||||
{preview.skippedReasons.length - 10} more
|
||||
</p>
|
||||
@@ -510,7 +649,7 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
)}
|
||||
|
||||
{preview.totalRows > LARGE_FILE_ROWS && (
|
||||
<div className="rounded-lg bg-amber-900/30 border border-amber-200 px-4 py-3 text-sm text-amber-700">
|
||||
<div className="rounded-lg bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700">
|
||||
Large file ({preview.totalRows.toLocaleString()} rows) — confirm to
|
||||
proceed.
|
||||
</div>
|
||||
@@ -519,14 +658,14 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => handleReset()}
|
||||
className="rounded-lg border border-zinc-600 px-4 py-2 text-sm text-zinc-400 hover:bg-zinc-900"
|
||||
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleConfirm()}
|
||||
disabled={preview.validRows === 0}
|
||||
className="rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
className="rounded-lg bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Import {preview.validRows} contacts
|
||||
</button>
|
||||
@@ -536,7 +675,7 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
|
||||
{/* ── STEP: importing ────────────────────────────────────── */}
|
||||
{step === "importing" && (
|
||||
<div className="rounded-lg bg-blue-900/30 border border-blue-200 px-4 py-6 text-center text-sm text-blue-700">
|
||||
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-6 text-center text-sm text-emerald-700 font-semibold">
|
||||
Importing {importRows.length} contacts...
|
||||
</div>
|
||||
)}
|
||||
@@ -544,26 +683,29 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
{/* ── STEP: result ────────────────────────────────────────── */}
|
||||
{step === "result" && result && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg bg-green-900/30 border border-green-200 px-4 py-3 text-sm space-y-1">
|
||||
<p className="font-semibold text-green-400">Import complete</p>
|
||||
<p className="text-green-600">Created: {result.created}</p>
|
||||
<p className="text-green-600">Updated: {result.updated}</p>
|
||||
<p className="text-green-600">Skipped: {result.skipped}</p>
|
||||
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm space-y-1">
|
||||
<p className="font-semibold text-emerald-700 flex items-center gap-2">
|
||||
{Icons.check("h-4 w-4")}
|
||||
Import complete
|
||||
</p>
|
||||
<p className="text-emerald-600">Created: {result.created.toLocaleString()}</p>
|
||||
<p className="text-emerald-600">Updated: {result.updated.toLocaleString()}</p>
|
||||
<p className="text-emerald-600">Skipped: {result.skipped.toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
{result.errors.length > 0 && (
|
||||
<div className="rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm">
|
||||
<p className="font-medium text-red-400 mb-2">
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm">
|
||||
<p className="font-semibold text-red-600 mb-2">
|
||||
Errors ({result.errors.length})
|
||||
</p>
|
||||
<div className="max-h-40 overflow-y-auto space-y-1">
|
||||
{result.errors.slice(0, 10).map((e, i) => (
|
||||
<p key={i} className="text-red-400 text-xs">
|
||||
<p key={i} className="text-red-600 text-xs">
|
||||
{e.error}: {JSON.stringify(e.row).slice(0, 80)}
|
||||
</p>
|
||||
))}
|
||||
{result.errors.length > 10 && (
|
||||
<p className="text-red-400 text-xs">
|
||||
<p className="text-red-600 text-xs">
|
||||
...and {result.errors.length - 10} more
|
||||
</p>
|
||||
)}
|
||||
@@ -573,12 +715,34 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="rounded-lg border border-zinc-600 px-4 py-2 text-sm text-zinc-400 hover:bg-zinc-900"
|
||||
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] transition-colors"
|
||||
>
|
||||
Import more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import history for bucket imports */}
|
||||
{importHistory.length > 0 && (
|
||||
<div className="border-t border-[var(--admin-border)] pt-4 mt-4">
|
||||
<p className="text-xs font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide mb-3">
|
||||
Previous Imports
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{importHistory.slice(0, 5).map((imp, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-xs p-2 rounded-lg bg-[var(--admin-card)]">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icons.file("h-4 w-4 text-[var(--admin-text-muted)]")}
|
||||
<span className="text-[var(--admin-text-primary)]">{imp.filename}</span>
|
||||
</div>
|
||||
<span className="text-[var(--admin-text-muted)]">
|
||||
{(imp.size / 1024).toFixed(1)} KB
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -594,28 +758,16 @@ function Stat({
|
||||
highlight?: boolean;
|
||||
warn?: boolean;
|
||||
}) {
|
||||
const bgColor = highlight ? "bg-emerald-50 border-emerald-200" : warn ? "bg-amber-50 border-amber-200" : "bg-[var(--admin-card)] border-[var(--admin-border)]";
|
||||
const valueColor = highlight ? "text-emerald-700" : warn ? "text-amber-700" : "text-[var(--admin-text-primary)]";
|
||||
const labelColor = highlight ? "text-emerald-600" : warn ? "text-amber-600" : "text-[var(--admin-text-muted)]";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border px-4 py-3 text-center ${
|
||||
highlight
|
||||
? "bg-blue-900/30 border-blue-200"
|
||||
: warn
|
||||
? "bg-amber-900/30 border-amber-200"
|
||||
: "bg-zinc-900 border-zinc-800"
|
||||
}`}
|
||||
>
|
||||
<p
|
||||
className={`text-xl font-semibold ${
|
||||
highlight
|
||||
? "text-blue-700"
|
||||
: warn
|
||||
? "text-amber-700"
|
||||
: "text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
<div className={`rounded-lg border px-4 py-3 text-center ${bgColor}`}>
|
||||
<p className={`text-xl font-bold ${valueColor}`}>
|
||||
{value.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">{label}</p>
|
||||
<p className={`text-xs mt-0.5 ${labelColor}`}>{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,53 @@ import { getContacts, deleteContact, exportContacts } from "@/actions/communicat
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
const SOURCE_COLORS: Record<ContactSource, string> = {
|
||||
order: "bg-blue-900/40 text-blue-700",
|
||||
order: "bg-blue-100 text-blue-700",
|
||||
import: "bg-purple-100 text-purple-700",
|
||||
manual: "bg-green-900/40 text-green-400",
|
||||
admin: "bg-zinc-950 text-zinc-300",
|
||||
manual: "bg-emerald-100 text-emerald-700",
|
||||
admin: "bg-stone-100 text-stone-600",
|
||||
};
|
||||
|
||||
// Icon components
|
||||
const Icons = {
|
||||
users: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
),
|
||||
download: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
),
|
||||
search: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.3-4.3"/>
|
||||
</svg>
|
||||
),
|
||||
trash: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
<line x1="10" y1="11" x2="10" y2="17"/>
|
||||
<line x1="14" y1="11" x2="14" y2="17"/>
|
||||
</svg>
|
||||
),
|
||||
chevronLeft: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m15 18-6-6 6-6"/>
|
||||
</svg>
|
||||
),
|
||||
chevronRight: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m9 18 6-6-6-6"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export default function ContactListPanel({
|
||||
@@ -101,34 +144,46 @@ export default function ContactListPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-zinc-100">Contacts</h2>
|
||||
<p className="text-sm text-zinc-500">{total} contact{total !== 1 ? "s" : ""}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-text-primary)]">
|
||||
{Icons.users("w-4 h-4 text-[var(--admin-bg)]")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Contacts</h2>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">{total} contact{total !== 1 ? "s" : ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting || total === 0}
|
||||
className="rounded-lg border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--admin-border)] bg-white px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] hover:bg-[var(--admin-card-hover)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{exporting ? "Exporting..." : "Export Contacts"}
|
||||
{Icons.download("h-3.5 w-3.5 sm:h-4 sm:w-4")}
|
||||
<span>{exporting ? "Exporting..." : "Export"}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search + filters */}
|
||||
<form onSubmit={handleSearch} className="flex gap-3 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search email, name, phone..."
|
||||
className="flex-1 text-sm border border-zinc-600 rounded-lg px-3 py-2"
|
||||
/>
|
||||
<form onSubmit={handleSearch} className="flex flex-col sm:flex-row gap-3 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
|
||||
{Icons.search("h-4 w-4 text-[var(--admin-text-muted)]")}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search email, name, phone..."
|
||||
className="w-full pl-10 pr-3 py-2 text-sm border border-[var(--admin-border)] rounded-lg bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={sourceFilter}
|
||||
onChange={(e) => handleSourceFilter(e.target.value as ContactSource | "")}
|
||||
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
|
||||
className="text-sm border border-[var(--admin-border)] rounded-lg px-3 py-2 bg-white text-[var(--admin-text-primary)]"
|
||||
>
|
||||
<option value="">All Sources</option>
|
||||
<option value="order">Order</option>
|
||||
@@ -138,7 +193,7 @@ export default function ContactListPanel({
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
@@ -146,56 +201,58 @@ export default function ContactListPanel({
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-400">Loading...</div>
|
||||
<div className="text-center py-12 text-[var(--admin-text-muted)]">Loading...</div>
|
||||
) : contacts.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">No contacts found</div>
|
||||
<div className="text-center py-12 text-[var(--admin-text-muted)]">No contacts found</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden sm:block bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-zinc-800">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Name</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Email</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Phone</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Source</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Email Opt-in</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Unsubscribed</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400"></th>
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden sm:block overflow-x-auto -mx-4 sm:mx-0">
|
||||
<table className="w-full text-xs sm:text-sm">
|
||||
<thead className="bg-[var(--admin-card)]">
|
||||
<tr className="border-b border-[var(--admin-border)]">
|
||||
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Name</th>
|
||||
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Email</th>
|
||||
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Phone</th>
|
||||
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Source</th>
|
||||
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Email</th>
|
||||
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Unsubscribed</th>
|
||||
<th className="text-right px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
<tbody className="divide-y divide-[var(--admin-border)]">
|
||||
{contacts.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-zinc-800">
|
||||
<td className="px-4 py-3 font-medium text-zinc-100">
|
||||
<tr key={c.id} className="hover:bg-[var(--admin-card-hover)] transition-colors">
|
||||
<td className="px-3 sm:px-4 py-3 font-medium text-[var(--admin-text-primary)]">
|
||||
{c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-400">{c.email || "—"}</td>
|
||||
<td className="px-4 py-3 text-zinc-400">{c.phone || "—"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${SOURCE_COLORS[c.source]}`}>
|
||||
<td className="px-3 sm:px-4 py-3 text-[var(--admin-text-muted)]">{c.email || "—"}</td>
|
||||
<td className="px-3 sm:px-4 py-3 text-[var(--admin-text-muted)]">{c.phone || "—"}</td>
|
||||
<td className="px-3 sm:px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] sm:text-xs font-medium ${SOURCE_COLORS[c.source]}`}>
|
||||
{c.source}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<td className="px-3 sm:px-4 py-3">
|
||||
{c.email_opt_in ? (
|
||||
<span className="text-green-600 text-xs font-medium">Opted in</span>
|
||||
<span className="text-emerald-600 text-xs font-semibold">Opted in</span>
|
||||
) : (
|
||||
<span className="text-red-500 text-xs font-medium">Opted out</span>
|
||||
<span className="text-red-500 text-xs font-semibold">Opted out</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-500 text-xs">
|
||||
<td className="px-3 sm:px-4 py-3 text-[var(--admin-text-muted)] text-xs">
|
||||
{c.unsubscribed_at
|
||||
? formatDate(new Date(c.unsubscribed_at))
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<td className="px-3 sm:px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => handleDelete(c.id)}
|
||||
disabled={deleting === c.id}
|
||||
className="text-red-500 hover:text-red-400 text-xs disabled:opacity-50"
|
||||
className="p-1.5 rounded-lg hover:bg-red-50 text-red-500 hover:text-red-600 disabled:opacity-50 transition-colors"
|
||||
title="Delete contact"
|
||||
>
|
||||
{deleting === c.id ? "..." : "Delete"}
|
||||
{Icons.trash("h-4 w-4")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -207,29 +264,29 @@ export default function ContactListPanel({
|
||||
{/* Mobile cards */}
|
||||
<div className="sm:hidden space-y-3">
|
||||
{contacts.map((c) => (
|
||||
<div key={c.id} className="bg-zinc-900 rounded-xl border border-zinc-800 p-4 space-y-2">
|
||||
<div key={c.id} className="rounded-xl border border-[var(--admin-border)] bg-white p-4 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="font-medium text-zinc-100 text-sm">
|
||||
<div className="font-semibold text-[var(--admin-text-primary)] text-sm">
|
||||
{c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "—"}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(c.id)}
|
||||
disabled={deleting === c.id}
|
||||
className="text-red-500 hover:text-red-400 text-xs disabled:opacity-50"
|
||||
className="p-1.5 rounded-lg hover:bg-red-50 text-red-500 hover:text-red-600 disabled:opacity-50"
|
||||
>
|
||||
{deleting === c.id ? "..." : "Delete"}
|
||||
{Icons.trash("h-4 w-4")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-400">{c.email || "—"}</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${SOURCE_COLORS[c.source]}`}>
|
||||
<div className="text-xs text-[var(--admin-text-muted)]">{c.email || "—"}</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${SOURCE_COLORS[c.source]}`}>
|
||||
{c.source}
|
||||
</span>
|
||||
{c.phone && <span className="text-xs text-zinc-500">{c.phone}</span>}
|
||||
{c.phone && <span className="text-xs text-[var(--admin-text-muted)]">{c.phone}</span>}
|
||||
{c.email_opt_in ? (
|
||||
<span className="text-green-600 text-xs font-medium">Opted in</span>
|
||||
<span className="text-emerald-600 text-xs font-semibold">Opted in</span>
|
||||
) : (
|
||||
<span className="text-red-500 text-xs font-medium">Opted out</span>
|
||||
<span className="text-red-500 text-xs font-semibold">Opted out</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -237,24 +294,26 @@ export default function ContactListPanel({
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<span className="text-sm text-zinc-500">
|
||||
Page {page + 1} — {contacts.length} of {total}
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-[var(--admin-border)]">
|
||||
<span className="text-xs sm:text-sm text-[var(--admin-text-muted)]">
|
||||
Page {page + 1} — {Math.min((page + 1) * limit, total)} of {total}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handlePage(-1)}
|
||||
disabled={page === 0}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-sm disabled:opacity-50 hover:bg-zinc-800"
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-[var(--admin-border)] px-3 py-1.5 text-xs sm:text-sm disabled:opacity-50 hover:bg-[var(--admin-card-hover)] transition-colors"
|
||||
>
|
||||
Previous
|
||||
{Icons.chevronLeft("h-4 w-4")}
|
||||
<span>Previous</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePage(1)}
|
||||
disabled={(page + 1) * limit >= total}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-sm disabled:opacity-50 hover:bg-zinc-800"
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-[var(--admin-border)] px-3 py-1.5 text-xs sm:text-sm disabled:opacity-50 hover:bg-[var(--admin-card-hover)] transition-colors"
|
||||
>
|
||||
Next
|
||||
<span>Next</span>
|
||||
{Icons.chevronRight("h-4 w-4")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -262,4 +321,4 @@ export default function ContactListPanel({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import UpgradePlanModal from "@/components/admin/UpgradePlanModal";
|
||||
|
||||
type Section = {
|
||||
title: string;
|
||||
href: string;
|
||||
description: string;
|
||||
group: "operations" | "fulfillment" | "management" | "tools";
|
||||
addonKey?: string;
|
||||
upgradeText?: string;
|
||||
prominent?: boolean;
|
||||
};
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
title: "Orders",
|
||||
href: "/admin/orders",
|
||||
description: "View orders, pickup status, fulfillment, and customer details.",
|
||||
group: "operations",
|
||||
prominent: true,
|
||||
},
|
||||
{
|
||||
title: "Products",
|
||||
href: "/admin/products",
|
||||
description: "Manage products, pricing, shipping type, and availability.",
|
||||
group: "operations",
|
||||
prominent: true,
|
||||
},
|
||||
{
|
||||
title: "Tours & Stops",
|
||||
href: "/admin/stops",
|
||||
description: "Manage routes, pickup locations, dates, and cutoff times.",
|
||||
group: "fulfillment",
|
||||
},
|
||||
{
|
||||
title: "Driver Pickup",
|
||||
href: "/admin/pickup",
|
||||
description: "Mobile pickup lookup, QR scanning, and completion tools.",
|
||||
group: "fulfillment",
|
||||
},
|
||||
{
|
||||
title: "Shipping",
|
||||
href: "/admin/shipping",
|
||||
description: "FedEx integration, label creation, and shipment tracking.",
|
||||
group: "fulfillment",
|
||||
},
|
||||
{
|
||||
title: "Reports",
|
||||
href: "/admin/reports",
|
||||
description: "Sales, route, product, pickup, and customer reports.",
|
||||
group: "management",
|
||||
},
|
||||
{
|
||||
title: "Tax Dashboard",
|
||||
href: "/admin/taxes",
|
||||
description: "Sales tax collected, breakdown by state, and exportable reports.",
|
||||
group: "management",
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
href: "/admin/settings",
|
||||
description: "Users, billing, brand, integrations, payments, and shipping.",
|
||||
group: "management",
|
||||
},
|
||||
{
|
||||
title: "Harvest Reach",
|
||||
href: "/admin/communications",
|
||||
description: "Email campaigns, stop blast, templates, and audience segments.",
|
||||
group: "tools",
|
||||
addonKey: "harvest_reach",
|
||||
upgradeText: "Enable to access email & SMS marketing",
|
||||
},
|
||||
{
|
||||
title: "Wholesale Portal",
|
||||
href: "/admin/wholesale",
|
||||
description: "Standalone B2B portal with custom pricing, credit limits, and net-30.",
|
||||
group: "tools",
|
||||
addonKey: "wholesale_portal",
|
||||
upgradeText: "Enable to unlock B2B buyer portal",
|
||||
},
|
||||
{
|
||||
title: "Import Center",
|
||||
href: "/admin/import",
|
||||
description: "AI-powered import for products, orders, contacts, and stops.",
|
||||
group: "tools",
|
||||
addonKey: "ai_tools",
|
||||
upgradeText: "Enable AI import with smart column mapping",
|
||||
},
|
||||
{
|
||||
title: "AI Intelligence",
|
||||
href: "/admin/settings/ai",
|
||||
description: "Campaign writer, pricing advisor, and report explainer.",
|
||||
group: "tools",
|
||||
addonKey: "ai_tools",
|
||||
upgradeText: "Enable AI tools for marketing and pricing",
|
||||
},
|
||||
{
|
||||
title: "Time Tracking",
|
||||
href: "/admin/time-tracking",
|
||||
description: "Worker clock-in/out, hours tracking, and overtime management.",
|
||||
group: "tools",
|
||||
addonKey: "time_tracking",
|
||||
upgradeText: "Enable for field worker time tracking",
|
||||
},
|
||||
{
|
||||
title: "Water Log",
|
||||
href: "/admin/water-log",
|
||||
description: "Irrigation tracking and water usage reporting.",
|
||||
group: "tools",
|
||||
addonKey: "water_log",
|
||||
upgradeText: "Enable for agricultural water tracking",
|
||||
},
|
||||
{
|
||||
title: "Route Trace",
|
||||
href: "/admin/route-trace",
|
||||
description: "Lot tracking, QR stickers, hauling board, and supply chain traceability.",
|
||||
group: "tools",
|
||||
addonKey: "route_trace",
|
||||
upgradeText: "Enable for field-to-delivery traceability",
|
||||
},
|
||||
];
|
||||
|
||||
type Tab = "operations" | "fulfillment" | "management" | "tools";
|
||||
|
||||
const TABS: { id: Tab; label: string; icon: ReactNode }[] = [
|
||||
{
|
||||
id: "operations",
|
||||
label: "Operations",
|
||||
icon: (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "fulfillment",
|
||||
label: "Fulfillment",
|
||||
icon: (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16.5 9.4 7.55 4.24"/>
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "management",
|
||||
label: "Management",
|
||||
icon: (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
label: "Tools",
|
||||
icon: (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
brandId: string | null;
|
||||
brandName: string;
|
||||
planTier: string;
|
||||
isWaterLogVisible: boolean;
|
||||
enabledAddons: Record<string, boolean>;
|
||||
usage: { users: number; stops_this_month: number; products: number };
|
||||
limits: { max_users: number; max_stops_monthly: number; max_products: number };
|
||||
};
|
||||
|
||||
export default function DashboardClient({
|
||||
brandId,
|
||||
brandName,
|
||||
planTier,
|
||||
isWaterLogVisible,
|
||||
enabledAddons,
|
||||
usage,
|
||||
limits,
|
||||
}: Props) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("operations");
|
||||
const [isUpgradeOpen, setIsUpgradeOpen] = useState(false);
|
||||
|
||||
const usagePct = {
|
||||
users: limits.max_users > 0 ? (usage.users / limits.max_users) * 100 : 0,
|
||||
stops: limits.max_stops_monthly > 0 ? (usage.stops_this_month / limits.max_stops_monthly) * 100 : 0,
|
||||
products: limits.max_products > 0 ? (usage.products / limits.max_products) * 100 : 0,
|
||||
};
|
||||
|
||||
const tabSections = sections.filter((s) => s.group === activeTab);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||
{/* Header */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-4 sm:mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-xl bg-emerald-600">
|
||||
<svg className="h-5 w-5 sm:h-6 sm:w-6 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="14" width="7" height="7" rx="1"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-black text-[var(--admin-text-primary)] tracking-tight">Admin Dashboard</h1>
|
||||
<p className="text-xs sm:text-sm text-[var(--admin-text-muted)]">{brandName} Control Center</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<a href="/admin/settings/billing" className="text-sm font-medium text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] transition-colors">
|
||||
Billing →
|
||||
</a>
|
||||
{planTier === "starter" && brandId && (
|
||||
<button
|
||||
onClick={() => setIsUpgradeOpen(true)}
|
||||
className="rounded-full bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-semibold text-white transition-all shadow-sm"
|
||||
>
|
||||
Upgrade Plan
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage stats - compact bar */}
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 mb-4">
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold tracking-wide ${
|
||||
planTier === "enterprise" ? "bg-amber-100 text-amber-700 border border-amber-200" :
|
||||
planTier === "farm" ? "bg-emerald-100 text-emerald-700 border border-emerald-200" :
|
||||
"bg-stone-100 text-stone-600 border border-stone-200"
|
||||
}`}>
|
||||
{planTier.charAt(0).toUpperCase() + planTier.slice(1)} Plan
|
||||
</span>
|
||||
<span className="text-xs text-stone-500">{brandId ? "Tuxedo Corn" : "All Brands"}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{ label: "Users", value: `${usage.users}/${limits.max_users}`, pct: usagePct.users },
|
||||
{ label: "Stops", value: `${usage.stops_this_month}/${limits.max_stops_monthly}`, pct: usagePct.stops },
|
||||
{ label: "Products", value: `${usage.products}/${limits.max_products}`, pct: usagePct.products },
|
||||
].map(({ label, value, pct }) => (
|
||||
<div key={label}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs font-medium text-stone-600">{label}</span>
|
||||
<span className="text-xs font-semibold text-stone-900">{value}</span>
|
||||
</div>
|
||||
<div className="h-1 rounded-full bg-stone-200 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
pct > 85 ? "bg-amber-500" : "bg-emerald-500"
|
||||
}`}
|
||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab navigation */}
|
||||
<nav className="grid grid-cols-4 gap-1 p-1.5 rounded-xl bg-white border border-[var(--admin-border)]">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`relative flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 rounded-lg px-2 sm:px-4 py-2.5 sm:py-3 text-[10px] sm:text-sm font-semibold transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "bg-emerald-600 text-white"
|
||||
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
<span>{tab.label}</span>
|
||||
{activeTab === tab.id && (
|
||||
<div className="absolute bottom-1 sm:bottom-1.5 left-1/2 -translate-x-1/2 h-0.5 w-6 sm:w-12 bg-white rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{tabSections.map((section) => {
|
||||
if (section.title === "Water Log" && !isWaterLogVisible) return null;
|
||||
if (section.title === "Route Trace" && !enabledAddons["route_trace"]) return null;
|
||||
|
||||
const isAddon = Boolean(section.addonKey);
|
||||
const isEnabled = section.addonKey ? (enabledAddons[section.addonKey] ?? false) : true;
|
||||
const isProminent = section.prominent;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={section.title}
|
||||
href={section.href}
|
||||
className={[
|
||||
"group relative flex flex-col rounded-2xl border bg-white p-5 transition-all hover:-translate-y-0.5 hover:shadow-md",
|
||||
isAddon && !isEnabled
|
||||
? "border-stone-200 shadow-sm opacity-75 hover:opacity-100"
|
||||
: isProminent
|
||||
? "border-emerald-200 shadow-md shadow-emerald-100/50"
|
||||
: "border-stone-200 shadow-sm",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`flex h-9 w-9 items-center justify-center rounded-xl ${
|
||||
isAddon && !isEnabled
|
||||
? "bg-stone-100 text-stone-400"
|
||||
: isProminent
|
||||
? "bg-emerald-100 text-emerald-600"
|
||||
: "bg-violet-100 text-violet-600"
|
||||
}`}>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
{isAddon && !isEnabled && (
|
||||
<span className="text-[10px] font-semibold text-amber-800 bg-amber-100 border border-amber-200 rounded-full px-2.5 py-0.5">
|
||||
Add-on
|
||||
</span>
|
||||
)}
|
||||
{isAddon && isEnabled && (
|
||||
<span className="text-[10px] font-semibold text-emerald-800 bg-emerald-100 border border-emerald-200 rounded-full px-2.5 py-0.5">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
{isProminent && (
|
||||
<span className="text-[10px] font-semibold text-emerald-800 bg-emerald-100 border border-emerald-200 rounded-full px-2.5 py-0.5">
|
||||
Core
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-semibold leading-tight text-stone-950">
|
||||
{section.title}
|
||||
</h3>
|
||||
|
||||
<p className={`mt-2 text-sm leading-relaxed ${
|
||||
isAddon && !isEnabled ? "text-stone-500" : "text-stone-600"
|
||||
}`}>
|
||||
{section.description}
|
||||
</p>
|
||||
|
||||
{isAddon && !isEnabled && section.upgradeText && (
|
||||
<p className="mt-3 text-xs text-amber-700 font-medium">{section.upgradeText}</p>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upgrade Modal */}
|
||||
{planTier === "starter" && brandId && (
|
||||
<UpgradePlanModal
|
||||
isOpen={isUpgradeOpen}
|
||||
onClose={() => setIsUpgradeOpen(false)}
|
||||
brandId={brandId}
|
||||
currentTier={planTier}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,13 +17,23 @@ export default function DashboardHeader({ brandId, brandName, planTier }: Dashbo
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-emerald-600 mb-2">Control Center</p>
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-stone-950">{brandName}</h1>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-600">
|
||||
<svg className="h-6 w-6 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16.5 9.4 7.55 4.24"/>
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--admin-text-primary)] tracking-tight">Admin Dashboard</h1>
|
||||
<p className="text-xs sm:text-sm text-[var(--admin-text-muted)]">{brandName} Control Center</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<a href="/admin/settings/billing" className="text-sm font-medium text-stone-500 hover:text-stone-700 transition-colors">
|
||||
<a href="/admin/settings/billing" className="text-sm font-medium text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] transition-colors">
|
||||
Billing →
|
||||
</a>
|
||||
{planTier === "starter" && brandId && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { markPickupComplete } from "@/actions/pickup";
|
||||
|
||||
@@ -56,7 +56,6 @@ function formatPickupTime(iso: string | null) {
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffMin < 1440) return `${Math.floor(diffMin / 60)}h ago`;
|
||||
|
||||
// Same year — show mon/day
|
||||
if (d.getFullYear() === now.getFullYear()) {
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
@@ -83,17 +82,13 @@ export default function DriverPickupPanel({
|
||||
const [showPickedUp, setShowPickedUp] = useState(true);
|
||||
const [pickupToast, setPickupToast] = useState<string | null>(null);
|
||||
|
||||
const activeStopFilter = stopFilter;
|
||||
|
||||
const filteredPending = pendingOrders.filter((o) => {
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
o.customer_name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(o.customer_phone ?? "").includes(search) ||
|
||||
o.id.includes(search.toUpperCase().slice(0, 8));
|
||||
|
||||
const matchesStop = !activeStopFilter || o.stop_id === activeStopFilter;
|
||||
|
||||
const matchesStop = !stopFilter || o.stop_id === stopFilter;
|
||||
return matchesSearch && matchesStop;
|
||||
});
|
||||
|
||||
@@ -103,9 +98,7 @@ export default function DriverPickupPanel({
|
||||
o.customer_name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(o.customer_phone ?? "").includes(search) ||
|
||||
o.id.includes(search.toUpperCase().slice(0, 8));
|
||||
|
||||
const matchesStop = !activeStopFilter || o.stop_id === activeStopFilter;
|
||||
|
||||
const matchesStop = !stopFilter || o.stop_id === stopFilter;
|
||||
return matchesSearch && matchesStop;
|
||||
});
|
||||
|
||||
@@ -139,60 +132,71 @@ export default function DriverPickupPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950">
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
{/* Header */}
|
||||
<div className="bg-slate-900 px-4 py-5">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<header className="border-b border-stone-200 bg-white">
|
||||
<div className="mx-auto max-w-5xl px-6 py-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Driver Pickup</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
<h1 className="text-2xl font-semibold text-stone-950 tracking-tight">Driver Pickup</h1>
|
||||
<p className="mt-1 text-sm text-stone-500">
|
||||
{filteredPending.length} pending
|
||||
{hasAnyPickedUp && ` · ${pickedUpOrders.length} picked up`}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/orders"
|
||||
className="rounded-lg bg-slate-700 px-4 py-2 text-sm font-medium text-white hover:bg-slate-600"
|
||||
className="rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm font-medium text-stone-600 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
All Orders
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mx-auto max-w-6xl px-4 py-4 space-y-4">
|
||||
{/* Stop Filter */}
|
||||
<select
|
||||
value={stopFilter}
|
||||
onChange={(e) => setStopFilter(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-4 text-base font-medium outline-none focus:border-slate-900"
|
||||
>
|
||||
<option value="">All Stops</option>
|
||||
{stops.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.city}, {s.state} · {s.date}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Search */}
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search name, phone, or order #..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-4 text-base outline-none focus:border-slate-900"
|
||||
/>
|
||||
<main className="mx-auto max-w-5xl px-6 py-6 space-y-5">
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3">
|
||||
<select
|
||||
value={stopFilter}
|
||||
onChange={(e) => setStopFilter(e.target.value)}
|
||||
className="rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm font-medium text-stone-700 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
>
|
||||
<option value="">All Stops</option>
|
||||
{stops.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.city}, {s.state} · {s.date}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="relative flex-1">
|
||||
<svg className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search name, phone, or order #..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white pl-11 pr-4 py-3 text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pending Orders */}
|
||||
<div className="space-y-3">
|
||||
{filteredPending.length === 0 ? (
|
||||
<div className="rounded-xl bg-zinc-900 py-12 text-center text-slate-400">
|
||||
No pending orders
|
||||
{filteredPending.length === 0 ? (
|
||||
<div className="rounded-2xl bg-white border border-stone-200 py-16 text-center">
|
||||
<div className="mx-auto mb-3 h-12 w-12 rounded-full bg-stone-100 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
filteredPending.map((order) => (
|
||||
<p className="text-lg font-medium text-stone-600">No pending orders</p>
|
||||
<p className="mt-1 text-sm text-stone-400">Orders awaiting pickup will appear here</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredPending.map((order) => (
|
||||
<OrderCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
@@ -201,45 +205,45 @@ export default function DriverPickupPanel({
|
||||
pickingUp={pickingUp}
|
||||
shortId={shortId(order.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Picked Up Toggle */}
|
||||
{hasAnyPickedUp && (
|
||||
<button
|
||||
onClick={() => setShowPickedUp((v) => !v)}
|
||||
className="w-full rounded-xl border border-green-200 bg-green-900/30 px-4 py-3 text-sm font-medium text-green-400 hover:bg-green-900/40"
|
||||
className="w-full rounded-xl border border-emerald-200 bg-emerald-50 px-5 py-3 text-sm font-medium text-emerald-700 hover:bg-emerald-100 transition-colors"
|
||||
>
|
||||
{showPickedUp ? "▲ Hide" : "▼"} {pickedUpOrders.length} picked up
|
||||
{showPickedUp ? "▼ Hide" : "▶ Show"} {pickedUpOrders.length} picked up
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showPickedUp && (
|
||||
{showPickedUp && filteredPickedUp.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{filteredPickedUp.length === 0 && activeStopFilter ? (
|
||||
<div className="rounded-xl border border-green-200 bg-green-900/30 py-6 text-center text-sm text-green-600">
|
||||
No picked-up orders for this stop. {pickedUpOrders.length} picked up at other stops.
|
||||
</div>
|
||||
) : (
|
||||
filteredPickedUp.map((order) => (
|
||||
<PickedUpCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
shortId={shortId(order.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{filteredPickedUp.map((order) => (
|
||||
<PickedUpCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
shortId={shortId(order.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success toast */}
|
||||
{showPickedUp && filteredPickedUp.length === 0 && stopFilter && (
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 py-6 text-center text-sm text-stone-500">
|
||||
No picked-up orders for this stop
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toast */}
|
||||
{pickupToast && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 rounded-xl bg-green-600 px-6 py-3 text-sm font-semibold text-white shadow-lg animate-in fade-in slide-in-from-bottom-2">
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 rounded-xl bg-emerald-600 px-6 py-3 text-sm font-semibold text-white shadow-lg shadow-emerald-200/50">
|
||||
✓ Order picked up
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -260,104 +264,113 @@ function OrderCard({ order, canManagePickup, onMarkPickup, pickingUp, shortId }:
|
||||
const pickupTotal = pickupItems.reduce((sum, i) => sum + Number(i.price) * i.quantity, 0);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-zinc-900 p-5 shadow-black/20 ring-1 ring-zinc-700">
|
||||
{/* Top row: name + order # */}
|
||||
<div className="rounded-2xl bg-white border border-stone-200 p-6 shadow-sm">
|
||||
{/* Header row */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xl font-bold text-zinc-100">{order.customer_name}</p>
|
||||
<p className="text-lg font-semibold text-stone-950">{order.customer_name}</p>
|
||||
{order.customer_phone && (
|
||||
<p className="mt-1 text-base text-zinc-400">{order.customer_phone}</p>
|
||||
<p className="mt-0.5 text-sm text-stone-500">{order.customer_phone}</p>
|
||||
)}
|
||||
<p className="mt-1 font-mono text-xs text-slate-400 uppercase">{shortId}</p>
|
||||
<p className="mt-1 font-mono text-xs text-stone-400 uppercase">{shortId}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0 flex flex-col items-end gap-1">
|
||||
<span className="rounded-full bg-yellow-100 px-3 py-1 text-xs font-bold text-yellow-800">
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className="rounded-full bg-amber-100 px-3 py-1 text-xs font-bold text-amber-700">
|
||||
Pending
|
||||
</span>
|
||||
{isMixed && (
|
||||
<span className="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-semibold text-purple-700">
|
||||
Mixed order
|
||||
<span className="rounded-full bg-violet-100 px-2 py-0.5 text-xs font-semibold text-violet-700">
|
||||
Mixed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Products list — pickup items only */}
|
||||
{/* Items */}
|
||||
{pickupItems.length > 0 && (
|
||||
<div className="mt-4 rounded-lg bg-slate-50 p-3">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-zinc-500">
|
||||
Pickup items ({pickupItems.reduce((s, i) => s + i.quantity, 0)} total)
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
<div className="mt-5 rounded-xl bg-stone-50 border border-stone-100 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-stone-500">
|
||||
Pickup Items
|
||||
</span>
|
||||
<span className="text-xs text-stone-400">
|
||||
{pickupItems.reduce((s, i) => s + i.quantity, 0)} total
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{pickupItems.map((item) => (
|
||||
<li key={item.id} className="flex items-center justify-between text-sm">
|
||||
<span className="text-zinc-300">
|
||||
<span className="text-stone-700">
|
||||
{item.quantity > 1 && (
|
||||
<span className="font-semibold text-zinc-100">{item.quantity}× </span>
|
||||
<span className="font-semibold text-stone-900">{item.quantity}× </span>
|
||||
)}
|
||||
{item.products?.name ?? "Unknown Product"}
|
||||
</span>
|
||||
<span className="text-zinc-500">
|
||||
<span className="text-stone-500 font-medium">
|
||||
${(Number(item.price) * item.quantity).toFixed(2)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-2 border-t border-zinc-800 pt-2 flex justify-between">
|
||||
<span className="text-sm font-medium text-zinc-400">Pickup subtotal</span>
|
||||
<span className="text-sm font-bold text-zinc-100">${pickupTotal.toFixed(2)}</span>
|
||||
<div className="mt-3 border-t border-stone-200 pt-3 flex justify-between">
|
||||
<span className="text-sm font-medium text-stone-600">Subtotal</span>
|
||||
<span className="text-sm font-bold text-stone-900">${pickupTotal.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shipping items note */}
|
||||
{/* Shipping warning */}
|
||||
{isMixed && (
|
||||
<div className="mt-3 rounded-lg bg-purple-50 border border-purple-100 px-3 py-2 text-sm text-purple-700">
|
||||
⚠ {shippingItems.length} shipping item{shippingItems.length > 1 ? "s" : ""} — will also be marked picked up
|
||||
<div className="mt-4 rounded-lg bg-violet-50 border border-violet-100 px-4 py-3 text-sm text-violet-700">
|
||||
{shippingItems.length} shipping item{shippingItems.length > 1 ? "s" : ""} will also be marked picked up
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Middle: stop + items count */}
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
{/* Footer */}
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
<div>
|
||||
{order.stops ? (
|
||||
<p className="font-medium text-zinc-300">
|
||||
{order.stops.city}, {order.stops.state}
|
||||
</p>
|
||||
<>
|
||||
<p className="font-medium text-stone-700">{order.stops.city}, {order.stops.state}</p>
|
||||
<p className="text-sm text-stone-400">
|
||||
{pickupItems.length} pickup {pickupItems.length === 1 ? "item" : "items"}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-slate-400">No stop</p>
|
||||
<p className="text-stone-400">No stop assigned</p>
|
||||
)}
|
||||
<p className="text-sm text-slate-400">
|
||||
{pickupItems.length} pickup {pickupItems.length === 1 ? "item" : "items"}
|
||||
{isMixed && ` · ${shippingItems.length} shipping`}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-zinc-100">
|
||||
${pickupTotal.toFixed(2)}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-stone-950">${pickupTotal.toFixed(2)}</p>
|
||||
</div>
|
||||
|
||||
{/* Pickup Button */}
|
||||
{/* Actions */}
|
||||
{canManagePickup ? (
|
||||
<div className="mt-4 flex gap-2">
|
||||
<a
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<Link
|
||||
href={`/admin/orders/${order.id}`}
|
||||
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-4 text-center text-base font-semibold text-zinc-300 active:bg-slate-50"
|
||||
style={{ minHeight: "56px", display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
className="flex items-center justify-center rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm font-medium text-stone-600 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
View Details
|
||||
</a>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => onMarkPickup(order.id)}
|
||||
disabled={pickingUp === order.id}
|
||||
className="flex-1 rounded-xl bg-green-600 px-4 py-5 text-xl font-bold text-white active:bg-green-700 disabled:opacity-50"
|
||||
style={{ minHeight: "56px" }}
|
||||
className="flex items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-3 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{pickingUp === order.id ? "..." : "✓ Pick Up"}
|
||||
{pickingUp === order.id ? (
|
||||
"..."
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Pick Up
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-xl bg-zinc-950 px-4 py-3 text-center text-sm text-zinc-500">
|
||||
<div className="mt-5 rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-center text-sm text-stone-400">
|
||||
No pickup permission
|
||||
</div>
|
||||
)}
|
||||
@@ -377,40 +390,38 @@ function PickedUpCard({ order, shortId }: PickedUpCardProps) {
|
||||
const isMixed = allItems.some((i) => i.fulfillment === "shipping");
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-green-200 bg-green-900/30 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50/50 p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-bold text-green-900">{order.customer_name}</p>
|
||||
<p className="font-semibold text-stone-900">{order.customer_name}</p>
|
||||
{order.customer_phone && (
|
||||
<p className="text-sm text-green-400">{order.customer_phone}</p>
|
||||
<p className="mt-0.5 text-sm text-stone-500">{order.customer_phone}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-green-600">
|
||||
<p className="mt-1 text-xs text-stone-400">
|
||||
{shortId} · {order.stops?.city}, {order.stops?.state}
|
||||
</p>
|
||||
{pickupItems.length > 0 && (
|
||||
<ul className="mt-2 space-y-0.5">
|
||||
<ul className="mt-3 space-y-1">
|
||||
{pickupItems.map((item) => (
|
||||
<li key={item.id} className="text-xs text-green-400">
|
||||
<li key={item.id} className="text-sm text-stone-600">
|
||||
{item.quantity > 1 && <span className="font-semibold">{item.quantity}× </span>}
|
||||
{item.products?.name ?? "Unknown"}
|
||||
<span className="ml-1 text-green-500">${(Number(item.price) * item.quantity).toFixed(2)}</span>
|
||||
<span className="ml-2 text-stone-400">${(Number(item.price) * item.quantity).toFixed(2)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="mt-2 text-xs font-semibold text-green-400">
|
||||
{pickupItems.reduce((s, i) => s + i.quantity, 0)} pickup items · ${pickupTotal.toFixed(2)}
|
||||
{isMixed && <span className="text-purple-600 ml-1">· mixed</span>}
|
||||
<p className="mt-2 text-xs text-stone-500">
|
||||
{pickupItems.reduce((s, i) => s + i.quantity, 0)} items · ${pickupTotal.toFixed(2)}
|
||||
{isMixed && <span className="ml-2 text-violet-600">· mixed</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<span className="rounded-full bg-green-200 px-3 py-1 text-xs font-bold text-green-800">
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className="rounded-full bg-emerald-100 px-3 py-1 text-xs font-bold text-emerald-700">
|
||||
Picked Up
|
||||
</span>
|
||||
{order.pickup_completed_at && (
|
||||
<p className="mt-1 text-xs text-green-600">
|
||||
{formatPickupTime(order.pickup_completed_at)}
|
||||
</p>
|
||||
<p className="text-xs text-stone-400">{formatPickupTime(order.pickup_completed_at)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
titleIcon?: React.ReactNode;
|
||||
subtitle?: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
maxWidth?: string;
|
||||
};
|
||||
|
||||
export default function GlassModal({ title, titleIcon, subtitle, onClose, children, maxWidth = "max-w-md" }: Props) {
|
||||
// Lock body scroll
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, []);
|
||||
|
||||
// Escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [onClose]);
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
onClick={handleBackdropClick}
|
||||
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
|
||||
>
|
||||
{/* Modal card - solid white with shadow for high contrast */}
|
||||
<div
|
||||
className={`relative w-full ${maxWidth} rounded-2xl shadow-2xl`}
|
||||
style={{
|
||||
backgroundColor: "#ffffff",
|
||||
border: "1px solid var(--admin-border)",
|
||||
boxShadow: "0 25px 50px -12px rgba(60, 56, 37, 0.35), 0 12px 24px -8px rgba(60, 56, 37, 0.2)",
|
||||
}}
|
||||
>
|
||||
{/* Subtle top border accent */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-1 rounded-t-2xl overflow-hidden"
|
||||
style={{
|
||||
background: "linear-gradient(90deg, var(--admin-accent) 0%, var(--admin-accent-hover) 100%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-6 py-5"
|
||||
style={{ borderBottom: "1px solid var(--admin-border-light)" }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{titleIcon && (
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl"
|
||||
style={{ backgroundColor: "var(--admin-accent-light)" }}
|
||||
>
|
||||
{titleIcon}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h2
|
||||
className="text-xl font-semibold text-[var(--admin-text-primary)]"
|
||||
style={{ letterSpacing: "-0.02em" }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="mt-0.5 text-sm text-[var(--admin-text-muted)]">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full transition-all"
|
||||
style={{
|
||||
backgroundColor: "rgba(60, 56, 37, 0.04)",
|
||||
color: "rgba(60, 56, 37, 0.5)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "rgba(60, 56, 37, 0.08)";
|
||||
e.currentTarget.style.color = "rgba(60, 56, 37, 0.7)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "rgba(60, 56, 37, 0.04)";
|
||||
e.currentTarget.style.color = "rgba(60, 56, 37, 0.5)";
|
||||
}}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative p-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,11 +9,11 @@ type Props = {
|
||||
|
||||
type Period = "30" | "90" | "all";
|
||||
|
||||
function RateBar({ value }: { value: number }) {
|
||||
function RateBar({ value, color = "bg-emerald-500" }: { value: number; color?: string }) {
|
||||
return (
|
||||
<div className="w-full bg-zinc-950 rounded-full h-1.5">
|
||||
<div className="w-full bg-stone-100 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-stone-900 h-1.5 rounded-full"
|
||||
className={`h-1.5 rounded-full ${color}`}
|
||||
style={{ width: `${Math.min(100, value)}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -22,10 +22,10 @@ function RateBar({ value }: { value: number }) {
|
||||
|
||||
function StatCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-5 flex flex-col gap-1.5">
|
||||
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide">{label}</p>
|
||||
<p className="text-2xl font-bold text-zinc-100">{value}</p>
|
||||
{sub && <p className="text-xs text-slate-400">{sub}</p>}
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4 sm:p-5">
|
||||
<p className="text-[10px] sm:text-xs font-medium text-[var(--admin-text-muted)] uppercase tracking-wide">{label}</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1.5">{value}</p>
|
||||
{sub && <p className="text-[10px] sm:text-xs text-stone-400 mt-1">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -42,30 +42,45 @@ export default function AnalyticsDashboard({ analytics }: Props) {
|
||||
const avgClickRate = totalDelivered > 0 ? (totalClicked / totalDelivered) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
|
||||
<svg className="h-5 w-5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Campaign Analytics</h2>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Track your campaign performance</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary stat cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||
<StatCard label="Total Sent" value={totalSent.toLocaleString()} sub={`${analytics.length} campaign${analytics.length !== 1 ? "s" : ""}`} />
|
||||
<StatCard
|
||||
label="Delivered"
|
||||
value={totalSent > 0 ? `${Math.round((totalDelivered / totalSent) * 100)}%` : "—"}
|
||||
sub={totalDelivered > 0 ? `${totalDelivered.toLocaleString()} emails` : undefined}
|
||||
sub={totalDelivered > 0 ? `${totalDelivered.toLocaleString()} messages` : undefined}
|
||||
/>
|
||||
<StatCard label="Avg. Open Rate" value={totalSent > 0 ? `${avgOpenRate.toFixed(1)}%` : "—"} sub="of delivered" />
|
||||
<StatCard label="Avg. Click Rate" value={totalSent > 0 ? `${avgClickRate.toFixed(1)}%` : "—"} sub="of delivered" />
|
||||
</div>
|
||||
|
||||
{/* Campaign performance table */}
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Campaign Performance</h3>
|
||||
<div className="flex rounded-lg border border-zinc-800 bg-slate-50 p-0.5">
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] overflow-hidden">
|
||||
<div className="px-4 sm:px-6 py-4 border-b border-[var(--admin-border)] flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Campaign Performance</h3>
|
||||
<div className="flex rounded-lg border border-[var(--admin-border)] bg-stone-50 p-0.5">
|
||||
{(["30", "90", "all"] as const).map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setPeriod(val as Period)}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||
period === val ? "bg-stone-900 text-white" : "text-zinc-400 hover:bg-zinc-900"
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
period === val ? "bg-emerald-600 text-white" : "text-stone-500 hover:bg-white"
|
||||
}`}
|
||||
>
|
||||
{val === "30" ? "30 days" : val === "90" ? "90 days" : "All time"}
|
||||
@@ -76,66 +91,79 @@ export default function AnalyticsDashboard({ analytics }: Props) {
|
||||
|
||||
{analytics.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center">
|
||||
<p className="text-sm text-slate-400">No campaign analytics yet.</p>
|
||||
<p className="text-xs text-slate-400 mt-1">Send a campaign to start tracking engagement.</p>
|
||||
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
|
||||
<svg className="h-8 w-8 text-stone-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-stone-600">No campaign analytics yet</p>
|
||||
<p className="text-xs text-stone-400 mt-1">Send a campaign to start tracking engagement</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-zinc-800">
|
||||
<tr>
|
||||
{["Campaign", "Sent", "Delivered", "Opened", "Clicked", "Bounced", "Engagement"].map((h) => (
|
||||
<th key={h} className={`text-left px-6 py-3 font-medium text-zinc-400 ${h !== "Campaign" ? "text-right" : ""}`}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{analytics.map((a) => (
|
||||
<tr key={a.campaign_id} className="hover:bg-zinc-800 transition-colors">
|
||||
<td className="px-6 py-3.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-slate-800">{a.campaign_name}</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
{a.sent_at ? new Date(a.sent_at).toLocaleDateString() : "—"}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-3.5 text-right text-zinc-300">{a.total_sent.toLocaleString()}</td>
|
||||
<td className="px-6 py-3.5 text-right">
|
||||
<span className="text-zinc-300">{a.total_delivered.toLocaleString()}</span>
|
||||
<span className="text-xs text-slate-400 ml-1">({a.delivered_rate}%)</span>
|
||||
</td>
|
||||
<td className="px-6 py-3.5 text-right">
|
||||
<span className="text-zinc-300">{a.total_opened.toLocaleString()}</span>
|
||||
<span className="text-xs text-slate-400 ml-1">({a.open_rate}%)</span>
|
||||
</td>
|
||||
<td className="px-6 py-3.5 text-right">
|
||||
<span className="text-zinc-300">{a.total_clicked.toLocaleString()}</span>
|
||||
<span className="text-xs text-slate-400 ml-1">({a.click_rate}%)</span>
|
||||
</td>
|
||||
<td className="px-6 py-3.5 text-right">
|
||||
<span className={a.total_bounced > 0 ? "text-red-400" : "text-slate-400"}>
|
||||
{a.total_bounced.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400 ml-1">({a.bounce_rate}%)</span>
|
||||
</td>
|
||||
<td className="px-6 py-3.5 w-36">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-zinc-500">Open</span>
|
||||
<span className="font-medium text-zinc-300">{a.open_rate}%</span>
|
||||
</div>
|
||||
<RateBar value={Number(a.open_rate)} />
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-zinc-500">Click</span>
|
||||
<span className="font-medium text-zinc-300">{a.click_rate}%</span>
|
||||
</div>
|
||||
<RateBar value={Number(a.click_rate)} />
|
||||
</div>
|
||||
</td>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-stone-50 border-b border-[var(--admin-border)]">
|
||||
<tr>
|
||||
<th className="text-left px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Campaign</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Sent</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Delivered</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Opened</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Clicked</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Bounced</th>
|
||||
<th className="text-left px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Engagement</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border)]">
|
||||
{analytics.map((a) => (
|
||||
<tr key={a.campaign_id} className="hover:bg-stone-50 transition-colors">
|
||||
<td className="px-4 sm:px-6 py-3.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-[var(--admin-text-primary)]">{a.campaign_name}</span>
|
||||
<span className="text-xs text-stone-400">
|
||||
{a.sent_at ? new Date(a.sent_at).toLocaleDateString() : "—"}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-3.5 text-right text-[var(--admin-text-primary)]">{a.total_sent.toLocaleString()}</td>
|
||||
<td className="px-4 sm:px-6 py-3.5 text-right">
|
||||
<span className="text-[var(--admin-text-primary)]">{a.total_delivered.toLocaleString()}</span>
|
||||
<span className="text-xs text-stone-400 ml-1">({a.delivered_rate}%)</span>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-3.5 text-right">
|
||||
<span className="text-[var(--admin-text-primary)]">{a.total_opened.toLocaleString()}</span>
|
||||
<span className="text-xs text-stone-400 ml-1">({a.open_rate}%)</span>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-3.5 text-right">
|
||||
<span className="text-[var(--admin-text-primary)]">{a.total_clicked.toLocaleString()}</span>
|
||||
<span className="text-xs text-stone-400 ml-1">({a.click_rate}%)</span>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-3.5 text-right">
|
||||
<span className={a.total_bounced > 0 ? "text-red-600" : "text-stone-400"}>
|
||||
{a.total_bounced.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-xs text-stone-400 ml-1">({a.bounce_rate}%)</span>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-3.5 w-36">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-stone-500">Open</span>
|
||||
<span className="font-medium text-[var(--admin-text-primary)]">{a.open_rate}%</span>
|
||||
</div>
|
||||
<RateBar value={Number(a.open_rate)} />
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-stone-500">Click</span>
|
||||
<span className="font-medium text-[var(--admin-text-primary)]">{a.click_rate}%</span>
|
||||
</div>
|
||||
<RateBar value={Number(a.click_rate)} color="bg-amber-500" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,9 +3,40 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { type SegmentRuleV2, type PreviewResult } from "@/actions/harvest-reach/segments";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
rules: SegmentRuleV2;
|
||||
// Icon components
|
||||
const Icons = {
|
||||
users: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
),
|
||||
search: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.3-4.3"/>
|
||||
</svg>
|
||||
),
|
||||
spinner: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" strokeOpacity="0.25"/>
|
||||
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round">
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite"/>
|
||||
</path>
|
||||
</svg>
|
||||
),
|
||||
chevronLeft: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m15 18-6-6 6-6"/>
|
||||
</svg>
|
||||
),
|
||||
chevronRight: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m9 18 6-6-6-6"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
const DEBOUNCE_MS = 300;
|
||||
@@ -47,11 +78,11 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
|
||||
const paged = searched.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-5 flex flex-col gap-4">
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-5 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Matching Customers</h3>
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Matching Customers</h3>
|
||||
{preview && (
|
||||
<span className="inline-flex items-center rounded-full bg-stone-100 px-2.5 py-0.5 text-xs font-medium text-stone-700">
|
||||
<span className="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-semibold text-emerald-700">
|
||||
{total.toLocaleString()} total
|
||||
</span>
|
||||
)}
|
||||
@@ -59,52 +90,57 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
|
||||
|
||||
{rules.filters.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center py-12">
|
||||
<p className="text-sm text-slate-400 text-center">
|
||||
Add filters to see matching customers
|
||||
</p>
|
||||
<div className="text-center">
|
||||
{Icons.users("w-10 h-10 text-[var(--admin-text-muted)] mx-auto mb-3")}
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||
Add filters to see matching customers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="flex-1 flex items-center justify-center py-12">
|
||||
<div className="flex items-center gap-2.5 text-slate-400">
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<div className="flex items-center gap-2.5 text-[var(--admin-text-muted)]">
|
||||
{Icons.spinner("h-5 w-5 animate-spin")}
|
||||
<span className="text-sm">Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search by name or email…"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
/>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
|
||||
{Icons.search("h-4 w-4 text-[var(--admin-text-muted)]")}
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search by name or email…"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
className="w-full pl-10 pr-3 py-2 text-sm border border-[var(--admin-border)] rounded-lg bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{paged.length === 0 ? (
|
||||
<p className="text-sm text-slate-400 text-center py-6">No customers match these filters.</p>
|
||||
<p className="text-sm text-[var(--admin-text-muted)] text-center py-6">No customers match these filters.</p>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto max-h-[460px] flex flex-col gap-1">
|
||||
{paged.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-zinc-800 transition-colors"
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-[var(--admin-card-hover)] transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-stone-200 flex items-center justify-center text-xs font-medium text-stone-600 flex-shrink-0">
|
||||
<div className="w-8 h-8 rounded-full bg-emerald-100 flex items-center justify-center text-xs font-semibold text-emerald-700 flex-shrink-0">
|
||||
{c.name ? c.name.slice(0, 2).toUpperCase() : "??"}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-800 truncate">{c.name || "(no name)"}</p>
|
||||
<p className="text-xs text-slate-400 truncate">{c.email}</p>
|
||||
<p className="text-sm font-medium text-[var(--admin-text-primary)] truncate">{c.name || "(no name)"}</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] truncate">{c.email}</p>
|
||||
</div>
|
||||
{c.tags.length > 0 && (
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
{c.tags.slice(0, 2).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center rounded-full bg-blue-900/30 text-blue-400 px-2 py-0.5 text-xs"
|
||||
className="inline-flex items-center rounded-full bg-blue-100 text-blue-700 px-2 py-0.5 text-xs"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
@@ -117,24 +153,26 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
|
||||
)}
|
||||
|
||||
{searched.length > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between pt-3 border-t border-slate-100">
|
||||
<span className="text-xs text-slate-400">
|
||||
<div className="flex items-center justify-between pt-3 border-t border-[var(--admin-border)]">
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
Showing {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, searched.length)} of {searched.length}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-3 py-1 text-xs rounded-lg border border-zinc-800 text-zinc-400 hover:bg-zinc-800 disabled:opacity-40"
|
||||
className="inline-flex items-center gap-1 px-3 py-1 text-xs rounded-lg border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Prev
|
||||
{Icons.chevronLeft("h-4 w-4")}
|
||||
<span>Prev</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={(page + 1) * PAGE_SIZE >= searched.length}
|
||||
className="px-3 py-1 text-xs rounded-lg border border-zinc-800 text-zinc-400 hover:bg-zinc-800 disabled:opacity-40"
|
||||
className="inline-flex items-center gap-1 px-3 py-1 text-xs rounded-lg border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Next
|
||||
<span>Next</span>
|
||||
{Icons.chevronRight("h-4 w-4")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,4 +181,9 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
rules: SegmentRuleV2;
|
||||
};
|
||||
@@ -12,6 +12,17 @@ type Props = {
|
||||
initialSegments: Segment[];
|
||||
};
|
||||
|
||||
// Icon components
|
||||
const Icons = {
|
||||
layers: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
|
||||
<polyline points="2 17 12 22 22 17"/>
|
||||
<polyline points="2 12 12 17 22 12"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export default function SegmentBuilderPage({ brandId, initialSegments }: Props) {
|
||||
const [segments, setSegments] = useState<Segment[]>(initialSegments);
|
||||
const [activeSegment, setActiveSegment] = useState<Segment | null>(null);
|
||||
@@ -68,21 +79,26 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Page header */}
|
||||
<div className="p-4 sm:p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-zinc-100">Segment Builder</h2>
|
||||
<p className="text-sm text-zinc-500 mt-0.5">
|
||||
Build filters to define your audience, then save and reuse the segment.
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-text-primary)]">
|
||||
{Icons.layers("w-4 h-4 text-[var(--admin-bg)]")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Segment Builder</h2>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
|
||||
Build filters to define your audience, then save and reuse the segment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main layout */}
|
||||
<div className="flex gap-5">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* Left sidebar */}
|
||||
<div className="w-72 flex-shrink-0">
|
||||
<div className="lg:w-72 flex-shrink-0">
|
||||
<SegmentListSidebar
|
||||
segments={segments}
|
||||
activeSegmentId={activeSegment?.id}
|
||||
@@ -93,7 +109,7 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
|
||||
</div>
|
||||
|
||||
{/* Main content: builder + preview */}
|
||||
<div className="flex-1 grid grid-cols-2 gap-5 min-h-[580px]">
|
||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<SegmentBuilderPanel
|
||||
brandId={brandId}
|
||||
rules={currentRules}
|
||||
|
||||
@@ -8,6 +8,15 @@ import {
|
||||
type SegmentFilterParams,
|
||||
} from "@/actions/harvest-reach/segments";
|
||||
|
||||
// Icon components
|
||||
const Icons = {
|
||||
x: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
rules: SegmentRuleV2;
|
||||
@@ -63,28 +72,28 @@ export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-5 flex flex-col gap-5">
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-5 flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Filter Rules</h3>
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Filter Rules</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-zinc-500">Match</span>
|
||||
<div className="flex rounded-lg border border-zinc-800 bg-slate-50 p-0.5">
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">Match</span>
|
||||
<div className="flex rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card)] p-0.5">
|
||||
{(["AND", "OR"] as const).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCombinator(c)}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||
className={`px-3 py-1 text-xs font-semibold rounded-md transition-colors ${
|
||||
rules.combinator === c
|
||||
? "bg-stone-900 text-white"
|
||||
: "text-zinc-400 hover:bg-zinc-900"
|
||||
? "bg-emerald-600 text-white"
|
||||
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)]"
|
||||
}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-zinc-500">of the following</span>
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">of the following</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -92,7 +101,7 @@ export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave,
|
||||
<div className="flex flex-col gap-3">
|
||||
{rules.filters.length === 0 && (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-slate-400">
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||
No filters yet. Add a filter below to start building your segment.
|
||||
</p>
|
||||
</div>
|
||||
@@ -114,7 +123,7 @@ export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave,
|
||||
<button
|
||||
key={ft.value}
|
||||
onClick={() => addFilter(ft.value)}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-full border border-zinc-600 text-zinc-400 hover:bg-zinc-800 hover:border-slate-400 transition-colors"
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-full border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-emerald-50 hover:border-emerald-200 hover:text-emerald-700 transition-colors"
|
||||
>
|
||||
+ {ft.label}
|
||||
</button>
|
||||
@@ -122,11 +131,11 @@ export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave,
|
||||
</div>
|
||||
|
||||
{/* Save button */}
|
||||
<div className="pt-1 border-t border-slate-100">
|
||||
<div className="pt-1 border-t border-[var(--admin-border)]">
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={rules.filters.length === 0}
|
||||
className="w-full py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
className="w-full py-2.5 rounded-lg bg-emerald-600 text-white text-sm font-semibold hover:bg-emerald-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{hasActiveSegment ? "Update Segment" : "Save Segment"}
|
||||
</button>
|
||||
@@ -171,14 +180,14 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-zinc-800 p-4 flex flex-col gap-3">
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card)] p-4 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<select
|
||||
value={filter.type}
|
||||
onChange={(e) =>
|
||||
onChange({ type: e.target.value as SegmentFilterType, params: emptyFilter(e.target.value as SegmentFilterType).params })
|
||||
}
|
||||
className="text-sm font-medium border border-zinc-800 rounded-lg px-2.5 py-1.5 bg-zinc-900 outline-none focus:border-slate-900"
|
||||
className="text-sm font-medium border border-[var(--admin-border)] rounded-lg px-2.5 py-1.5 bg-white text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
>
|
||||
{FILTER_TYPES.map((ft) => (
|
||||
<option key={ft.value} value={ft.value}>{ft.label}</option>
|
||||
@@ -186,12 +195,10 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
|
||||
</select>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="text-slate-400 hover:text-red-500 transition-colors"
|
||||
className="p-1.5 rounded-lg hover:bg-red-50 text-[var(--admin-text-muted)] hover:text-red-500 transition-colors"
|
||||
aria-label="Remove filter"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
{Icons.x("w-4 h-4")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -203,7 +210,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
|
||||
value={filter.params.stop_id ?? ""}
|
||||
onChange={(e) => onChange({ params: { ...filter.params, stop_id: e.target.value } })}
|
||||
onMouseEnter={() => loadStops(filter.type === "stop")}
|
||||
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm bg-zinc-900 w-full outline-none focus:border-slate-900"
|
||||
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white w-full outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
>
|
||||
<option value="">Select {filter.type === "stop" ? "past" : "upcoming"} stop…</option>
|
||||
{stops.map((s) => (
|
||||
@@ -215,13 +222,13 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
|
||||
type="date"
|
||||
value={filter.params.date_from ?? ""}
|
||||
onChange={(e) => onChange({ params: { ...filter.params, date_from: e.target.value } })}
|
||||
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={filter.params.date_to ?? ""}
|
||||
onChange={(e) => onChange({ params: { ...filter.params, date_to: e.target.value } })}
|
||||
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -237,7 +244,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
|
||||
if (e.target.value) loadProducts();
|
||||
}}
|
||||
onMouseEnter={loadProducts}
|
||||
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm bg-zinc-900 w-full outline-none focus:border-slate-900"
|
||||
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white w-full outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
>
|
||||
<option value="">Select a product…</option>
|
||||
{products.map((p) => (
|
||||
@@ -245,16 +252,16 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
|
||||
))}
|
||||
</select>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-zinc-500">In the last</label>
|
||||
<label className="text-xs text-[var(--admin-text-muted)]">In the last</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={filter.params.days_back ?? 90}
|
||||
onChange={(e) => onChange({ params: { ...filter.params, days_back: parseInt(e.target.value) } })}
|
||||
className="border border-zinc-800 rounded-lg px-2.5 py-1.5 text-sm w-20 outline-none focus:border-slate-900"
|
||||
className="border border-[var(--admin-border)] rounded-lg px-2.5 py-1.5 text-sm w-20 bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
/>
|
||||
<span className="text-xs text-zinc-500">days</span>
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">days</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -274,14 +281,14 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
|
||||
},
|
||||
})
|
||||
}
|
||||
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="City (optional)"
|
||||
value={filter.params.city ?? ""}
|
||||
onChange={(e) => onChange({ params: { ...filter.params, city: e.target.value } })}
|
||||
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -294,23 +301,23 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
|
||||
onChange={(e) =>
|
||||
onChange({ params: { ...filter.params, order_history: e.target.value as "all" | "first_order" | "repeat" } })
|
||||
}
|
||||
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm bg-zinc-900 outline-none focus:border-slate-900"
|
||||
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
>
|
||||
{ORDER_HISTORY_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-zinc-500">In the last</label>
|
||||
<label className="text-xs text-[var(--admin-text-muted)]">In the last</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={filter.params.days_back ?? 90}
|
||||
onChange={(e) => onChange({ params: { ...filter.params, days_back: parseInt(e.target.value) } })}
|
||||
className="border border-zinc-800 rounded-lg px-2.5 py-1.5 text-sm w-20 outline-none focus:border-slate-900"
|
||||
className="border border-[var(--admin-border)] rounded-lg px-2.5 py-1.5 text-sm w-20 bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
/>
|
||||
<span className="text-xs text-zinc-500">days</span>
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">days</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -329,13 +336,13 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
|
||||
},
|
||||
})
|
||||
}
|
||||
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* All customers */}
|
||||
{filter.type === "all_customers" && (
|
||||
<p className="text-xs text-slate-400">Matches all customers in your brand.</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Matches all customers in your brand.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,22 @@
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
// Icon components
|
||||
const Icons = {
|
||||
x: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
),
|
||||
layers: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
|
||||
<polyline points="2 17 12 22 22 17"/>
|
||||
<polyline points="2 12 12 17 22 12"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialName?: string;
|
||||
initialDescription?: string;
|
||||
@@ -22,55 +38,78 @@ export default function SegmentEditModal({ initialName = "", initialDescription
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 w-full max-w-md p-6 flex flex-col gap-4 shadow-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-zinc-100">
|
||||
{initialName ? "Update Segment" : "Save Segment"}
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-zinc-400 transition-colors">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl border border-[var(--admin-border)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--admin-border)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
|
||||
{Icons.layers("h-5 w-5 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">
|
||||
{initialName ? "Update Segment" : "Save Segment"}
|
||||
</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
{initialName ? "Update segment details" : "Create a new audience segment"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-[var(--admin-card-hover)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
|
||||
>
|
||||
{Icons.x("h-5 w-5")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Content */}
|
||||
<div className="p-6 flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Segment Name</label>
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Segment Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
||||
placeholder="e.g. Fort Pierce Regulars"
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Description <span className="text-slate-400 font-normal">(optional)</span></label>
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
|
||||
Description <span className="text-[var(--admin-text-muted)] font-normal">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="e.g. Customers who pick up at the Fort Pierce stop regularly"
|
||||
rows={3}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm resize-none"
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] resize-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
{/* Footer */}
|
||||
<div className="flex gap-3 px-6 py-4 border-t border-[var(--admin-border)]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2.5 rounded-lg border border-zinc-600 text-sm font-medium text-zinc-400 hover:bg-zinc-800 transition-colors"
|
||||
className="flex-1 py-2.5 rounded-lg border border-[var(--admin-border)] text-sm font-medium text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!name.trim() || saving}
|
||||
className="flex-1 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="flex-1 py-2.5 rounded-lg bg-emerald-600 text-white text-sm font-semibold hover:bg-emerald-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? "Saving…" : "Save Segment"}
|
||||
</button>
|
||||
|
||||
@@ -3,6 +3,29 @@
|
||||
import { useState } from "react";
|
||||
import type { Segment } from "@/actions/harvest-reach/segments";
|
||||
|
||||
// Icon components
|
||||
const Icons = {
|
||||
plus: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
),
|
||||
trash: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
<line x1="10" y1="11" x2="10" y2="17"/>
|
||||
<line x1="14" y1="11" x2="14" y2="17"/>
|
||||
</svg>
|
||||
),
|
||||
search: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.3-4.3"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
type Props = {
|
||||
segments: Segment[];
|
||||
activeSegmentId?: string;
|
||||
@@ -20,47 +43,52 @@ export default function SegmentListSidebar({ segments, activeSegmentId, onSelect
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-4 flex flex-col gap-4">
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Saved Segments</h3>
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Saved Segments</h3>
|
||||
<button
|
||||
onClick={onNew}
|
||||
className="w-7 h-7 rounded-lg bg-stone-900 text-white flex items-center justify-center text-base leading-none hover:bg-stone-800 transition-colors"
|
||||
className="w-7 h-7 rounded-lg bg-emerald-600 text-white flex items-center justify-center hover:bg-emerald-700 transition-colors"
|
||||
aria-label="New segment"
|
||||
>
|
||||
+
|
||||
{Icons.plus("w-4 h-4")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search segments…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
/>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
|
||||
{Icons.search("h-4 w-4 text-[var(--admin-text-muted)]")}
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search segments…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-3 py-2 text-sm border border-[var(--admin-border)] rounded-lg bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-xs text-slate-400 text-center py-4">
|
||||
<p className="text-xs text-[var(--admin-text-muted)] text-center py-4">
|
||||
{search ? "No segments match." : "No saved segments yet."}
|
||||
</p>
|
||||
)}
|
||||
{filtered.map((segment) => (
|
||||
<div key={segment.id} className="group relative">
|
||||
{confirmDelete === segment.id ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-900/30 p-3 flex flex-col gap-2">
|
||||
<p className="text-xs text-red-400 font-medium">Delete “{segment.name}”?</p>
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-3 flex flex-col gap-2">
|
||||
<p className="text-xs text-red-600 font-medium">Delete "{segment.name}"?</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { onDelete(segment.id); setConfirmDelete(null); }}
|
||||
className="flex-1 py-1.5 text-xs rounded-lg bg-red-600 text-white hover:bg-red-700 font-medium"
|
||||
className="flex-1 py-1.5 text-xs rounded-lg bg-red-600 text-white hover:bg-red-700 font-semibold transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
className="flex-1 py-1.5 text-xs rounded-lg border border-zinc-600 bg-zinc-900 hover:bg-zinc-800 font-medium"
|
||||
className="flex-1 py-1.5 text-xs rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] font-medium transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -71,24 +99,22 @@ export default function SegmentListSidebar({ segments, activeSegmentId, onSelect
|
||||
onClick={() => onSelect(segment)}
|
||||
className={`px-3 py-2.5 rounded-xl cursor-pointer flex items-center justify-between gap-2 transition-all ${
|
||||
activeSegmentId === segment.id
|
||||
? "bg-stone-100 border border-stone-300"
|
||||
: "hover:bg-zinc-800 border border-transparent"
|
||||
? "bg-emerald-50 border border-emerald-200"
|
||||
: "hover:bg-[var(--admin-card-hover)] border border-transparent"
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-800 truncate">{segment.name}</p>
|
||||
<p className="text-sm font-medium text-[var(--admin-text-primary)] truncate">{segment.name}</p>
|
||||
{segment.description && (
|
||||
<p className="text-xs text-slate-400 truncate mt-0.5">{segment.description}</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] truncate mt-0.5">{segment.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setConfirmDelete(segment.id); }}
|
||||
className="opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 transition-all p-1"
|
||||
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-lg hover:bg-red-50 text-red-500 hover:text-red-600 transition-all"
|
||||
aria-label="Delete segment"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
{Icons.trash("w-4 h-4")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,86 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { MessageLogEntry } from "@/actions/communications/send";
|
||||
import { useState, useEffect } from "react";
|
||||
import { getMessageLogs, type MessageLogEntry } from "@/actions/communications/send";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
// Icon components
|
||||
const Icons = {
|
||||
list: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="8" y1="6" x2="21" y2="6"/>
|
||||
<line x1="8" y1="12" x2="21" y2="12"/>
|
||||
<line x1="8" y1="18" x2="21" y2="18"/>
|
||||
<line x1="3" y1="6" x2="3.01" y2="6"/>
|
||||
<line x1="3" y1="12" x2="3.01" y2="12"/>
|
||||
<line x1="3" y1="18" x2="3.01" y2="18"/>
|
||||
</svg>
|
||||
),
|
||||
search: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.3-4.3"/>
|
||||
</svg>
|
||||
),
|
||||
refresh: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||
<path d="M3 3v5h5"/>
|
||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
|
||||
<path d="M16 16h5v5"/>
|
||||
</svg>
|
||||
),
|
||||
check: (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>
|
||||
),
|
||||
x: (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="15" y1="9" x2="9" y2="15"/>
|
||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||
</svg>
|
||||
),
|
||||
eye: (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>
|
||||
),
|
||||
mousePointer: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m3 3 7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/>
|
||||
<path d="m13 13 6 6"/>
|
||||
</svg>
|
||||
),
|
||||
messageSquare: (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 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
queued: "bg-zinc-950 text-zinc-300",
|
||||
sent: "bg-blue-900/40 text-blue-700",
|
||||
queued: "bg-stone-100 text-stone-600",
|
||||
sent: "bg-blue-100 text-blue-700",
|
||||
delivered: "bg-emerald-100 text-emerald-700",
|
||||
opened: "bg-violet-100 text-violet-700",
|
||||
clicked: "bg-amber-100 text-amber-700",
|
||||
bounced: "bg-red-900/40 text-red-400",
|
||||
failed: "bg-red-900/40 text-red-400",
|
||||
bounced: "bg-red-100 text-red-700",
|
||||
failed: "bg-red-100 text-red-700",
|
||||
unsubscribed: "bg-orange-100 text-orange-700",
|
||||
};
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = {
|
||||
email: "bg-blue-900/40 text-blue-700",
|
||||
sms: "bg-green-900/40 text-green-400",
|
||||
email: "bg-blue-100 text-blue-700",
|
||||
sms: "bg-emerald-100 text-emerald-700",
|
||||
push: "bg-purple-100 text-purple-700",
|
||||
internal: "bg-zinc-950 text-zinc-400",
|
||||
internal: "bg-stone-100 text-stone-600",
|
||||
};
|
||||
|
||||
function EngagementBadges({ log }: { log: MessageLogEntry }) {
|
||||
const badges = [];
|
||||
if (log.delivered_at) {
|
||||
badges.push(
|
||||
<span key="delivered" className="rounded-full bg-emerald-100 text-emerald-700 px-2 py-0.5 text-xs font-medium">
|
||||
✓ Delivered
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (log.opened_at) {
|
||||
badges.push(
|
||||
<span key="opened" className="rounded-full bg-violet-100 text-violet-700 px-2 py-0.5 text-xs font-medium">
|
||||
Opened {formatDate(new Date(log.opened_at))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (log.clicked_at) {
|
||||
badges.push(
|
||||
<span key="clicked" className="rounded-full bg-amber-100 text-amber-700 px-2 py-0.5 text-xs font-medium">
|
||||
Clicked {formatDate(new Date(log.clicked_at))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (log.bounced_at) {
|
||||
badges.push(
|
||||
<span key="bounced" className="rounded-full bg-red-900/40 text-red-400 px-2 py-0.5 text-xs font-medium">
|
||||
✗ Bounced
|
||||
{log.bounce_reason && <span className="ml-1">{log.bounce_reason}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return badges.length > 0 ? <div className="flex flex-wrap gap-1 mt-1">{badges}</div> : null;
|
||||
}
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function MessageLogPanel({ initialLogs }: { initialLogs: MessageLogEntry[] }) {
|
||||
const [logs] = useState(initialLogs);
|
||||
const [filterStatus, setFilterStatus] = useState("all");
|
||||
const [filterMethod, setFilterMethod] = useState("all");
|
||||
export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
const [logs, setLogs] = useState<MessageLogEntry[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const filtered = logs.filter((l) => {
|
||||
if (filterStatus !== "all" && l.status !== filterStatus) return false;
|
||||
if (filterMethod !== "all" && l.delivery_method !== filterMethod) return false;
|
||||
return true;
|
||||
});
|
||||
const fetchLogs = async () => {
|
||||
if (!brandId) return;
|
||||
setIsLoading(true);
|
||||
const result = await getMessageLogs({
|
||||
brandId,
|
||||
status: statusFilter === "all" ? undefined : statusFilter,
|
||||
limit: 100,
|
||||
});
|
||||
if (result.success) {
|
||||
setLogs(result.logs);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [brandId, statusFilter]);
|
||||
|
||||
// Filter logs based on search
|
||||
const filteredLogs = search
|
||||
? logs.filter((l: MessageLogEntry) =>
|
||||
l.customer_email?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
l.subject?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
l.status?.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: logs;
|
||||
|
||||
// Calculate stats
|
||||
const stats = {
|
||||
total: logs.length,
|
||||
delivered: logs.filter((l: MessageLogEntry) => l.status === "delivered" || l.delivered_at).length,
|
||||
failed: logs.filter((l: MessageLogEntry) => l.status === "failed" || l.status === "bounced").length,
|
||||
pending: logs.filter((l: MessageLogEntry) => l.status === "queued" || l.status === "sent").length,
|
||||
};
|
||||
|
||||
// Paginate
|
||||
const totalPages = Math.max(1, Math.ceil(filteredLogs.length / PAGE_SIZE));
|
||||
const paginatedLogs = filteredLogs.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchLogs();
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-zinc-100">Message Logs</h2>
|
||||
<p className="text-sm text-zinc-500">{filtered.length} message{filtered.length !== 1 ? "s" : ""}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
|
||||
{Icons.messageSquare("h-5 w-5 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Message Logs</h2>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
|
||||
{isLoading ? "Loading..." : `${filteredLogs.length} message${filteredLogs.length !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-stone-600 bg-white border border-stone-200 rounded-lg hover:bg-stone-50 hover:text-stone-900 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{Icons.refresh("h-4 w-4" + (isLoading ? " animate-spin" : ""))}
|
||||
<span className="hidden sm:inline">Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-3 sm:p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1">{stats.total}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-3 sm:p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Delivered</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-emerald-600 mt-1">{stats.delivered}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-3 sm:p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Failed</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-red-600 mt-1">{stats.failed}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-3 sm:p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Pending</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-stone-600 mt-1">{stats.pending}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mb-4 flex-wrap">
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-4">
|
||||
<div className="relative flex-1">
|
||||
{Icons.search("absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-stone-400")}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by email or subject..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="w-full pl-10 pr-4 py-2.5 text-sm border border-[var(--admin-border)] rounded-xl bg-white text-[var(--admin-text-primary)] placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-4 py-2.5 text-sm border border-[var(--admin-border)] rounded-xl bg-white text-[var(--admin-text-primary)] focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="queued">Queued</option>
|
||||
@@ -91,70 +209,87 @@ export default function MessageLogPanel({ initialLogs }: { initialLogs: MessageL
|
||||
<option value="bounced">Bounced</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
<select
|
||||
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
|
||||
value={filterMethod}
|
||||
onChange={(e) => setFilterMethod(e.target.value)}
|
||||
>
|
||||
<option value="all">All Methods</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="sms">SMS</option>
|
||||
<option value="push">Push</option>
|
||||
<option value="internal">Internal</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">No messages logged yet</div>
|
||||
{/* Table */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex items-center gap-3 text-stone-500">
|
||||
{Icons.refresh("h-5 w-5 animate-spin")}
|
||||
<span>Loading logs...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : paginatedLogs.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
|
||||
{Icons.list("h-8 w-8 text-stone-400")}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-stone-600">No messages logged yet</p>
|
||||
<p className="text-xs text-stone-400 mt-1">Send a campaign to see logs here</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<div className="hidden sm:block bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
|
||||
<div className="hidden sm:block overflow-hidden rounded-xl border border-[var(--admin-border)]">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-zinc-800">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Sent At</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Recipient</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Method</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Subject</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Status</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Engagement</th>
|
||||
<thead className="bg-stone-50">
|
||||
<tr className="border-b border-[var(--admin-border)]">
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Sent At</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Recipient</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Method</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Subject</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Status</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Engagement</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{filtered.map((l) => (
|
||||
<tr key={l.id} className="hover:bg-zinc-800">
|
||||
<td className="px-4 py-3 text-zinc-500 text-xs whitespace-nowrap">
|
||||
{l.sent_at ? new Date(l.sent_at).toLocaleString() : "—"}
|
||||
<tbody className="divide-y divide-[var(--admin-border)] bg-white">
|
||||
{paginatedLogs.map((log: MessageLogEntry) => (
|
||||
<tr key={log.id} className="hover:bg-stone-50 transition-colors">
|
||||
<td className="px-4 py-3 text-[var(--admin-text-muted)] text-xs whitespace-nowrap">
|
||||
{log.sent_at ? formatDate(log.sent_at) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--admin-text-primary)] text-xs">
|
||||
{log.customer_email ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-300 text-xs">{l.customer_email ?? "—"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${METHOD_COLORS[l.delivery_method] ?? "bg-zinc-950"}`}>
|
||||
{l.delivery_method}
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ${METHOD_COLORS[log.delivery_method] ?? "bg-stone-100 text-stone-600"}`}>
|
||||
{log.delivery_method}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-300 text-xs truncate max-w-[180px]">{l.subject ?? "—"}</td>
|
||||
<td className="px-4 py-3 text-[var(--admin-text-primary)] text-xs truncate max-w-[160px]" title={log.subject ?? undefined}>
|
||||
{log.subject ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[l.status] ?? "bg-zinc-950"}`}>
|
||||
{l.status}
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ${STATUS_COLORS[log.status] ?? "bg-stone-100 text-stone-600"}`}>
|
||||
{log.status}
|
||||
</span>
|
||||
{l.error_message && (
|
||||
<p className="text-xs text-red-500 mt-0.5 truncate max-w-[120px]">{l.error_message}</p>
|
||||
{log.error_message && (
|
||||
<p className="text-[10px] text-red-600 mt-0.5 truncate max-w-[100px]" title={log.error_message}>
|
||||
{log.error_message}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
{l.delivered_at && (
|
||||
<span className="text-xs text-emerald-600">✓ {formatDate(new Date(l.delivered_at))}</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{log.delivered_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 border border-emerald-200 text-emerald-700 px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{Icons.check("h-3 w-3")} Delivered
|
||||
</span>
|
||||
)}
|
||||
{l.opened_at && (
|
||||
<span className="text-xs text-violet-600">Opened {formatDate(new Date(l.opened_at))}</span>
|
||||
{log.opened_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-violet-50 border border-violet-200 text-violet-700 px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{Icons.eye("h-3 w-3")} Opened
|
||||
</span>
|
||||
)}
|
||||
{l.clicked_at && (
|
||||
<span className="text-xs text-amber-400">Clicked {formatDate(new Date(l.clicked_at))}</span>
|
||||
{log.clicked_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-50 border border-amber-200 text-amber-700 px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{Icons.mousePointer("h-3 w-3")} Clicked
|
||||
</span>
|
||||
)}
|
||||
{l.bounced_at && (
|
||||
<span className="text-xs text-red-500">✗ Bounced{l.bounce_reason ? `: ${l.bounce_reason}` : ""}</span>
|
||||
{log.bounced_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-red-50 border border-red-200 text-red-700 px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{Icons.x("h-3 w-3")} Bounced
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
@@ -166,29 +301,93 @@ export default function MessageLogPanel({ initialLogs }: { initialLogs: MessageL
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="sm:hidden space-y-3">
|
||||
{filtered.map((l) => (
|
||||
<div key={l.id} className="bg-zinc-900 rounded-xl border border-zinc-800 p-4 space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-sm font-medium text-zinc-100">{l.customer_email ?? "—"}</div>
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[l.status] ?? "bg-zinc-950"}`}>
|
||||
{l.status}
|
||||
{paginatedLogs.map((log: MessageLogEntry) => (
|
||||
<div key={log.id} className="rounded-xl border border-[var(--admin-border)] bg-white p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-[var(--admin-text-primary)]">{log.customer_email ?? "—"}</div>
|
||||
<div className="text-xs text-[var(--admin-text-muted)] mt-0.5">{log.subject ?? "—"}</div>
|
||||
</div>
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold shrink-0 ${STATUS_COLORS[log.status] ?? "bg-stone-100 text-stone-600"}`}>
|
||||
{log.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">{l.subject ?? "—"}</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${METHOD_COLORS[l.delivery_method] ?? "bg-zinc-950"}`}>
|
||||
{l.delivery_method}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ${METHOD_COLORS[log.delivery_method] ?? "bg-stone-100 text-stone-600"}`}>
|
||||
{log.delivery_method}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
{l.sent_at ? formatDate(new Date(l.sent_at)) : "—"}
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
{log.sent_at ? formatDate(log.sent_at) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<EngagementBadges log={l} />
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{log.delivered_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 border border-emerald-200 text-emerald-700 px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{Icons.check("h-3 w-3")} Delivered
|
||||
</span>
|
||||
)}
|
||||
{log.opened_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-violet-50 border border-violet-200 text-violet-700 px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{Icons.eye("h-3 w-3")} Opened
|
||||
</span>
|
||||
)}
|
||||
{log.clicked_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-50 border border-amber-200 text-amber-700 px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{Icons.mousePointer("h-3 w-3")} Clicked
|
||||
</span>
|
||||
)}
|
||||
{log.bounced_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-red-50 border border-red-200 text-red-700 px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{Icons.x("h-3 w-3")} Bounced
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-[var(--admin-border)]">
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
Showing {(page - 1) * PAGE_SIZE + 1} to {Math.min(page * PAGE_SIZE, filteredLogs.length)} of {filteredLogs.length}
|
||||
</p>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1.5 text-sm border border-[var(--admin-border)] rounded-lg bg-white text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
const pageNum = i + 1;
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => setPage(pageNum)}
|
||||
className={`px-3 py-1.5 text-sm border rounded-lg transition-colors ${
|
||||
page === pageNum
|
||||
? "bg-emerald-600 text-white border-emerald-600"
|
||||
: "bg-white text-[var(--admin-text-primary)] border-[var(--admin-border)] hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-1.5 text-sm border border-[var(--admin-border)] rounded-lg bg-white text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,10 @@ type EditableItem = {
|
||||
removed: boolean;
|
||||
};
|
||||
|
||||
function formatCurrency(amount: number) {
|
||||
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
|
||||
}
|
||||
|
||||
export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
const router = useRouter();
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -157,38 +161,38 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saved && (
|
||||
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400">
|
||||
Order updated successfully.
|
||||
<div className="rounded-xl bg-green-50 border border-green-200 p-4 text-sm text-green-700">
|
||||
✓ Order updated successfully
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Order items */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-zinc-300">
|
||||
<p className="mb-3 text-sm font-semibold text-stone-700">
|
||||
Order Items ({visibleItems.length})
|
||||
</p>
|
||||
{visibleItems.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-zinc-600 p-4 text-center text-sm text-zinc-500">
|
||||
No items.
|
||||
<p className="rounded-xl border border-dashed border-stone-300 p-4 text-center text-sm text-stone-400">
|
||||
No items
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{visibleItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-xl border border-zinc-800 bg-zinc-900 p-4"
|
||||
className="rounded-xl border border-stone-200 bg-white p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="font-medium text-zinc-100">{item.productName}</p>
|
||||
<p className="font-medium text-stone-900">{item.productName}</p>
|
||||
<button
|
||||
onClick={() => removeItem(item.id)}
|
||||
className="shrink-0 rounded-lg px-2 py-1 text-xs font-medium text-red-400 hover:bg-red-900/30"
|
||||
className="shrink-0 rounded-lg px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
@@ -196,9 +200,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-500">
|
||||
Qty
|
||||
</label>
|
||||
<label className="mb-1 block text-xs font-medium text-stone-500">Qty</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -206,13 +208,11 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
onChange={(e) =>
|
||||
updateItem(item.id, "quantity", Number(e.target.value))
|
||||
}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-sm outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-500">
|
||||
Price
|
||||
</label>
|
||||
<label className="mb-1 block text-xs font-medium text-stone-500">Price</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
@@ -221,13 +221,13 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
onChange={(e) =>
|
||||
updateItem(item.id, "price", Number(e.target.value))
|
||||
}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-3 py-2 text-sm outline-none focus:border-slate-900"
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-sm outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-right text-sm font-semibold text-zinc-100">
|
||||
${(Number(item.price) * item.quantity).toFixed(2)}
|
||||
<p className="mt-2 text-right text-sm font-semibold text-stone-900">
|
||||
{formatCurrency(Number(item.price) * item.quantity)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -235,95 +235,48 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pricing — subtotal derived from items */}
|
||||
{/* Pricing */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Subtotal (auto-calculated)
|
||||
</label>
|
||||
<label className="mb-1 block text-xs font-semibold text-stone-500">Subtotal (auto-calculated)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={subtotal}
|
||||
readOnly
|
||||
className="w-full rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3 text-base text-zinc-500"
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 text-sm text-stone-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Discount Amount
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={discount_amount}
|
||||
onChange={(e) => setDiscount_amount(Number(e.target.value))}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Discount Reason
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={discount_reason}
|
||||
onChange={(e) => setDiscount_reason(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Tax Amount
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={order.tax_amount ?? 0}
|
||||
onChange={(e) => {}}
|
||||
readOnly
|
||||
className="w-full rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3 text-base text-zinc-500"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-400 italic">Taxes calculated at payment integration.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Tax Rate
|
||||
</label>
|
||||
<label className="mb-1 block text-xs font-semibold text-stone-500">Discount Amount</label>
|
||||
<input
|
||||
type="text"
|
||||
value={order.tax_rate ?? ""}
|
||||
readOnly
|
||||
placeholder="e.g. 0.08"
|
||||
className="w-full rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3 text-base text-zinc-500"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={discount_amount}
|
||||
onChange={(e) => setDiscount_amount(Number(e.target.value))}
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 text-sm outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Tax Location
|
||||
</label>
|
||||
<label className="mb-1 block text-xs font-semibold text-stone-500">Discount Reason</label>
|
||||
<input
|
||||
type="text"
|
||||
value={order.tax_location ?? ""}
|
||||
readOnly
|
||||
placeholder="e.g. NC"
|
||||
className="w-full rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3 text-base text-zinc-500"
|
||||
value={discount_reason}
|
||||
onChange={(e) => setDiscount_reason(e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 text-sm outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-4">
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-lg font-medium text-zinc-300">Total</span>
|
||||
<span className="text-2xl font-bold text-zinc-100">
|
||||
${(subtotal + Number(order.tax_amount ?? 0) - discount_amount).toFixed(2)}
|
||||
<span className="text-sm font-medium text-stone-600">Total</span>
|
||||
<span className="text-xl font-bold text-stone-900">
|
||||
{formatCurrency(subtotal + Number(order.tax_amount ?? 0) - discount_amount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -332,57 +285,49 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
{/* Customer fields */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Name
|
||||
</label>
|
||||
<label className="mb-1 block text-xs font-semibold text-stone-500">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customer_name}
|
||||
onChange={(e) => setCustomer_name(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={customer_email}
|
||||
onChange={(e) => setCustomer_email(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Phone
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={customer_phone}
|
||||
onChange={(e) => setCustomer_phone(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-stone-500">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={customer_email}
|
||||
onChange={(e) => setCustomer_email(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-stone-500">Phone</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={customer_phone}
|
||||
onChange={(e) => setCustomer_phone(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status & pickup */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300">
|
||||
Status
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<label className="mb-2 block text-xs font-semibold text-stone-500">Status</label>
|
||||
<div className="flex gap-2">
|
||||
{["pending", "confirmed", "cancelled"].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setStatus(s)}
|
||||
className={`flex-1 rounded-xl px-4 py-3 text-sm font-medium capitalize transition-colors ${
|
||||
className={`flex-1 rounded-xl px-4 py-2.5 text-sm font-semibold capitalize transition-colors ${
|
||||
status === s
|
||||
? "bg-slate-900 text-white"
|
||||
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
|
||||
? "bg-emerald-600 text-white"
|
||||
: "bg-white text-stone-600 border border-stone-200 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
@@ -392,49 +337,39 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300">
|
||||
Pickup
|
||||
</label>
|
||||
<label className="mb-2 block text-xs font-semibold text-stone-500">Pickup</label>
|
||||
<button
|
||||
onClick={() => setPickup_complete((v) => !v)}
|
||||
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
|
||||
className={`w-full rounded-xl px-4 py-2.5 text-sm font-semibold transition-colors ${
|
||||
pickup_complete
|
||||
? "bg-green-900/40 text-green-400"
|
||||
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
|
||||
? "bg-green-100 text-green-700 border border-green-200"
|
||||
: "bg-amber-100 text-amber-700 border border-amber-200"
|
||||
}`}
|
||||
>
|
||||
{pickup_complete ? "Picked Up" : "Not Picked Up"}
|
||||
{pickup_complete ? "✓ Picked Up" : "○ Not Picked Up"}
|
||||
</button>
|
||||
{order.pickup_complete && order.pickup_completed_at && (
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
Completed{" "}
|
||||
{new Date(order.pickup_completed_at).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Internal notes */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Internal Notes
|
||||
</label>
|
||||
<label className="mb-1 block text-xs font-semibold text-stone-500">Internal Notes</label>
|
||||
<textarea
|
||||
value={internal_notes}
|
||||
onChange={(e) => setInternal_notes(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
rows={2}
|
||||
placeholder="Private notes for staff..."
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-emerald-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-bold text-white disabled:opacity-50"
|
||||
className="w-full rounded-xl bg-emerald-600 px-6 py-3 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { updateOrder } from "@/actions/orders/update-order";
|
||||
import { createRefund } from "@/actions/orders/create-refund";
|
||||
@@ -29,6 +28,10 @@ type OrderPaymentSectionProps = {
|
||||
existingRefunds: Refund[];
|
||||
};
|
||||
|
||||
function formatCurrency(amount: number) {
|
||||
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
|
||||
}
|
||||
|
||||
export default function OrderPaymentSection({
|
||||
orderId,
|
||||
brandId,
|
||||
@@ -36,11 +39,8 @@ export default function OrderPaymentSection({
|
||||
payment_processor,
|
||||
payment_status,
|
||||
payment_transaction_id,
|
||||
refunded_amount,
|
||||
refund_reason,
|
||||
existingRefunds,
|
||||
}: OrderPaymentSectionProps) {
|
||||
const router = useRouter();
|
||||
const [processor, setProcessor] = useState(payment_processor ?? "");
|
||||
const [status, setStatus] = useState(payment_status ?? "manual");
|
||||
const [transactionId, setTransactionId] = useState(payment_transaction_id ?? "");
|
||||
@@ -102,28 +102,28 @@ export default function OrderPaymentSection({
|
||||
const remainingBalance = Math.max(0, orderTotal - totalRefunded);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-5">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">{error}</div>
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment details */}
|
||||
<form onSubmit={handleSavePayment} className="space-y-4">
|
||||
{saved && (
|
||||
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400">
|
||||
Payment details saved.
|
||||
<div className="rounded-xl bg-green-50 border border-green-200 p-4 text-sm text-green-700">
|
||||
✓ Payment details saved
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Processor
|
||||
</label>
|
||||
<label className="mb-1 block text-xs font-semibold text-stone-500">Processor</label>
|
||||
<select
|
||||
value={processor}
|
||||
onChange={(e) => setProcessor(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-emerald-500"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{["manual", "stripe", "square", "cash", "venmo", "other"].map((p) => (
|
||||
@@ -135,13 +135,11 @@ export default function OrderPaymentSection({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Payment Status
|
||||
</label>
|
||||
<label className="mb-1 block text-xs font-semibold text-stone-500">Payment Status</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-emerald-500"
|
||||
>
|
||||
{["pending", "paid", "failed", "refunded", "cancelled"].map((s) => (
|
||||
<option key={s} value={s}>
|
||||
@@ -153,22 +151,20 @@ export default function OrderPaymentSection({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-300">
|
||||
Transaction ID
|
||||
</label>
|
||||
<label className="mb-1 block text-xs font-semibold text-stone-500">Transaction ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={transactionId}
|
||||
onChange={(e) => setTransactionId(e.target.value)}
|
||||
placeholder="External payment reference"
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="w-full rounded-xl bg-slate-900 px-5 py-3 text-sm font-medium text-white disabled:opacity-50"
|
||||
className="rounded-xl bg-emerald-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Payment Details"}
|
||||
</button>
|
||||
@@ -177,31 +173,31 @@ export default function OrderPaymentSection({
|
||||
{/* Refund history */}
|
||||
{existingRefunds.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-zinc-300">
|
||||
<p className="mb-3 text-sm font-semibold text-stone-700">
|
||||
Refunds ({existingRefunds.length})
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{existingRefunds.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="flex items-center justify-between rounded-xl border border-zinc-800 bg-slate-50 px-4 py-3"
|
||||
className="flex items-center justify-between rounded-xl border border-stone-200 bg-white px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-zinc-100">
|
||||
${Number(r.amount).toFixed(2)}
|
||||
<p className="font-semibold text-stone-900">
|
||||
{formatCurrency(Number(r.amount))}
|
||||
</p>
|
||||
{r.reason && (
|
||||
<p className="text-xs text-zinc-500">{r.reason}</p>
|
||||
<p className="text-xs text-stone-500">{r.reason}</p>
|
||||
)}
|
||||
<p className="text-xs text-slate-400">
|
||||
{formatDate(new Date(r.created_at))}
|
||||
<p className="text-xs text-stone-400">
|
||||
{formatDate(r.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs font-bold ${
|
||||
r.status === "completed"
|
||||
? "bg-green-900/40 text-green-400"
|
||||
: "bg-zinc-950 text-zinc-500"
|
||||
? "bg-green-50 text-green-700"
|
||||
: "bg-stone-100 text-stone-500"
|
||||
}`}
|
||||
>
|
||||
{r.status}
|
||||
@@ -209,28 +205,26 @@ export default function OrderPaymentSection({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex justify-between rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm">
|
||||
<span className="text-zinc-500">Total refunded</span>
|
||||
<span className="font-medium text-zinc-100">
|
||||
${totalRefunded.toFixed(2)}
|
||||
<div className="mt-2 flex justify-between rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 text-sm">
|
||||
<span className="text-stone-500">Total refunded</span>
|
||||
<span className="font-semibold text-stone-900">
|
||||
{formatCurrency(totalRefunded)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Record a refund */}
|
||||
<div className="rounded-xl border border-dashed border-zinc-600 p-4">
|
||||
<p className="mb-3 text-sm font-medium text-zinc-300">Record a Refund</p>
|
||||
<div className="rounded-xl border border-dashed border-stone-300 bg-stone-50 p-4">
|
||||
<p className="mb-3 text-sm font-semibold text-stone-700">Record a Refund</p>
|
||||
{refundSaved && (
|
||||
<div className="mb-3 rounded-xl bg-green-900/30 p-3 text-sm text-green-400">
|
||||
Refund recorded.
|
||||
<div className="mb-3 rounded-xl bg-green-50 border border-green-200 p-3 text-sm text-green-700">
|
||||
✓ Refund recorded
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleRefund} className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-500">
|
||||
Amount
|
||||
</label>
|
||||
<label className="mb-1 block text-xs font-semibold text-stone-500">Amount</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
@@ -238,26 +232,24 @@ export default function OrderPaymentSection({
|
||||
max={remainingBalance}
|
||||
value={refundAmount}
|
||||
onChange={(e) => setRefundAmount(e.target.value)}
|
||||
placeholder={`Max $${remainingBalance.toFixed(2)}`}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm outline-none focus:border-slate-900"
|
||||
placeholder={`Max ${formatCurrency(remainingBalance)}`}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-500">
|
||||
Reason
|
||||
</label>
|
||||
<label className="mb-1 block text-xs font-semibold text-stone-500">Reason</label>
|
||||
<input
|
||||
type="text"
|
||||
value={refundReason}
|
||||
onChange={(e) => setRefundReason(e.target.value)}
|
||||
placeholder="Customer request, defective product, etc."
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm outline-none focus:border-slate-900"
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!refundAmount || Number(refundAmount) <= 0 || refunding}
|
||||
className="w-full rounded-xl border border-red-300 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-900/30 disabled:opacity-50"
|
||||
className="w-full rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-semibold text-red-600 hover:bg-red-100 disabled:opacity-50"
|
||||
>
|
||||
{refunding ? "Processing..." : "Record Refund"}
|
||||
</button>
|
||||
@@ -265,4 +257,4 @@ export default function OrderPaymentSection({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { markPickupComplete } from "@/actions/pickup";
|
||||
|
||||
type Props = {
|
||||
type OrderPickupActionProps = {
|
||||
orderId: string;
|
||||
brandId: string | null;
|
||||
currentlyPickedUp: boolean;
|
||||
@@ -13,51 +14,33 @@ export default function OrderPickupAction({
|
||||
orderId,
|
||||
brandId,
|
||||
currentlyPickedUp,
|
||||
}: Props) {
|
||||
const [pickingUp, setPickingUp] = useState(false);
|
||||
const [toast, setToast] = useState<{ type: "success" | "error"; msg: string } | null>(null);
|
||||
}: OrderPickupActionProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
async function handlePickup() {
|
||||
setPickingUp(true);
|
||||
async function handleMarkPickup() {
|
||||
if (currentlyPickedUp) return;
|
||||
setLoading(true);
|
||||
const result = await markPickupComplete(orderId, brandId);
|
||||
setPickingUp(false);
|
||||
setLoading(false);
|
||||
if (result.success) {
|
||||
setToast({ type: "success", msg: "Order marked as picked up." });
|
||||
setTimeout(() => window.location.reload(), 1200);
|
||||
} else {
|
||||
setToast({ type: "error", msg: result.error ?? "Failed to mark picked up." });
|
||||
setDone(true);
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
if (currentlyPickedUp) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-100 px-3 py-1 text-sm font-semibold text-green-700">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Picked Up
|
||||
</span>
|
||||
);
|
||||
if (currentlyPickedUp || done) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<button
|
||||
onClick={handlePickup}
|
||||
disabled={pickingUp}
|
||||
className="rounded-xl bg-green-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{pickingUp ? "Marking..." : "Mark Picked Up"}
|
||||
</button>
|
||||
{toast && (
|
||||
<div
|
||||
className={`rounded-lg px-3 py-2 text-sm font-medium ${
|
||||
toast.type === "success" ? "bg-green-50 text-green-700" : "bg-red-50 text-red-700"
|
||||
}`}
|
||||
>
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleMarkPickup}
|
||||
disabled={loading}
|
||||
className="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Marking..." : "✓ Mark Picked Up"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -238,33 +238,44 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Publishable Key</label>
|
||||
<input
|
||||
type="text"
|
||||
value={stripePublishableKey}
|
||||
onChange={(e) => setStripePublishableKey(e.target.value)}
|
||||
placeholder="pk_live_..."
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-zinc-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Secret Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={stripeSecretKey}
|
||||
onChange={(e) => setStripeSecretKey(e.target.value)}
|
||||
placeholder="sk_live_..."
|
||||
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-zinc-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-xl bg-blue-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Stripe Settings"}
|
||||
</button>
|
||||
|
||||
{!settings?.stripe_publishable_key ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-blue-200">
|
||||
Connect your Stripe account to process payments. You'll be redirected to Stripe to authorize the connection.
|
||||
</p>
|
||||
<a
|
||||
href="/api/stripe/oauth"
|
||||
className="inline-block rounded-xl bg-blue-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-blue-700"
|
||||
>
|
||||
Connect with Stripe
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-green-400">
|
||||
✓ Your Stripe account is connected and ready to accept payments.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!confirm("Disconnect Stripe? You'll need to reconnect to accept payments.")) return;
|
||||
const result = await savePaymentSettings({
|
||||
brandId: activeBrandId,
|
||||
provider: null,
|
||||
stripePublishableKey: "",
|
||||
stripeSecretKey: "",
|
||||
});
|
||||
if (result.success) {
|
||||
window.location.reload();
|
||||
}
|
||||
}}
|
||||
className="text-sm text-red-400 hover:underline"
|
||||
>
|
||||
Disconnect Stripe
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,885 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useTransition } from "react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createProduct } from "@/actions/products/create-product";
|
||||
import { updateProduct } from "@/actions/products/update-product";
|
||||
import { deleteProduct } from "@/actions/products";
|
||||
import { uploadProductImage } from "@/actions/products/upload-image";
|
||||
import GlassModal from "@/components/admin/GlassModal";
|
||||
|
||||
type Product = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
type: string;
|
||||
active: boolean;
|
||||
image_url?: string | null;
|
||||
brand_id: string;
|
||||
is_taxable: boolean;
|
||||
};
|
||||
|
||||
type FormData = {
|
||||
name: string;
|
||||
description: string;
|
||||
price: string;
|
||||
type: string;
|
||||
active: boolean;
|
||||
image_url: string;
|
||||
is_taxable: boolean;
|
||||
};
|
||||
|
||||
type ViewMode = "table" | "cards";
|
||||
|
||||
type Props = {
|
||||
products: Product[];
|
||||
brandId: string;
|
||||
};
|
||||
|
||||
// Icons
|
||||
const Icons = {
|
||||
search: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.3-4.3"/>
|
||||
</svg>
|
||||
),
|
||||
grid: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7"/>
|
||||
<rect x="14" y="3" width="7" height="7"/>
|
||||
<rect x="14" y="14" width="7" height="7"/>
|
||||
<rect x="3" y="14" width="7" height="7"/>
|
||||
</svg>
|
||||
),
|
||||
list: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="8" y1="6" x2="21" y2="6"/>
|
||||
<line x1="8" y1="12" x2="21" y2="12"/>
|
||||
<line x1="8" y1="18" x2="21" y2="18"/>
|
||||
<line x1="3" y1="6" x2="3.01" y2="6"/>
|
||||
<line x1="3" y1="12" x2="3.01" y2="12"/>
|
||||
<line x1="3" y1="18" x2="3.01" y2="18"/>
|
||||
</svg>
|
||||
),
|
||||
plus: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
),
|
||||
package: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m7.5 4.27 9 5.15"/>
|
||||
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/>
|
||||
<path d="m3.3 7 8.7 5 8.7-5"/>
|
||||
<path d="M12 22V12"/>
|
||||
</svg>
|
||||
),
|
||||
upload: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export default function ProductsClient({ products, brandId }: Props) {
|
||||
const router = useRouter();
|
||||
const [, startTransition] = useTransition();
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all");
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("table");
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
name: "",
|
||||
description: "",
|
||||
price: "",
|
||||
type: "pickup",
|
||||
active: true,
|
||||
image_url: "",
|
||||
is_taxable: true,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
// Image upload states
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [pendingImageUrl, setPendingImageUrl] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const filtered = products.filter((p) => {
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
p.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.description.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "active" && p.active) ||
|
||||
(statusFilter === "inactive" && !p.active);
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const activeCount = products.filter((p) => p.active).length;
|
||||
const inactiveCount = products.filter((p) => !p.active).length;
|
||||
|
||||
async function resizeImage(file: File, maxWidth: number): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = document.createElement("img");
|
||||
img.onload = () => {
|
||||
let { width, height } = img;
|
||||
if (width > maxWidth) {
|
||||
height = Math.round(height * (maxWidth / width));
|
||||
width = maxWidth;
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error("Failed to resize image"));
|
||||
return;
|
||||
}
|
||||
blob.arrayBuffer().then((buf) => resolve(buf));
|
||||
}, "image/jpeg", 0.85);
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleFileSelect(file: File) {
|
||||
const validTypes = ["image/png", "image/jpeg", "image/webp"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
setUploadError("Only PNG, JPEG, and WebP images are allowed.");
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setUploadError("Image must be under 5MB.");
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadError(null);
|
||||
setUploading(true);
|
||||
|
||||
const resizedBuffer = await resizeImage(file, 1200);
|
||||
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
|
||||
|
||||
const productIdForUpload = editingProduct ? editingProduct.id : "__NEW__";
|
||||
const result = await uploadProductImage(productIdForUpload, resizedFile);
|
||||
|
||||
setUploading(false);
|
||||
|
||||
if (result.success) {
|
||||
setPendingImageUrl(result.imageUrl);
|
||||
setImagePreview(result.imageUrl);
|
||||
setFormData({ ...formData, image_url: result.imageUrl });
|
||||
} else {
|
||||
setUploadError(result.error ?? "Upload failed.");
|
||||
}
|
||||
}
|
||||
|
||||
const openAddModal = useCallback(() => {
|
||||
setEditingProduct(null);
|
||||
setFormData({
|
||||
name: "",
|
||||
description: "",
|
||||
price: "",
|
||||
type: "pickup",
|
||||
active: true,
|
||||
image_url: "",
|
||||
is_taxable: true,
|
||||
});
|
||||
setImagePreview(null);
|
||||
setPendingImageUrl(null);
|
||||
setUploadError(null);
|
||||
setError(null);
|
||||
setShowModal(true);
|
||||
}, []);
|
||||
|
||||
const openEditModal = useCallback((product: Product) => {
|
||||
setEditingProduct(product);
|
||||
setFormData({
|
||||
name: product.name,
|
||||
description: product.description || "",
|
||||
price: String(product.price),
|
||||
type: product.type,
|
||||
active: product.active,
|
||||
image_url: product.image_url || "",
|
||||
is_taxable: product.is_taxable,
|
||||
});
|
||||
setImagePreview(product.image_url || null);
|
||||
setPendingImageUrl(null);
|
||||
setUploadError(null);
|
||||
setError(null);
|
||||
setShowModal(true);
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setShowModal(false);
|
||||
setEditingProduct(null);
|
||||
setImagePreview(null);
|
||||
setPendingImageUrl(null);
|
||||
setUploadError(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSaving(true);
|
||||
|
||||
const price = parseFloat(formData.price);
|
||||
if (isNaN(price) || price < 0) {
|
||||
setError("Please enter a valid price");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let result;
|
||||
const imageUrl = pendingImageUrl || formData.image_url || null;
|
||||
|
||||
if (editingProduct) {
|
||||
result = await updateProduct(editingProduct.id, brandId, {
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim(),
|
||||
price,
|
||||
type: formData.type,
|
||||
active: formData.active,
|
||||
image_url: imageUrl,
|
||||
is_taxable: formData.is_taxable,
|
||||
});
|
||||
} else {
|
||||
result = await createProduct(brandId, {
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim(),
|
||||
price,
|
||||
type: formData.type,
|
||||
active: formData.active,
|
||||
image_url: imageUrl,
|
||||
is_taxable: formData.is_taxable,
|
||||
});
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save product");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
closeModal();
|
||||
startTransition(() => router.refresh());
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (productId: string) => {
|
||||
setDeletingId(productId);
|
||||
const result = await deleteProduct(productId, null);
|
||||
setDeletingId(null);
|
||||
if (result.success) {
|
||||
setDeleteConfirm(null);
|
||||
startTransition(() => router.refresh());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
|
||||
{Icons.package("h-5 w-5 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Products</h2>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
|
||||
{filtered.length} product{filtered.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white text-sm font-medium rounded-xl hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
{Icons.plus("h-4 w-4")}
|
||||
Add Product
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1">{products.length}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Active</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-emerald-600 mt-1">{activeCount}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Inactive</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-stone-400 mt-1">{inactiveCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
{/* Status Tabs */}
|
||||
<div className="flex rounded-xl border border-[var(--admin-border)] bg-white p-1">
|
||||
{(["all", "active", "inactive"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setStatusFilter(f)}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
||||
statusFilter === f
|
||||
? "bg-emerald-600 text-white"
|
||||
: "text-stone-500 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
{Icons.search("absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-stone-400")}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search products..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 text-sm border border-[var(--admin-border)] rounded-xl bg-white text-[var(--admin-text-primary)] placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* View Toggle */}
|
||||
<div className="flex rounded-xl border border-[var(--admin-border)] bg-white p-1">
|
||||
<button
|
||||
onClick={() => setViewMode("table")}
|
||||
className={`rounded-lg p-2 transition-colors ${
|
||||
viewMode === "table" ? "bg-emerald-600 text-white" : "text-stone-500 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{Icons.list("h-4 w-4")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("cards")}
|
||||
className={`rounded-lg p-2 transition-colors ${
|
||||
viewMode === "cards" ? "bg-emerald-600 text-white" : "text-stone-500 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{Icons.grid("h-4 w-4")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{viewMode === "table" ? (
|
||||
<TableView
|
||||
products={filtered}
|
||||
onEdit={openEditModal}
|
||||
onDelete={(id) => setDeleteConfirm(id)}
|
||||
deleteConfirm={deleteConfirm}
|
||||
onDeleteConfirm={(id) => handleDelete(id)}
|
||||
onDeleteCancel={() => setDeleteConfirm(null)}
|
||||
deletingId={deletingId}
|
||||
/>
|
||||
) : (
|
||||
<CardView
|
||||
products={filtered}
|
||||
onEdit={openEditModal}
|
||||
onDelete={(id) => setDeleteConfirm(id)}
|
||||
deleteConfirm={deleteConfirm}
|
||||
onDeleteConfirm={(id) => handleDelete(id)}
|
||||
onDeleteCancel={() => setDeleteConfirm(null)}
|
||||
deletingId={deletingId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<GlassModal
|
||||
title={editingProduct ? "Edit Product" : "Add Product"}
|
||||
subtitle={editingProduct ? `Editing ${editingProduct.name}` : "Create a new product"}
|
||||
onClose={closeModal}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Product name"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-stone-50 px-3 py-2.5 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Description</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Product description"
|
||||
rows={2}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-stone-50 px-3 py-2.5 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Price</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400 text-sm">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.price}
|
||||
onChange={(e) => setFormData({ ...formData, price: e.target.value })}
|
||||
placeholder="0.00"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-stone-50 pl-8 pr-3 py-2.5 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Type</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-stone-50 px-3 py-2.5 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20"
|
||||
>
|
||||
<option value="pickup">Pickup</option>
|
||||
<option value="wholesale">Wholesale</option>
|
||||
<option value="subscription">Subscription</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Upload */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Product Image</label>
|
||||
<p className="text-[10px] text-stone-500 mb-2">JPG, PNG, WebP · 1200px max width · max 5MB</p>
|
||||
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFileSelect(file);
|
||||
}}
|
||||
onClick={() => !uploading && fileInputRef.current?.click()}
|
||||
className={`
|
||||
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
|
||||
${uploadError ? "border-red-400" : "border-[var(--admin-border)] hover:border-emerald-400 hover:bg-emerald-50/50"}
|
||||
${uploading ? "opacity-50 pointer-events-none" : ""}
|
||||
`}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<div className="h-5 w-5 rounded-full border-2 border-emerald-400 border-t-transparent animate-spin" />
|
||||
<span className="text-sm text-stone-500">Uploading...</span>
|
||||
</>
|
||||
) : imagePreview ? (
|
||||
<div className="relative">
|
||||
<div className="relative h-40 w-auto">
|
||||
<Image src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
|
||||
</div>
|
||||
<span className="block text-center text-xs text-stone-500 mt-2">Click or drop to replace</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{Icons.upload("h-8 w-8 text-stone-400")}
|
||||
<span className="text-sm text-stone-500">Drag & drop or click to upload</span>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileSelect(file);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{uploadError && (
|
||||
<p className="mt-1 text-xs text-red-500">{uploadError}</p>
|
||||
)}
|
||||
|
||||
{(imagePreview || formData.image_url) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setImagePreview(null);
|
||||
setPendingImageUrl(null);
|
||||
setFormData({ ...formData, image_url: "" });
|
||||
}}
|
||||
className="mt-2 text-xs text-red-500 hover:underline"
|
||||
>
|
||||
Remove image
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.active}
|
||||
onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-stone-300 text-emerald-600"
|
||||
/>
|
||||
<span className="text-sm font-medium text-stone-700">Active</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_taxable}
|
||||
onChange={(e) => setFormData({ ...formData, is_taxable: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-stone-300 text-emerald-600"
|
||||
/>
|
||||
<span className="text-sm font-medium text-stone-700">Taxable</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-stone-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="rounded-xl border border-[var(--admin-border)] px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !formData.name.trim() || !formData.price}
|
||||
className="rounded-xl bg-emerald-600 px-5 py-2 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : editingProduct ? "Save Changes" : "Create Product"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</GlassModal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Table View ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function TableView({
|
||||
products,
|
||||
onEdit,
|
||||
onDelete,
|
||||
deleteConfirm,
|
||||
onDeleteConfirm,
|
||||
onDeleteCancel,
|
||||
deletingId,
|
||||
}: {
|
||||
products: Product[];
|
||||
onEdit: (p: Product) => void;
|
||||
onDelete: (id: string) => void;
|
||||
deleteConfirm: string | null;
|
||||
onDeleteConfirm: (id: string) => void;
|
||||
onDeleteCancel: () => void;
|
||||
deletingId: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-[var(--admin-border)] bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-stone-50">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Product</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Type</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Price</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Status</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border)]">
|
||||
{products.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-16 text-center">
|
||||
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
|
||||
{Icons.package("h-8 w-8 text-stone-400")}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-stone-600">No products found</p>
|
||||
<p className="text-xs text-stone-400 mt-1">Try adjusting your filters</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
products.map((product) => (
|
||||
<tr key={product.id} className="hover:bg-stone-50 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{product.image_url ? (
|
||||
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-lg border border-[var(--admin-border)]">
|
||||
<Image
|
||||
src={product.image_url}
|
||||
alt={product.name}
|
||||
fill
|
||||
style={{ objectFit: "cover" }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded-lg bg-stone-100 flex items-center justify-center shrink-0 border border-[var(--admin-border)]">
|
||||
<span className="text-stone-400">□</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => onEdit(product)}
|
||||
className="font-semibold text-[var(--admin-text-primary)] hover:text-emerald-600 transition-colors text-left"
|
||||
>
|
||||
{product.name}
|
||||
</button>
|
||||
<div className="text-xs text-stone-500 line-clamp-1">
|
||||
{product.description || <span className="italic text-stone-400">No description</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3">
|
||||
<span className="rounded-md bg-stone-100 px-2 py-0.5 text-[10px] font-semibold uppercase text-stone-600">
|
||||
{product.type}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3 font-mono text-sm font-semibold text-[var(--admin-text-primary)]">
|
||||
${Number(product.price).toFixed(2)}
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[10px] font-semibold ${
|
||||
product.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"
|
||||
}`}>
|
||||
{product.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="relative inline-flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => onEdit(product)}
|
||||
className="rounded-lg px-3 py-1.5 text-xs font-semibold text-stone-600 hover:bg-stone-100 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(product.id)}
|
||||
className="rounded-lg px-2 py-1.5 text-xs text-stone-400 hover:text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
|
||||
{deleteConfirm === product.id && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-30" onClick={onDeleteCancel} />
|
||||
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4">
|
||||
<p className="text-sm font-semibold text-stone-900">
|
||||
Delete "{product.name}"?
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-stone-500">
|
||||
This will remove the product. If attached to orders, it will be hidden.
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={onDeleteCancel}
|
||||
className="flex-1 rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2 text-xs font-semibold text-stone-700 hover:bg-stone-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteConfirm(product.id)}
|
||||
disabled={deletingId === product.id}
|
||||
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-bold text-white disabled:opacity-50 hover:bg-red-500"
|
||||
>
|
||||
{deletingId === product.id ? "..." : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Card View ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function CardView({
|
||||
products,
|
||||
onEdit,
|
||||
onDelete,
|
||||
deleteConfirm,
|
||||
onDeleteConfirm,
|
||||
onDeleteCancel,
|
||||
deletingId,
|
||||
}: {
|
||||
products: Product[];
|
||||
onEdit: (p: Product) => void;
|
||||
onDelete: (id: string) => void;
|
||||
deleteConfirm: string | null;
|
||||
onDeleteConfirm: (id: string) => void;
|
||||
onDeleteCancel: () => void;
|
||||
deletingId: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{products.length === 0 ? (
|
||||
<div className="col-span-full text-center py-16 rounded-xl border border-[var(--admin-border)] bg-white">
|
||||
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
|
||||
{Icons.package("h-8 w-8 text-stone-400")}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-stone-600">No products found</p>
|
||||
<p className="text-xs text-stone-400 mt-1">Try adjusting your filters</p>
|
||||
</div>
|
||||
) : (
|
||||
products.map((product) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className="group relative rounded-xl border border-[var(--admin-border)] bg-white hover:shadow-md hover:border-emerald-200 transition-all overflow-hidden"
|
||||
>
|
||||
{/* Image */}
|
||||
<div className="relative h-40 bg-stone-100">
|
||||
{product.image_url ? (
|
||||
<Image
|
||||
src={product.image_url}
|
||||
alt={product.name}
|
||||
fill
|
||||
style={{ objectFit: "cover" }}
|
||||
className="transition-transform group-hover:scale-105"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-4xl text-stone-300">□</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Status badge */}
|
||||
<div className="absolute top-3 right-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px] font-semibold shadow-sm ${
|
||||
product.active ? "bg-emerald-500 text-white" : "bg-stone-500 text-white"
|
||||
}`}>
|
||||
{product.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
<button
|
||||
onClick={() => onEdit(product)}
|
||||
className="block w-full text-left"
|
||||
>
|
||||
<h3 className="font-semibold text-[var(--admin-text-primary)] group-hover:text-emerald-600 transition-colors line-clamp-1">
|
||||
{product.name}
|
||||
</h3>
|
||||
<p className="text-xs text-stone-500 mt-1 line-clamp-2 h-8">
|
||||
{product.description || <span className="italic">No description</span>}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center justify-between mt-4 pt-3 border-t border-stone-100">
|
||||
<div>
|
||||
<span className="text-xs text-stone-500">Price</span>
|
||||
<p className="text-lg font-bold text-[var(--admin-text-primary)] font-mono">
|
||||
${Number(product.price).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="rounded-md bg-stone-100 px-2 py-0.5 text-[10px] font-semibold uppercase text-stone-600">
|
||||
{product.type}
|
||||
</span>
|
||||
{product.is_taxable && (
|
||||
<span className="rounded-md bg-amber-50 px-2 py-0.5 text-[10px] font-semibold text-amber-700 border border-amber-200">
|
||||
Tax
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<button
|
||||
onClick={() => onEdit(product)}
|
||||
className="flex-1 rounded-lg bg-emerald-50 border border-emerald-200 px-3 py-2 text-xs font-semibold text-emerald-700 hover:bg-emerald-100 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(product.id)}
|
||||
className="rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-stone-500 hover:bg-red-50 hover:text-red-600 hover:border-red-200 transition-colors"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete confirm */}
|
||||
{deleteConfirm === product.id && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-xl shadow-xl p-4 max-w-[280px] mx-4">
|
||||
<p className="text-sm font-semibold text-stone-900">
|
||||
Delete "{product.name}"?
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-stone-500">
|
||||
This will remove the product. If attached to orders, it will be hidden.
|
||||
</p>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
onClick={onDeleteCancel}
|
||||
className="flex-1 rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-stone-700 hover:bg-stone-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteConfirm(product.id)}
|
||||
disabled={deletingId === product.id}
|
||||
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-bold text-white disabled:opacity-50 hover:bg-red-500"
|
||||
>
|
||||
{deletingId === product.id ? "..." : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { createStopsBatch } from "@/actions/stops";
|
||||
import GlassModal from "@/components/admin/GlassModal";
|
||||
import type { ParsedStopRow } from "@/lib/csv-parsers";
|
||||
|
||||
type ParsedStop = Omit<ParsedStopRow, "_rowIndex" | "_warnings">;
|
||||
@@ -22,29 +23,8 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [useAI, setUseAI] = useState(false);
|
||||
const [created, setCreated] = useState(0);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Animate in
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => setIsVisible(true));
|
||||
}, []);
|
||||
|
||||
// Lock body scroll
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, []);
|
||||
|
||||
// Escape key handler
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [onClose]);
|
||||
|
||||
const processFile = useCallback(async (file: File) => {
|
||||
setError(null);
|
||||
setStep("parsing");
|
||||
@@ -146,283 +126,259 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
|
||||
|
||||
const hasWarnings = warnings.length > 0;
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 z-50 flex items-center justify-center p-4 transition-all duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
onClick={handleBackdropClick}
|
||||
<GlassModal
|
||||
title="📥 Import Schedule"
|
||||
subtitle="Upload a CSV to bulk-create stops as drafts"
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-md" />
|
||||
|
||||
<div
|
||||
className={`relative w-full max-w-2xl max-h-[90vh] overflow-hidden rounded-2xl bg-white shadow-2xl border border-stone-200 transition-all duration-300 ${isVisible ? "scale-100 opacity-100" : "scale-95 opacity-0"}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-stone-100 bg-gradient-to-r from-emerald-600 to-emerald-500 px-6 py-5">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Import Schedule</h2>
|
||||
<p className="mt-0.5 text-sm text-white/80">
|
||||
Upload a CSV to bulk-create stops as drafts.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">
|
||||
{step === "idle" && (
|
||||
<div className="space-y-5">
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI toggle */}
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl border border-stone-200 bg-stone-50">
|
||||
<button
|
||||
onClick={() => setUseAI((v) => !v)}
|
||||
className={`flex items-center gap-2.5 rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
|
||||
useAI
|
||||
? "bg-violet-100 border border-violet-300 text-violet-700"
|
||||
: "bg-white border border-stone-200 text-stone-600 hover:border-stone-300"
|
||||
}`}
|
||||
>
|
||||
<span className={`flex h-5 w-5 items-center justify-center rounded-full text-xs ${useAI ? "bg-violet-500 text-white" : "bg-stone-200 text-stone-500"}`}>
|
||||
{useAI ? "✓" : "○"}
|
||||
</span>
|
||||
Use AI for text/PDF parsing
|
||||
</button>
|
||||
<span className="text-xs text-stone-500">
|
||||
{useAI
|
||||
? "AI will parse unstructured text. Requires OPENAI_API_KEY."
|
||||
: "Best results with CSV. Text/PDF uses AI if enabled."}
|
||||
</span>
|
||||
<div className="space-y-5">
|
||||
{step === "idle" && (
|
||||
<>
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`cursor-pointer rounded-2xl border-2 border-dashed p-12 text-center transition-all ${
|
||||
dragOver
|
||||
? "border-emerald-500 bg-emerald-50"
|
||||
: "border-stone-300 hover:border-emerald-400 hover:bg-stone-50"
|
||||
{/* AI toggle */}
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl border border-stone-200 bg-stone-50">
|
||||
<button
|
||||
onClick={() => setUseAI((v) => !v)}
|
||||
className={`flex items-center gap-2.5 rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
|
||||
useAI
|
||||
? "bg-violet-100 border border-violet-300 text-violet-700"
|
||||
: "bg-white border border-stone-200 text-stone-600 hover:border-stone-300"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className={`flex h-16 w-16 items-center justify-center rounded-2xl ${dragOver ? "bg-emerald-100" : "bg-stone-100"}`}>
|
||||
<svg className={`h-8 w-8 ${dragOver ? "text-emerald-600" : "text-stone-400"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.801 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-stone-700">
|
||||
Drop your schedule file here
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-stone-500">
|
||||
or click to browse — CSV, TXT, JSON
|
||||
</p>
|
||||
<p className="mt-4 text-xs text-stone-400 font-mono bg-stone-100 rounded-lg px-3 py-2 inline-block">
|
||||
CSV columns: city, state, location, date, time, address, zip, notes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.txt,.json"
|
||||
className="hidden"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<span className={`flex h-5 w-5 items-center justify-center rounded-full text-xs ${useAI ? "bg-violet-500 text-white" : "bg-stone-200 text-stone-500"}`}>
|
||||
{useAI ? "✓" : "○"}
|
||||
</span>
|
||||
Use AI for text/PDF parsing
|
||||
</button>
|
||||
<span className="text-xs text-stone-500">
|
||||
{useAI ? "AI will parse unstructured text. Requires OPENAI_API_KEY." : "Best results with CSV."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "parsing" && (
|
||||
<div className="flex flex-col items-center gap-4 py-12">
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-3 border-emerald-200 border-t-emerald-600" />
|
||||
<p className="text-stone-600 font-medium">Parsing file…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "review" && (
|
||||
<div className="space-y-5">
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`cursor-pointer rounded-xl border-2 border-dashed p-8 text-center transition-all ${
|
||||
dragOver
|
||||
? "border-emerald-500 bg-emerald-50"
|
||||
: "border-stone-300 hover:border-emerald-400 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-center mb-3">
|
||||
<div className={`flex h-12 w-12 items-center justify-center rounded-xl ${dragOver ? "bg-emerald-100" : "bg-stone-100"}`}>
|
||||
<svg className={`h-6 w-6 ${dragOver ? "text-emerald-600" : "text-stone-400"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.801 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasWarnings && (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm">
|
||||
<p className="font-semibold text-amber-800">Parsing warnings:</p>
|
||||
<ul className="mt-1 list-disc list-inside space-y-0.5 text-amber-700">
|
||||
{warnings.slice(0, 5).map((w, i) => (
|
||||
<li key={i}>{w}</li>
|
||||
))}
|
||||
{warnings.length > 5 && <li>…and {warnings.length - 5} more</li>}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-stone-700">
|
||||
{parsedStops.length} stop{parsedStops.length !== 1 ? "s" : ""} found — review and edit below
|
||||
</p>
|
||||
<span className="text-xs text-stone-500">All will be created as drafts</span>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-stone-700">Drop your schedule file here</p>
|
||||
<p className="mt-1 text-xs text-stone-500">or click to browse — CSV, TXT, JSON</p>
|
||||
<p className="mt-3 text-[10px] text-stone-400 font-mono bg-stone-100 rounded-lg px-3 py-1.5 inline-block">
|
||||
CSV: city, state, location, date, time, address, zip, notes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Review table */}
|
||||
<div className="max-h-72 overflow-y-auto rounded-xl border border-stone-200 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-stone-50 border-b border-stone-200">
|
||||
<tr>
|
||||
<th className="px-3 py-3 text-left text-xs font-semibold uppercase tracking-wide text-stone-500">City</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-semibold uppercase tracking-wide text-stone-500">State</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-semibold uppercase tracking-wide text-stone-500">Location</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-semibold uppercase tracking-wide text-stone-500">Date</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-semibold uppercase tracking-wide text-stone-500">Time</th>
|
||||
<th className="w-10 px-3 py-3" />
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.txt,.json"
|
||||
className="hidden"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "parsing" && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="h-5 w-5 animate-spin text-emerald-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-stone-600">Parsing file…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "review" && (
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasWarnings && (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm">
|
||||
<p className="font-semibold text-amber-800">Parsing warnings:</p>
|
||||
<ul className="mt-1 list-disc list-inside space-y-0.5 text-amber-700">
|
||||
{warnings.slice(0, 5).map((w, i) => (
|
||||
<li key={i}>{w}</li>
|
||||
))}
|
||||
{warnings.length > 5 && <li>…and {warnings.length - 5} more</li>}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-stone-700">
|
||||
{parsedStops.length} stop{parsedStops.length !== 1 ? "s" : ""} found — review and edit
|
||||
</p>
|
||||
<span className="text-xs text-stone-400">All will be created as drafts</span>
|
||||
</div>
|
||||
|
||||
{/* Review table */}
|
||||
<div className="max-h-64 overflow-y-auto rounded-xl border border-stone-200 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-stone-50 border-b border-stone-100">
|
||||
<tr>
|
||||
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">City</th>
|
||||
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">State</th>
|
||||
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">Location</th>
|
||||
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">Date</th>
|
||||
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">Time</th>
|
||||
<th className="w-8 px-3 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-50">
|
||||
{parsedStops.map((stop, idx) => (
|
||||
<tr key={idx} className="hover:bg-stone-50/50 transition-colors">
|
||||
<td className="px-2 py-1.5">
|
||||
<input
|
||||
value={stop.city}
|
||||
onChange={(e) => updateStop(idx, "city", e.target.value)}
|
||||
className="w-full rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<input
|
||||
value={stop.state}
|
||||
onChange={(e) => updateStop(idx, "state", e.target.value)}
|
||||
className="w-12 rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<input
|
||||
value={stop.location}
|
||||
onChange={(e) => updateStop(idx, "location", e.target.value)}
|
||||
className="w-full rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<input
|
||||
value={stop.date}
|
||||
onChange={(e) => updateStop(idx, "date", e.target.value)}
|
||||
className="w-24 rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<input
|
||||
value={stop.time}
|
||||
onChange={(e) => updateStop(idx, "time", e.target.value)}
|
||||
className="w-16 rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<button
|
||||
onClick={() => removeStop(idx)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-lg text-stone-400 hover:bg-red-50 hover:text-red-600 transition-colors"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-100">
|
||||
{parsedStops.map((stop, idx) => (
|
||||
<tr key={idx} className="hover:bg-stone-50 transition-colors">
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
value={stop.city}
|
||||
onChange={(e) => updateStop(idx, "city", e.target.value)}
|
||||
className="w-full rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-sm text-stone-900 outline-none focus:border-emerald-400 focus:bg-white"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
value={stop.state}
|
||||
onChange={(e) => updateStop(idx, "state", e.target.value)}
|
||||
className="w-14 rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-sm text-stone-900 outline-none focus:border-emerald-400 focus:bg-white"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
value={stop.location}
|
||||
onChange={(e) => updateStop(idx, "location", e.target.value)}
|
||||
className="w-full rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-sm text-stone-900 outline-none focus:border-emerald-400 focus:bg-white"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
value={stop.date}
|
||||
onChange={(e) => updateStop(idx, "date", e.target.value)}
|
||||
className="w-28 rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-sm text-stone-900 outline-none focus:border-emerald-400 focus:bg-white"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
value={stop.time}
|
||||
onChange={(e) => updateStop(idx, "time", e.target.value)}
|
||||
className="w-20 rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-sm text-stone-900 outline-none focus:border-emerald-400 focus:bg-white"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<button
|
||||
onClick={() => removeStop(idx)}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg text-stone-400 hover:bg-red-50 hover:text-red-600 transition-colors"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<p className="text-sm text-stone-500">
|
||||
{parsedStops.length} draft stop{parsedStops.length !== 1 ? "s" : ""} will be created
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => { setStep("idle"); setParsedStops([]); setWarnings([]); }}
|
||||
className="rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm font-medium text-stone-600 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={parsedStops.length === 0}
|
||||
className="rounded-xl bg-emerald-600 hover:bg-emerald-500 active:bg-emerald-700 px-5 py-2.5 text-sm font-semibold text-white disabled:opacity-50 transition-colors shadow-sm shadow-emerald-200"
|
||||
>
|
||||
Create {parsedStops.length} Draft Stop{parsedStops.length !== 1 ? "s" : ""}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2 border-t border-stone-100">
|
||||
<p className="text-xs text-stone-500">
|
||||
{parsedStops.length} draft stop{parsedStops.length !== 1 ? "s" : ""} will be created
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setStep("idle"); setParsedStops([]); setWarnings([]); }}
|
||||
className="rounded-xl border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={parsedStops.length === 0}
|
||||
className="rounded-xl bg-emerald-600 hover:bg-emerald-700 px-5 py-2 text-sm font-bold text-white disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Create {parsedStops.length} Stop{parsedStops.length !== 1 ? "s" : ""}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "importing" && (
|
||||
<div className="flex flex-col items-center gap-4 py-12">
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-3 border-emerald-200 border-t-emerald-600" />
|
||||
<p className="text-stone-600 font-medium">Creating draft stops…</p>
|
||||
{step === "importing" && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="h-5 w-5 animate-spin text-emerald-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-stone-600">Creating draft stops…</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "done" && (
|
||||
<div className="flex flex-col items-center gap-5 py-12 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100">
|
||||
<svg className="h-8 w-8 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-stone-950">
|
||||
{created} draft stop{created !== 1 ? "s" : ""} created
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-stone-500">
|
||||
Review them in the stops list and publish when ready.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-xl bg-emerald-600 hover:bg-emerald-500 px-6 py-3 text-sm font-semibold text-white transition-colors shadow-sm shadow-emerald-200"
|
||||
>
|
||||
Back to Stops
|
||||
</button>
|
||||
{step === "done" && (
|
||||
<div className="flex flex-col items-center gap-4 py-8 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-green-50">
|
||||
<svg className="h-7 w-7 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xl font-bold text-stone-950">
|
||||
{created} draft stop{created !== 1 ? "s" : ""} created
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-stone-500">
|
||||
Review them in the stops list and publish when ready.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-xl bg-emerald-600 hover:bg-emerald-700 px-5 py-2.5 text-sm font-bold text-white transition-colors"
|
||||
>
|
||||
Back to Stops
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "error" && (
|
||||
<div className="flex flex-col items-center gap-4 py-12 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
|
||||
<svg className="h-8 w-8 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-red-700 font-medium">{error}</p>
|
||||
<button
|
||||
onClick={() => setStep("idle")}
|
||||
className="rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm font-medium text-stone-600 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
{step === "error" && (
|
||||
<div className="flex flex-col items-center gap-4 py-8 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-red-50">
|
||||
<svg className="h-7 w-7 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-red-700">{error}</p>
|
||||
<button
|
||||
onClick={() => setStep("idle")}
|
||||
className="rounded-xl border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GlassModal>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import SettingsSections from "@/components/admin/SettingsSections";
|
||||
import UsersPage from "@/components/admin/UsersPage";
|
||||
import IntegrationsInner from "@/components/admin/IntegrationsInner";
|
||||
import TimeTrackingSettingsClient from "@/components/admin/TimeTrackingSettingsClient";
|
||||
|
||||
type Tab = "general" | "users" | "integrations";
|
||||
type Tab = "general" | "workers" | "tasks" | "users";
|
||||
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: "general", label: "General" },
|
||||
{ id: "users", label: "Users" },
|
||||
{ id: "integrations", label: "Integrations" },
|
||||
const TABS: { id: Tab; label: string; icon: string; hash: string }[] = [
|
||||
{ id: "general", label: "General", icon: "settings", hash: "general" },
|
||||
{ id: "workers", label: "Workers", icon: "users", hash: "workers" },
|
||||
{ id: "tasks", label: "Tasks", icon: "list", hash: "tasks" },
|
||||
{ id: "users", label: "Users", icon: "user-check", hash: "users" },
|
||||
];
|
||||
|
||||
// Icon components
|
||||
const Icons = {
|
||||
settings: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
),
|
||||
users: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
|
||||
</svg>
|
||||
),
|
||||
"user-check": (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/>
|
||||
</svg>
|
||||
),
|
||||
plug: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/>
|
||||
</svg>
|
||||
),
|
||||
list: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
type Brand = { id: string; name: string };
|
||||
|
||||
type Props = {
|
||||
@@ -35,56 +67,116 @@ export default function SettingsClient({
|
||||
}: Props) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("general");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50 px-6 py-10">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-3">
|
||||
<a href="/admin" className="hover:text-stone-600 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-600">Settings</span>
|
||||
</nav>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-stone-950 tracking-tight">Settings</h1>
|
||||
<p className="mt-1.5 text-sm text-stone-500">Manage your brand, workers, tasks, users, and integrations.</p>
|
||||
</div>
|
||||
</div>
|
||||
// Handle URL hash for sidebar navigation
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
const matchingTab = TABS.find(t => t.hash === hash);
|
||||
if (matchingTab) {
|
||||
setActiveTab(matchingTab.id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
{/* Tab nav */}
|
||||
<div className="border-b border-stone-200 mt-6">
|
||||
<nav className="flex gap-1 -mb-px">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-5 py-3 text-sm font-medium border-b-2 transition-colors rounded-t-lg ${
|
||||
activeTab === tab.id
|
||||
? "border-emerald-500 text-emerald-700 bg-white"
|
||||
: "border-transparent text-stone-500 hover:text-stone-700 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||
{/* Header */}
|
||||
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-xl bg-emerald-600">
|
||||
{Icons.settings("h-5 w-5 sm:h-6 sm:w-6 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-black text-[var(--admin-text-primary)] tracking-tight">Settings</h1>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Manage your brand, workers, and integrations</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{/* Tab navigation */}
|
||||
<nav className="grid grid-cols-5 gap-1 p-1.5 rounded-xl bg-white border border-stone-200">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`relative flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 rounded-lg px-1 sm:px-3 py-2 text-[9px] sm:text-xs font-semibold transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "bg-emerald-600 text-white"
|
||||
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{tab.icon === "settings" && Icons.settings("h-4 w-4")}
|
||||
{tab.icon === "users" && Icons.users("h-4 w-4")}
|
||||
{tab.icon === "user-check" && Icons["user-check"]("h-4 w-4")}
|
||||
{tab.icon === "plug" && Icons.plug("h-4 w-4")}
|
||||
{tab.icon === "list" && Icons.list("h-4 w-4")}
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
<span className="sm:hidden">{tab.label.substring(0, 3)}</span>
|
||||
{activeTab === tab.id && (
|
||||
<div className="absolute bottom-1 sm:bottom-1.5 left-1/2 -translate-x-1/2 h-0.5 w-6 sm:w-8 bg-white rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
||||
{activeTab === "general" && (
|
||||
<div className="rounded-2xl bg-white border border-stone-200 shadow-sm p-6">
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<SettingsSections brandId={brandId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "users" && (
|
||||
<div className="rounded-2xl bg-white border border-stone-200 shadow-sm overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-stone-100 bg-stone-50">
|
||||
<h2 className="text-lg font-bold text-stone-950">Users & Permissions</h2>
|
||||
{activeTab === "workers" && (
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<div className="p-4 sm:p-6 border-b border-[var(--admin-border)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-600">
|
||||
{Icons.users("w-4 h-4 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm sm:text-lg font-bold text-[var(--admin-text-primary)]">Workers & PINs</h2>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Manage time tracking workers and PIN codes</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
<SettingsSections brandId={brandId} workersOnly />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "tasks" && (
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<div className="p-4 sm:p-6 border-b border-[var(--admin-border)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500">
|
||||
{Icons.list("w-4 h-4 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm sm:text-lg font-bold text-[var(--admin-text-primary)]">Tasks</h2>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Define tasks workers can clock into</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 sm:p-6">
|
||||
<SettingsSections brandId={brandId} tasksOnly />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "users" && (
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<div className="p-4 sm:p-6 border-b border-[var(--admin-border)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-text-primary)]">
|
||||
{Icons["user-check"]("w-4 h-4 text-[var(--admin-bg)]")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm sm:text-lg font-bold text-[var(--admin-text-primary)]">Users & Permissions</h2>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Manage team access and roles</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 sm:p-6">
|
||||
<UsersPage
|
||||
initialUsers={users}
|
||||
brands={brands}
|
||||
@@ -98,30 +190,27 @@ export default function SettingsClient({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "integrations" && (
|
||||
<div className="rounded-2xl bg-white border border-stone-200 shadow-sm overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-stone-100 bg-stone-50">
|
||||
<h2 className="text-lg font-bold text-stone-950">Integrations & Exports</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
<IntegrationsInner brandId={brandId} brands={brands} />
|
||||
|
||||
{/* Time Tracking Exports */}
|
||||
<div className="border-t border-stone-200 pt-6">
|
||||
<h3 className="text-base font-semibold text-stone-800 mb-4">Time Tracking Exports</h3>
|
||||
<TimeTrackingSettingsClient brandId={brandId} />
|
||||
<div className="mt-6 p-4 rounded-xl border border-violet-100 bg-violet-50/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-100">
|
||||
<svg className="w-4 h-4 text-violet-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" 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>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-stone-900">Advanced Settings</p>
|
||||
<p className="text-xs text-stone-500">AI, integrations, Square sync, shipping, and webhooks</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-stone-500">
|
||||
For more settings, see{" "}
|
||||
<a href="/admin/settings/ai" className="text-emerald-600 hover:text-emerald-700 underline underline-offset-2">AI Tools</a>
|
||||
{" "}and{" "}
|
||||
<a href="/admin/settings/shipping" className="text-emerald-600 hover:text-emerald-700 underline underline-offset-2">Shipping</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/admin/advanced"
|
||||
className="px-4 py-2 rounded-lg bg-violet-600 text-white text-sm font-semibold hover:bg-violet-500 transition-colors"
|
||||
>
|
||||
Open Advanced →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -75,9 +75,11 @@ function Accordion({ title, id, description, defaultOpen = false, accentColor =
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
workersOnly?: boolean;
|
||||
tasksOnly?: boolean;
|
||||
};
|
||||
|
||||
export default function SettingsSections({ brandId }: Props) {
|
||||
export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Props) {
|
||||
const [workers, setWorkers] = useState<TimeWorker[]>([]);
|
||||
const [tasks, setTasks] = useState<TimeTask[]>([]);
|
||||
const [settings, setSettings] = useState<TimeTrackingSettings | null>(null);
|
||||
@@ -292,261 +294,268 @@ export default function SettingsSections({ brandId }: Props) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{(!workersOnly && !tasksOnly) && (
|
||||
<>
|
||||
{/* ACCORDION 1: General Settings */}
|
||||
<Accordion
|
||||
id="general"
|
||||
title="General Settings"
|
||||
description="Pay period, overtime, alerts"
|
||||
defaultOpen={true}
|
||||
accentColor="stone"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* Pay Period & Overtime */}
|
||||
<div className="bg-stone-50 border border-stone-200 rounded-xl p-5">
|
||||
<h3 className="text-sm font-semibold text-stone-800 mb-4">Pay Period & Overtime</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Work week starts on</label>
|
||||
<select value={payPeriodStartDay} onChange={e => setPayPeriodStartDay(Number(e.target.value))}
|
||||
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
|
||||
{DAYS.map((d, i) => <option key={i} value={i}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Pay period length</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="number" min={1} max={31} value={payPeriodLength}
|
||||
onChange={e => setPayPeriodLength(Number(e.target.value))}
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
|
||||
<span className="text-sm text-stone-500">days</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Daily overtime threshold</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="number" min={1} max={24} value={dailyOvertimeThreshold}
|
||||
onChange={e => setDailyOvertimeThreshold(Number(e.target.value))}
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
|
||||
<span className="text-sm text-stone-500">hrs</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Weekly overtime threshold</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="number" min={1} max={80} value={weeklyOvertimeThreshold}
|
||||
onChange={e => setWeeklyOvertimeThreshold(Number(e.target.value))}
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
|
||||
<span className="text-sm text-stone-500">hrs</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ACCORDION 1: General Settings */}
|
||||
<Accordion
|
||||
id="general"
|
||||
title="General Settings"
|
||||
description="Pay period, overtime, alerts"
|
||||
defaultOpen={true}
|
||||
accentColor="stone"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* Pay Period & Overtime */}
|
||||
<div className="bg-stone-50 border border-stone-200 rounded-xl p-5">
|
||||
<h3 className="text-sm font-semibold text-stone-800 mb-4">Pay Period & Overtime</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Work week starts on</label>
|
||||
<select value={payPeriodStartDay} onChange={e => setPayPeriodStartDay(Number(e.target.value))}
|
||||
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
|
||||
{DAYS.map((d, i) => <option key={i} value={i}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Pay period length</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="number" min={1} max={31} value={payPeriodLength}
|
||||
onChange={e => setPayPeriodLength(Number(e.target.value))}
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
|
||||
<span className="text-sm text-stone-500">days</span>
|
||||
{/* Colorado notice */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-amber-600 text-base mt-0.5">⚠️</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-amber-800">Colorado Overtime Law</p>
|
||||
<p className="text-xs text-amber-700 mt-1">Colorado requires daily overtime (1.5×) after 12 hours in a workday, or weekly overtime after 40 hours in a workweek.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Daily overtime threshold</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="number" min={1} max={24} value={dailyOvertimeThreshold}
|
||||
onChange={e => setDailyOvertimeThreshold(Number(e.target.value))}
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
|
||||
<span className="text-sm text-stone-500">hrs</span>
|
||||
|
||||
{/* Alert Settings */}
|
||||
<div className="bg-stone-50 border border-stone-200 rounded-xl p-5">
|
||||
<h3 className="text-sm font-semibold text-stone-800 mb-4">Alert Settings</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-stone-700 font-medium">Daily overtime alerts</p>
|
||||
<p className="text-xs text-stone-500">Notify when worker hits daily limit</p>
|
||||
</div>
|
||||
<button onClick={() => setEnableDailyAlerts(!enableDailyAlerts)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableDailyAlerts ? "bg-emerald-600" : "bg-stone-300"}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${enableDailyAlerts ? "translate-x-4" : "translate-x-1"}`} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-stone-700 font-medium">Weekly overtime alerts</p>
|
||||
<p className="text-xs text-stone-500">Notify when worker hits weekly limit</p>
|
||||
</div>
|
||||
<button onClick={() => setEnableWeeklyAlerts(!enableWeeklyAlerts)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableWeeklyAlerts ? "bg-emerald-600" : "bg-stone-300"}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${enableWeeklyAlerts ? "translate-x-4" : "translate-x-1"}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Weekly overtime threshold</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="number" min={1} max={80} value={weeklyOvertimeThreshold}
|
||||
onChange={e => setWeeklyOvertimeThreshold(Number(e.target.value))}
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
|
||||
<span className="text-sm text-stone-500">hrs</span>
|
||||
|
||||
{/* Notification Recipients */}
|
||||
<div className="bg-stone-50 border border-stone-200 rounded-xl p-5">
|
||||
<h3 className="text-sm font-semibold text-stone-800 mb-4">Notification Recipients</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Email addresses</label>
|
||||
<div className="flex gap-2">
|
||||
<input value={newEmail}
|
||||
onChange={e => setNewEmail(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && (addEmail(), e.preventDefault())}
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500"
|
||||
placeholder="manager@farm.com" />
|
||||
<button onClick={addEmail} className="px-5 py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm">Add</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{notificationEmails.map(e => (
|
||||
<span key={e} className="inline-flex items-center gap-1.5 bg-stone-100 text-stone-700 text-xs px-2.5 py-1 rounded-lg">
|
||||
{e}
|
||||
<button onClick={() => removeEmail(e)} className="text-stone-400 hover:text-red-500 ml-1">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">SMS numbers</label>
|
||||
<div className="flex gap-2">
|
||||
<input value={newSms}
|
||||
onChange={e => setNewSms(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && (addSms(), e.preventDefault())}
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500"
|
||||
placeholder="+1234567890" />
|
||||
<button onClick={addSms} className="px-5 py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm">Add</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{notificationSmsNumbers.map(n => (
|
||||
<span key={n} className="inline-flex items-center gap-1.5 bg-stone-100 text-stone-700 text-xs px-2.5 py-1 rounded-lg">
|
||||
{n}
|
||||
<button onClick={() => removeSms(n)} className="text-stone-400 hover:text-red-500 ml-1">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settingsError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{settingsError}</div>
|
||||
)}
|
||||
{settingsSaved && (
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-xl py-3 px-4 text-emerald-700 text-sm">Settings saved.</div>
|
||||
)}
|
||||
<button onClick={handleSaveNotifications} disabled={settingsSaving}
|
||||
className="px-6 py-3 rounded-xl bg-stone-900 hover:bg-stone-800 disabled:opacity-40 font-semibold text-sm text-white transition-all">
|
||||
{settingsSaving ? "Saving..." : "Save Settings"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colorado notice */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-amber-600 text-base mt-0.5">⚠️</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-amber-800">Colorado Overtime Law</p>
|
||||
<p className="text-xs text-amber-700 mt-1">Colorado requires daily overtime (1.5×) after 12 hours in a workday, or weekly overtime after 40 hours in a workweek.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alert Settings */}
|
||||
<div className="bg-stone-50 border border-stone-200 rounded-xl p-5">
|
||||
<h3 className="text-sm font-semibold text-stone-800 mb-4">Alert Settings</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-stone-700 font-medium">Daily overtime alerts</p>
|
||||
<p className="text-xs text-stone-500">Notify when worker hits daily limit</p>
|
||||
</div>
|
||||
<button onClick={() => setEnableDailyAlerts(!enableDailyAlerts)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableDailyAlerts ? "bg-emerald-600" : "bg-stone-300"}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${enableDailyAlerts ? "translate-x-4" : "translate-x-1"}`} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-stone-700 font-medium">Weekly overtime alerts</p>
|
||||
<p className="text-xs text-stone-500">Notify when worker hits weekly limit</p>
|
||||
</div>
|
||||
<button onClick={() => setEnableWeeklyAlerts(!enableWeeklyAlerts)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableWeeklyAlerts ? "bg-emerald-600" : "bg-stone-300"}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${enableWeeklyAlerts ? "translate-x-4" : "translate-x-1"}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification Recipients */}
|
||||
<div className="bg-stone-50 border border-stone-200 rounded-xl p-5">
|
||||
<h3 className="text-sm font-semibold text-stone-800 mb-4">Notification Recipients</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Email addresses</label>
|
||||
<div className="flex gap-2">
|
||||
<input value={newEmail}
|
||||
onChange={e => setNewEmail(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && (addEmail(), e.preventDefault())}
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500"
|
||||
placeholder="manager@farm.com" />
|
||||
<button onClick={addEmail} className="px-5 py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm">Add</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{notificationEmails.map(e => (
|
||||
<span key={e} className="inline-flex items-center gap-1.5 bg-stone-100 text-stone-700 text-xs px-2.5 py-1 rounded-lg">
|
||||
{e}
|
||||
<button onClick={() => removeEmail(e)} className="text-stone-400 hover:text-red-500 ml-1">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">SMS numbers</label>
|
||||
<div className="flex gap-2">
|
||||
<input value={newSms}
|
||||
onChange={e => setNewSms(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && (addSms(), e.preventDefault())}
|
||||
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500"
|
||||
placeholder="+1234567890" />
|
||||
<button onClick={addSms} className="px-5 py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm">Add</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{notificationSmsNumbers.map(n => (
|
||||
<span key={n} className="inline-flex items-center gap-1.5 bg-stone-100 text-stone-700 text-xs px-2.5 py-1 rounded-lg">
|
||||
{n}
|
||||
<button onClick={() => removeSms(n)} className="text-stone-400 hover:text-red-500 ml-1">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settingsError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{settingsError}</div>
|
||||
)}
|
||||
{settingsSaved && (
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-xl py-3 px-4 text-emerald-700 text-sm">Settings saved.</div>
|
||||
)}
|
||||
<button onClick={handleSaveNotifications} disabled={settingsSaving}
|
||||
className="px-6 py-3 rounded-xl bg-stone-900 hover:bg-stone-800 disabled:opacity-40 font-semibold text-sm text-white transition-all">
|
||||
{settingsSaving ? "Saving..." : "Save Settings"}
|
||||
</button>
|
||||
</div>
|
||||
</Accordion>
|
||||
</Accordion>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ACCORDION 2: Workers & PINs */}
|
||||
<Accordion
|
||||
id="workers"
|
||||
title="Workers & PINs"
|
||||
description={`${workers.length} worker${workers.length !== 1 ? "s" : ""}`}
|
||||
defaultOpen={true}
|
||||
accentColor="emerald"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-xs text-stone-500">Manage time tracking workers and PIN codes.</p>
|
||||
<button onClick={openAddWorker}
|
||||
className="text-xs bg-emerald-600 hover:bg-emerald-500 text-white px-3 py-1.5 rounded-lg font-semibold transition-all">+ Add Worker</button>
|
||||
</div>
|
||||
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
|
||||
<th className="text-left px-4 py-3 font-medium">Name</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Role</th>
|
||||
<th className="text-left px-4 py-3 font-medium hidden sm:table-cell">Lang</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Status</th>
|
||||
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Last Used</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{workers.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-4 py-10 text-center text-stone-400 text-sm">No workers yet — add one to get started.</td></tr>
|
||||
) : workers.map(w => (
|
||||
<tr key={w.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
|
||||
<td className="px-4 py-3.5 text-stone-900 font-medium">{w.name}</td>
|
||||
<td className="px-4 py-3.5 text-stone-500 capitalize text-xs">{w.role}</td>
|
||||
<td className="px-4 py-3.5 text-stone-400 uppercase text-xs font-mono hidden sm:table-cell">{w.lang}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${w.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
|
||||
{w.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-stone-400 text-xs hidden md:table-cell">{w.last_used_at ? new Date(w.last_used_at).toLocaleDateString() : "—"}</td>
|
||||
<td className="px-4 py-3.5 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => openEditWorker(w)} className="text-xs text-stone-500 hover:text-stone-900 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">Edit</button>
|
||||
<button onClick={() => handleResetPin(w.id)} className="text-xs text-amber-600 hover:text-amber-800 px-2 py-1 rounded-lg hover:bg-amber-50 transition-all">Reset PIN</button>
|
||||
<button onClick={() => handleDeleteWorker(w.id)} className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded-lg hover:bg-red-50 transition-all">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
{(workersOnly || (!workersOnly && !tasksOnly)) && (
|
||||
<Accordion
|
||||
id="workers"
|
||||
title="Workers & PINs"
|
||||
description={`${workers.length} worker${workers.length !== 1 ? "s" : ""}`}
|
||||
defaultOpen={workersOnly}
|
||||
accentColor="emerald"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-xs text-stone-500">Manage time tracking workers and PIN codes.</p>
|
||||
<button onClick={openAddWorker}
|
||||
className="text-xs bg-emerald-600 hover:bg-emerald-500 text-white px-3 py-1.5 rounded-lg font-semibold transition-all">+ Add Worker</button>
|
||||
</div>
|
||||
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
|
||||
<th className="text-left px-4 py-3 font-medium">Name</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Role</th>
|
||||
<th className="text-left px-4 py-3 font-medium hidden sm:table-cell">Lang</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Status</th>
|
||||
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Last Used</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{workers.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-4 py-10 text-center text-stone-400 text-sm">No workers yet — add one to get started.</td></tr>
|
||||
) : workers.map(w => (
|
||||
<tr key={w.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
|
||||
<td className="px-4 py-3.5 text-stone-900 font-medium">{w.name}</td>
|
||||
<td className="px-4 py-3.5 text-stone-500 capitalize text-xs">{w.role}</td>
|
||||
<td className="px-4 py-3.5 text-stone-400 uppercase text-xs font-mono hidden sm:table-cell">{w.lang}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${w.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
|
||||
{w.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-stone-400 text-xs hidden md:table-cell">{w.last_used_at ? new Date(w.last_used_at).toLocaleDateString() : "—"}</td>
|
||||
<td className="px-4 py-3.5 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => openEditWorker(w)} className="text-xs text-stone-500 hover:text-stone-900 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">Edit</button>
|
||||
<button onClick={() => handleResetPin(w.id)} className="text-xs text-amber-600 hover:text-amber-800 px-2 py-1 rounded-lg hover:bg-amber-50 transition-all">Reset PIN</button>
|
||||
<button onClick={() => handleDeleteWorker(w.id)} className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded-lg hover:bg-red-50 transition-all">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
{/* ACCORDION 3: Tasks */}
|
||||
<Accordion
|
||||
id="tasks"
|
||||
title="Tasks"
|
||||
description={`${tasks.length} task${tasks.length !== 1 ? "s" : ""}`}
|
||||
defaultOpen={false}
|
||||
accentColor="amber"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-xs text-stone-500">Define tasks workers can clock into.</p>
|
||||
<button onClick={openAddTask}
|
||||
className="text-xs bg-amber-500 hover:bg-amber-600 text-white px-3 py-1.5 rounded-lg font-semibold transition-all">+ Add Task</button>
|
||||
</div>
|
||||
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
|
||||
<th className="text-left px-4 py-3 font-medium">Name (EN)</th>
|
||||
<th className="text-left px-4 py-3 font-medium hidden sm:table-cell">Name (ES)</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Unit</th>
|
||||
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Sort</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Status</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-4 py-10 text-center text-stone-400 text-sm">No tasks yet — add one to get started.</td></tr>
|
||||
) : tasks.map(t => (
|
||||
<tr key={t.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
|
||||
<td className="px-4 py-3.5 text-stone-900 font-medium">{t.name}</td>
|
||||
<td className="px-4 py-3.5 text-stone-500 text-xs hidden sm:table-cell">{t.name_es ?? "—"}</td>
|
||||
<td className="px-4 py-3.5 text-stone-400 text-xs font-mono">{t.unit}</td>
|
||||
<td className="px-4 py-3.5 text-stone-400 text-xs hidden md:table-cell">{t.sort_order}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${t.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
|
||||
{t.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => openEditTask(t)} className="text-xs text-stone-500 hover:text-stone-900 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">Edit</button>
|
||||
<button onClick={() => handleDeleteTask(t.id)} className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded-lg hover:bg-red-50 transition-all">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
{(tasksOnly || (!workersOnly && !tasksOnly)) && (
|
||||
<Accordion
|
||||
id="tasks"
|
||||
title="Tasks"
|
||||
description={`${tasks.length} task${tasks.length !== 1 ? "s" : ""}`}
|
||||
defaultOpen={tasksOnly}
|
||||
accentColor="amber"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-xs text-stone-500">Define tasks workers can clock into.</p>
|
||||
<button onClick={openAddTask}
|
||||
className="text-xs bg-amber-500 hover:bg-amber-600 text-white px-3 py-1.5 rounded-lg font-semibold transition-all">+ Add Task</button>
|
||||
</div>
|
||||
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
|
||||
<th className="text-left px-4 py-3 font-medium">Name (EN)</th>
|
||||
<th className="text-left px-4 py-3 font-medium hidden sm:table-cell">Name (ES)</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Unit</th>
|
||||
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Sort</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Status</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-4 py-10 text-center text-stone-400 text-sm">No tasks yet — add one to get started.</td></tr>
|
||||
) : tasks.map(t => (
|
||||
<tr key={t.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
|
||||
<td className="px-4 py-3.5 text-stone-900 font-medium">{t.name}</td>
|
||||
<td className="px-4 py-3.5 text-stone-500 text-xs hidden sm:table-cell">{t.name_es ?? "—"}</td>
|
||||
<td className="px-4 py-3.5 text-stone-400 text-xs font-mono">{t.unit}</td>
|
||||
<td className="px-4 py-3.5 text-stone-400 text-xs hidden md:table-cell">{t.sort_order}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${t.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
|
||||
{t.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => openEditTask(t)} className="text-xs text-stone-500 hover:text-stone-900 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">Edit</button>
|
||||
<button onClick={() => handleDeleteTask(t.id)} className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded-lg hover:bg-red-50 transition-all">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
{/* Worker Modal */}
|
||||
{showWorkerModal && (
|
||||
|
||||
@@ -45,11 +45,13 @@ export default function StopsHeaderActions({ brandId }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ScheduleImportModal
|
||||
brandId={brandId}
|
||||
onClose={() => setShowImport(false)}
|
||||
onComplete={handleImportComplete}
|
||||
/>
|
||||
{showImport && (
|
||||
<ScheduleImportModal
|
||||
brandId={brandId}
|
||||
onClose={() => setShowImport(false)}
|
||||
onComplete={handleImportComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddStopModal
|
||||
isOpen={showAdd}
|
||||
|
||||
@@ -24,6 +24,12 @@ const CAMPAIGN_TYPES: { value: CampaignType; label: string }[] = [
|
||||
{ value: "transactional", label: "Transactional" },
|
||||
];
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
email_template: "bg-blue-100 text-blue-700",
|
||||
sms_template: "bg-emerald-100 text-emerald-700",
|
||||
internal_note: "bg-stone-100 text-stone-600",
|
||||
};
|
||||
|
||||
// Sample variables for preview rendering
|
||||
const SAMPLE_VARS: Record<string, string> = {
|
||||
first_name: "Jane",
|
||||
@@ -42,52 +48,242 @@ const SAMPLE_VARS: Record<string, string> = {
|
||||
unsubscribe_url: "#",
|
||||
};
|
||||
|
||||
export function TemplateListPanel({ templates }: { templates: Template[] }) {
|
||||
// Icon components
|
||||
const Icons = {
|
||||
plus: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
),
|
||||
x: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
),
|
||||
fileText: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
),
|
||||
arrowRight: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 12h14"/>
|
||||
<path d="m12 5 7 7-7 7"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
// New Template Modal
|
||||
function NewTemplateModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
brandId
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
brandId: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [templateType, setTemplateType] = useState<TemplateType>("email_template");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) {
|
||||
setError("Template name is required");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError("");
|
||||
|
||||
const result = await upsertTemplate({
|
||||
brand_id: brandId,
|
||||
name: name.trim(),
|
||||
subject: "",
|
||||
body_text: "",
|
||||
template_type: templateType,
|
||||
});
|
||||
|
||||
setSaving(false);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to create template");
|
||||
return;
|
||||
}
|
||||
|
||||
setName("");
|
||||
setTemplateType("email_template");
|
||||
onSuccess();
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-zinc-100">Templates</h2>
|
||||
<p className="text-sm text-zinc-500">{templates.length} template{templates.length !== 1 ? "s" : ""}</p>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl border border-[var(--admin-border)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--admin-border)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
|
||||
{Icons.fileText("h-5 w-5 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">New Template</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Create a new email template</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-[var(--admin-card-hover)] transition-colors"
|
||||
>
|
||||
{Icons.x("h-5 w-5 text-[var(--admin-text-muted)]")}
|
||||
</button>
|
||||
</div>
|
||||
<a
|
||||
href="/admin/communications/templates/new"
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
|
||||
Template Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||||
placeholder="e.g. Pickup Reminder"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
|
||||
Template Type *
|
||||
</label>
|
||||
<select
|
||||
value={templateType}
|
||||
onChange={(e) => setTemplateType(e.target.value as TemplateType)}
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||||
>
|
||||
{TEMPLATE_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--admin-border)]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={saving || !name.trim()}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? "Creating..." : "Create Template"}
|
||||
{!saving && Icons.arrowRight("h-4 w-4")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TemplateListPanel({ templates, brandId = "64294306-5f42-463d-a5e8-2ad6c81a96de" }: { templates: Template[], brandId?: string }) {
|
||||
const router = useRouter();
|
||||
const [showNewModal, setShowNewModal] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<NewTemplateModal
|
||||
isOpen={showNewModal}
|
||||
onClose={() => setShowNewModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowNewModal(false);
|
||||
router.refresh();
|
||||
}}
|
||||
brandId={brandId}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-text-primary)]">
|
||||
{Icons.fileText("w-4 h-4 text-[var(--admin-bg)]")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Templates</h2>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">{templates.length} template{templates.length !== 1 ? "s" : ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowNewModal(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3 sm:px-4 py-2 text-xs sm:text-sm font-semibold text-white hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
+ New Template
|
||||
</a>
|
||||
{Icons.plus("h-3.5 w-3.5 sm:h-4 sm:w-4")}
|
||||
<span>New Template</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{templates.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">No templates yet</div>
|
||||
<div className="text-center py-12 text-[var(--admin-text-muted)]">No templates yet</div>
|
||||
) : (
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-zinc-900 border-b border-zinc-800">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Name</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Type</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Subject</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Updated</th>
|
||||
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
||||
<table className="w-full text-xs sm:text-sm">
|
||||
<thead className="bg-[var(--admin-card)]">
|
||||
<tr className="border-b border-[var(--admin-border)]">
|
||||
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Name</th>
|
||||
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Type</th>
|
||||
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Subject</th>
|
||||
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Updated</th>
|
||||
<th className="text-right px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
<tbody className="divide-y divide-[var(--admin-border)]">
|
||||
{templates.map((t) => (
|
||||
<tr key={t.id} className="hover:bg-zinc-900 cursor-pointer" onClick={() => router.push(`/admin/communications/templates/${t.id}`)}>
|
||||
<td className="px-4 py-3 font-medium text-zinc-100">{t.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
t.template_type === "email_template" ? "bg-blue-900/40 text-blue-700" :
|
||||
t.template_type === "sms_template" ? "bg-green-900/40 text-green-400" :
|
||||
"bg-zinc-950 text-zinc-400"
|
||||
}`}>
|
||||
<tr
|
||||
key={t.id}
|
||||
className="hover:bg-[var(--admin-card-hover)] cursor-pointer transition-colors"
|
||||
onClick={() => router.push(`/admin/communications/templates/${t.id}`)}
|
||||
>
|
||||
<td className="px-3 sm:px-4 py-3 font-medium text-[var(--admin-text-primary)]">{t.name}</td>
|
||||
<td className="px-3 sm:px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] sm:text-xs font-medium ${TYPE_COLORS[t.template_type] || "bg-stone-100 text-stone-600"}`}>
|
||||
{t.template_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-400 truncate max-w-xs">{t.subject}</td>
|
||||
<td className="px-4 py-3 text-zinc-500">{formatDate(new Date(t.updated_at))}</td>
|
||||
<td className="px-3 sm:px-4 py-3 text-[var(--admin-text-muted)] truncate max-w-[200px] sm:max-w-xs">{t.subject}</td>
|
||||
<td className="px-3 sm:px-4 py-3 text-[var(--admin-text-muted)]">{formatDate(new Date(t.updated_at))}</td>
|
||||
<td className="px-3 sm:px-4 py-3 text-right">
|
||||
{Icons.arrowRight("h-4 w-4 text-[var(--admin-text-muted)]")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -176,23 +372,42 @@ export function TemplateEditForm({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl">
|
||||
<div className="p-4 sm:p-6 max-w-5xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-zinc-100">
|
||||
{mode === "new" ? "New Template" : "Edit Template"}
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-text-primary)]">
|
||||
{Icons.fileText("w-4 h-4 text-[var(--admin-bg)]")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">
|
||||
{mode === "new" ? "New Template" : "Edit Template"}
|
||||
</h2>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
|
||||
{mode === "new" ? "Create a new email template" : "Update template settings"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("edit")}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium ${activeTab === "edit" ? "bg-slate-900 text-white" : "bg-zinc-950 text-zinc-400 hover:bg-slate-200"}`}
|
||||
className={`rounded-lg px-3 sm:px-4 py-2 text-xs sm:text-sm font-semibold transition-colors ${
|
||||
activeTab === "edit"
|
||||
? "bg-emerald-600 text-white"
|
||||
: "bg-white border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)]"
|
||||
}`}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("preview")}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium ${activeTab === "preview" ? "bg-slate-900 text-white" : "bg-zinc-950 text-zinc-400 hover:bg-slate-200"}`}
|
||||
className={`rounded-lg px-3 sm:px-4 py-2 text-xs sm:text-sm font-semibold transition-colors ${
|
||||
activeTab === "preview"
|
||||
? "bg-emerald-600 text-white"
|
||||
: "bg-white border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)]"
|
||||
}`}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
@@ -200,20 +415,20 @@ export function TemplateEditForm({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">{error}</div>
|
||||
)}
|
||||
|
||||
{activeTab === "edit" ? (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* Basic info */}
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Template Info</h3>
|
||||
<h3 className="text-xs sm:text-sm font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide">Template Info</h3>
|
||||
{/* Built-in template picker */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-zinc-500">Start from:</span>
|
||||
<span className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Start from:</span>
|
||||
<select
|
||||
className="text-sm border border-zinc-600 rounded-lg px-3 py-1.5"
|
||||
className="text-xs sm:text-sm border border-[var(--admin-border)] rounded-lg px-3 py-1.5 bg-white text-[var(--admin-text-primary)]"
|
||||
onChange={(e) => {
|
||||
const tpl = BUILT_IN_TEMPLATES.find((t) => t.id === e.target.value);
|
||||
if (tpl) applyBuiltIn(tpl);
|
||||
@@ -229,22 +444,22 @@ export function TemplateEditForm({
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Template Name *</label>
|
||||
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Template Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
|
||||
placeholder="e.g. Pickup Reminder"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Template Type *</label>
|
||||
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Template Type *</label>
|
||||
<select
|
||||
value={templateType}
|
||||
onChange={(e) => setTemplateType(e.target.value as TemplateType)}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
|
||||
>
|
||||
{TEMPLATE_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
@@ -252,11 +467,11 @@ export function TemplateEditForm({
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Campaign Type</label>
|
||||
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Campaign Type</label>
|
||||
<select
|
||||
value={campaignType}
|
||||
onChange={(e) => setCampaignType(e.target.value as CampaignType)}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
|
||||
>
|
||||
<option value="">Any</option>
|
||||
{CAMPAIGN_TYPES.map((t) => (
|
||||
@@ -268,18 +483,18 @@ export function TemplateEditForm({
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Subject Line</h3>
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-6 space-y-4">
|
||||
<h3 className="text-xs sm:text-sm font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide">Subject Line</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Subject *</label>
|
||||
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Subject *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
|
||||
placeholder="Email subject line"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
<p className="mt-1 text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
|
||||
Variables:{" "}
|
||||
{TEMPLATE_VARIABLES.slice(0, 6).map((v) => (
|
||||
<button
|
||||
@@ -287,10 +502,9 @@ export function TemplateEditForm({
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const tok = `{{${v.key}}}`;
|
||||
const start = subject.length;
|
||||
setSubject((s) => s + tok);
|
||||
}}
|
||||
className="ml-1 text-blue-400 hover:text-blue-800"
|
||||
className="ml-1 text-emerald-600 hover:text-emerald-700 font-medium"
|
||||
>
|
||||
{`{{${v.key}}}`}
|
||||
</button>
|
||||
@@ -300,30 +514,30 @@ export function TemplateEditForm({
|
||||
</div>
|
||||
|
||||
{/* Body editor */}
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Message Body</h3>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<h3 className="text-xs sm:text-sm font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide">Message Body</h3>
|
||||
<label className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={htmlMode}
|
||||
onChange={(e) => setHtmlMode(e.target.checked)}
|
||||
className="rounded border-slate-400"
|
||||
className="rounded border-[var(--admin-border)] text-emerald-600"
|
||||
/>
|
||||
<span className="text-zinc-400">HTML mode</span>
|
||||
<span className="text-[var(--admin-text-muted)]">HTML mode</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Variable toolbar */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="text-xs text-slate-400 self-center mr-1">Insert:</span>
|
||||
<span className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] self-center mr-1">Insert:</span>
|
||||
{TEMPLATE_VARIABLES.map((v) => (
|
||||
<button
|
||||
key={v.key}
|
||||
type="button"
|
||||
title={`${v.label}: ${v.example}`}
|
||||
onClick={() => insertVariable(v.key)}
|
||||
className="rounded bg-zinc-950 px-2 py-1 text-xs text-zinc-400 hover:bg-blue-900/40 hover:text-blue-700"
|
||||
className="rounded bg-[var(--admin-card)] border border-[var(--admin-border)] px-2 py-1 text-[10px] sm:text-xs text-[var(--admin-text-muted)] hover:bg-emerald-50 hover:text-emerald-700 hover:border-emerald-200 transition-colors"
|
||||
>
|
||||
{`{{${v.key}}}`}
|
||||
</button>
|
||||
@@ -333,42 +547,42 @@ export function TemplateEditForm({
|
||||
{htmlMode ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">HTML Body</label>
|
||||
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">HTML Body</label>
|
||||
<textarea
|
||||
value={bodyHtml}
|
||||
onChange={(e) => setBodyHtml(e.target.value)}
|
||||
rows={12}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm font-mono text-xs"
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm font-mono text-xs bg-white text-[var(--admin-text-primary)]"
|
||||
placeholder="<p>HTML version with variables like {{first_name}}...</p>"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Plain Text Fallback *</label>
|
||||
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Plain Text Fallback *</label>
|
||||
<textarea
|
||||
id="body-textarea"
|
||||
value={bodyText}
|
||||
onChange={(e) => setBodyText(e.target.value)}
|
||||
rows={6}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
|
||||
placeholder="Plain text version..."
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1">Body (Plain Text) *</label>
|
||||
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Body (Plain Text) *</label>
|
||||
<textarea
|
||||
id="body-textarea"
|
||||
value={bodyText}
|
||||
onChange={(e) => setBodyText(e.target.value)}
|
||||
rows={10}
|
||||
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
|
||||
placeholder="Message body with variables like {{first_name}}..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-slate-400">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
|
||||
Tip: Click any variable button above to insert it at the cursor position. Variables are replaced when the email is sent.
|
||||
</p>
|
||||
</div>
|
||||
@@ -379,11 +593,11 @@ export function TemplateEditForm({
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !name || !subject || !bodyText}
|
||||
className="inline-flex items-center rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
className="inline-flex items-center rounded-lg bg-emerald-600 px-4 sm:px-5 py-2 text-xs sm:text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Template"}
|
||||
</button>
|
||||
<a href="/admin/communications/templates" className="text-sm text-zinc-500 hover:text-zinc-300 ml-4">
|
||||
<a href="/admin/communications/templates" className="text-xs sm:text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] ml-4">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
@@ -392,17 +606,25 @@ export function TemplateEditForm({
|
||||
/* Preview Tab */
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-zinc-400">Preview:</span>
|
||||
<div className="flex rounded-lg border border-zinc-600 overflow-hidden">
|
||||
<span className="text-xs sm:text-sm text-[var(--admin-text-muted)]">Preview:</span>
|
||||
<div className="flex rounded-lg border border-[var(--admin-border)] overflow-hidden">
|
||||
<button
|
||||
onClick={() => setPreviewDevice("desktop")}
|
||||
className={`px-4 py-2 text-sm font-medium ${previewDevice === "desktop" ? "bg-slate-900 text-white" : "bg-zinc-900 text-zinc-400 hover:bg-zinc-900"}`}
|
||||
className={`px-4 py-2 text-xs sm:text-sm font-semibold transition-colors ${
|
||||
previewDevice === "desktop"
|
||||
? "bg-emerald-600 text-white"
|
||||
: "bg-white text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)]"
|
||||
}`}
|
||||
>
|
||||
Desktop
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPreviewDevice("mobile")}
|
||||
className={`px-4 py-2 text-sm font-medium ${previewDevice === "mobile" ? "bg-slate-900 text-white" : "bg-zinc-900 text-zinc-400 hover:bg-zinc-900"}`}
|
||||
className={`px-4 py-2 text-xs sm:text-sm font-semibold transition-colors ${
|
||||
previewDevice === "mobile"
|
||||
? "bg-emerald-600 text-white"
|
||||
: "bg-white text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)]"
|
||||
}`}
|
||||
>
|
||||
Mobile
|
||||
</button>
|
||||
@@ -411,15 +633,15 @@ export function TemplateEditForm({
|
||||
|
||||
{/* Email preview */}
|
||||
<div className={`mx-auto transition-all ${previewDevice === "mobile" ? "max-w-[375px]" : "max-w-[600px]"}`}>
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 shadow-black/20 overflow-hidden">
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white shadow-lg overflow-hidden">
|
||||
{/* Mock email header */}
|
||||
<div className="bg-zinc-950 border-b border-zinc-800 px-4 py-2 flex items-center gap-2">
|
||||
<div className="bg-[var(--admin-card)] border-b border-[var(--admin-border)] px-4 py-2 flex items-center gap-2">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-red-400" />
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-400" />
|
||||
<div className="w-3 h-3 rounded-full bg-green-400" />
|
||||
<div className="w-3 h-3 rounded-full bg-emerald-400" />
|
||||
</div>
|
||||
<div className="flex-1 text-center text-xs text-zinc-500 truncate">
|
||||
<div className="flex-1 text-center text-xs text-[var(--admin-text-muted)] truncate">
|
||||
{preview.subject}
|
||||
</div>
|
||||
</div>
|
||||
@@ -430,11 +652,11 @@ export function TemplateEditForm({
|
||||
/>
|
||||
{/* Plain text toggle */}
|
||||
{bodyText && (
|
||||
<details className="border-t border-zinc-800">
|
||||
<summary className="px-4 py-2 text-xs text-zinc-500 cursor-pointer hover:text-zinc-300">
|
||||
<details className="border-t border-[var(--admin-border)]">
|
||||
<summary className="px-4 py-2 text-xs text-[var(--admin-text-muted)] cursor-pointer hover:text-[var(--admin-text-primary)]">
|
||||
View plain text version
|
||||
</summary>
|
||||
<pre className="px-4 pb-4 text-xs text-zinc-400 whitespace-pre-wrap">{preview.body_text}</pre>
|
||||
<pre className="px-4 pb-4 text-xs text-[var(--admin-text-muted)] whitespace-pre-wrap font-mono">{preview.body_text}</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
@@ -443,4 +665,4 @@ export function TemplateEditForm({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,48 @@ import {
|
||||
type NotificationLogEntry,
|
||||
} from "@/actions/time-tracking";
|
||||
|
||||
// One-color outline icons
|
||||
const Icons = {
|
||||
clock: (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"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
),
|
||||
dollarSign: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" x2="12" y1="2" y2="22"/>
|
||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
||||
</svg>
|
||||
),
|
||||
clipboard: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||
<path d="M12 11h4M12 16h4M8 11h.01M8 16h.01"/>
|
||||
</svg>
|
||||
),
|
||||
chart: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" x2="12" y1="20" y2="10"/>
|
||||
<line x1="18" x2="18" y1="20" y2="4"/>
|
||||
<line x1="6" x2="6" y1="20" y2="16"/>
|
||||
</svg>
|
||||
),
|
||||
check: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 6 9 17l-5-5"/>
|
||||
</svg>
|
||||
),
|
||||
alert: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/>
|
||||
<path d="M12 9v4"/>
|
||||
<path d="M12 17h.01"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
type Tab = "dashboard" | "settings";
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
@@ -316,7 +358,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button onClick={() => triggerDownload(buildExportUrl("quickbooks"))}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-stone-50 border border-stone-200 hover:border-emerald-500 transition-all text-left">
|
||||
<span className="text-lg">📒</span>
|
||||
<span className="text-stone-400">{Icons.clock("h-5 w-5")}</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-stone-800">QuickBooks</p>
|
||||
<p className="text-xs text-stone-500">Time import CSV</p>
|
||||
@@ -324,7 +366,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
</button>
|
||||
<button onClick={() => triggerDownload(buildExportUrl("payroll"))}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-stone-50 border border-stone-200 hover:border-blue-500 transition-all text-left">
|
||||
<span className="text-lg">💰</span>
|
||||
<span className="text-stone-400">{Icons.dollarSign("h-5 w-5")}</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-stone-800">Payroll</p>
|
||||
<p className="text-xs text-stone-500">ADP / Gusto / Generic</p>
|
||||
@@ -332,7 +374,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
</button>
|
||||
<button onClick={() => triggerDownload(buildExportUrl("detailed"))}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-stone-50 border border-stone-200 hover:border-violet-500 transition-all text-left">
|
||||
<span className="text-lg">📋</span>
|
||||
<span className="text-stone-400">{Icons.clipboard("h-5 w-5")}</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-stone-800">Detailed Log</p>
|
||||
<p className="text-xs text-stone-500">Full audit report</p>
|
||||
@@ -340,7 +382,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
</button>
|
||||
<button onClick={() => triggerDownload(buildExportUrl("summary"))}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-stone-50 border border-stone-200 hover:border-amber-500 transition-all text-left">
|
||||
<span className="text-lg">📊</span>
|
||||
<span className="text-stone-400">{Icons.chart("h-5 w-5")}</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-stone-800">Period Summary</p>
|
||||
<p className="text-xs text-stone-500">Per-worker totals</p>
|
||||
@@ -385,10 +427,10 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${meta.color}`}>{meta.en}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-center">
|
||||
<span className={`text-sm font-bold ${entry.email_sent ? "text-emerald-600" : "text-stone-300"}`}>{entry.email_sent ? "✓" : "—"}</span>
|
||||
<span className="text-emerald-600">{Icons.check("h-4 w-4")}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-center">
|
||||
<span className={`text-sm font-bold ${entry.sms_sent ? "text-emerald-600" : "text-stone-300"}`}>{entry.sms_sent ? "✓" : "—"}</span>
|
||||
<span className="text-stone-300">{entry.sms_sent ? Icons.check("h-4 w-4 text-emerald-600") : "—"}</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -535,7 +577,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
{/* Colorado notice */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-amber-600 text-lg mt-0.5">⚠️</span>
|
||||
<span className="text-amber-600 mt-0.5">{Icons.alert("h-5 w-5")}</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-amber-800">Colorado Overtime Law</p>
|
||||
<p className="text-xs text-amber-700 mt-1">Colorado requires daily overtime (1.5×) after 12 hours in a workday, or weekly overtime after 40 hours in a workweek.</p>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type Action = {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: "default" | "danger" | "success";
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type AdminActionMenuProps = {
|
||||
actions: Action[];
|
||||
triggerLabel?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function AdminActionMenu({ actions, triggerLabel = "⋮", className = "" }: AdminActionMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleAction = (action: Action) => {
|
||||
action.onClick();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className={`relative inline-flex items-center justify-end ${className}`}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
||||
>
|
||||
{triggerLabel}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-[var(--admin-border)] shadow-[var(--admin-shadow-lg)] overflow-hidden">
|
||||
{actions.map((action, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handleAction(action)}
|
||||
disabled={action.disabled}
|
||||
className={`w-full text-left px-4 py-2.5 text-sm transition-colors ${
|
||||
action.variant === "danger"
|
||||
? "text-[var(--admin-danger)] hover:bg-[var(--admin-danger-light)]"
|
||||
: action.variant === "success"
|
||||
? "text-[var(--admin-accent-text)] hover:bg-[var(--admin-accent-light)]"
|
||||
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
|
||||
} ${action.disabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple inline action button
|
||||
type AdminActionButtonProps = {
|
||||
children: ReactNode;
|
||||
onClick: () => void;
|
||||
variant?: "default" | "primary" | "danger";
|
||||
size?: "sm" | "md";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AdminActionButton({
|
||||
children,
|
||||
onClick,
|
||||
variant = "default",
|
||||
size = "sm",
|
||||
className = ""
|
||||
}: AdminActionButtonProps) {
|
||||
const baseClasses = "rounded-lg font-medium transition-colors";
|
||||
const sizeClasses = size === "sm" ? "px-3 py-1.5 text-xs" : "px-4 py-2 text-sm";
|
||||
|
||||
const variantClasses = {
|
||||
default: "text-[var(--admin-text-secondary)] hover:text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg-subtle)]",
|
||||
primary: "bg-[var(--admin-accent)] text-white hover:opacity-90",
|
||||
danger: "text-[var(--admin-danger)] hover:bg-[var(--admin-danger-light)]",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`${baseClasses} ${sizeClasses} ${variantClasses[variant]} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
type BadgeVariant = "default" | "success" | "warning" | "danger" | "info";
|
||||
|
||||
type AdminBadgeProps = {
|
||||
children: React.ReactNode;
|
||||
variant?: BadgeVariant;
|
||||
dot?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const variantClasses: Record<BadgeVariant, { bg: string; text: string; dot: string }> = {
|
||||
default: { bg: "bg-[var(--admin-border-light)]", text: "text-[var(--admin-text-secondary)]", dot: "bg-[var(--admin-text-muted)]" },
|
||||
success: { bg: "bg-[var(--admin-accent-light)]", text: "text-[var(--admin-accent-text)]", dot: "bg-[var(--admin-accent)]" },
|
||||
warning: { bg: "bg-[var(--admin-warning-light)]", text: "text-[var(--admin-warning)]", dot: "bg-[var(--admin-warning)]" },
|
||||
danger: { bg: "bg-[var(--admin-danger-light)]", text: "text-[var(--admin-danger)]", dot: "bg-[var(--admin-danger)]" },
|
||||
info: { bg: "bg-[var(--admin-border)]", text: "text-[var(--admin-text-secondary)]", dot: "bg-[var(--admin-info)]" },
|
||||
};
|
||||
|
||||
export default function AdminBadge({
|
||||
children,
|
||||
variant = "default",
|
||||
dot = false,
|
||||
className = ""
|
||||
}: AdminBadgeProps) {
|
||||
const { bg, text, dot: dotColor } = variantClasses[variant];
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[10px] font-bold ${bg} ${text} ${className}`}>
|
||||
{dot && <span className={`h-1.5 w-1.5 rounded-full ${dotColor}`} />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Status badge with predefined statuses
|
||||
type AdminStatusBadgeProps = {
|
||||
status: "active" | "inactive" | "pending" | "draft" | "completed" | "cancelled";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const statusConfig: Record<string, { variant: BadgeVariant; dot: boolean; label: string }> = {
|
||||
active: { variant: "success", dot: true, label: "Active" },
|
||||
inactive: { variant: "default", dot: true, label: "Inactive" },
|
||||
pending: { variant: "warning", dot: true, label: "Pending" },
|
||||
draft: { variant: "default", dot: true, label: "Draft" },
|
||||
completed: { variant: "success", dot: true, label: "Completed" },
|
||||
cancelled: { variant: "danger", dot: true, label: "Cancelled" },
|
||||
};
|
||||
|
||||
export function AdminStatusBadge({ status, className = "" }: AdminStatusBadgeProps) {
|
||||
const config = statusConfig[status] || statusConfig.inactive;
|
||||
return (
|
||||
<AdminBadge variant={config.variant} dot={config.dot} className={className}>
|
||||
{config.label}
|
||||
</AdminBadge>
|
||||
);
|
||||
}
|
||||
|
||||
// Count badge (circular)
|
||||
type AdminCountBadgeProps = {
|
||||
count: number;
|
||||
variant?: BadgeVariant;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AdminCountBadge({ count, variant = "default", className = "" }: AdminCountBadgeProps) {
|
||||
const { bg, text } = variantClasses[variant];
|
||||
return (
|
||||
<span className={`inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1.5 text-xs font-bold ${bg} ${text} ${className}`}>
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, CSSProperties } from "react";
|
||||
|
||||
type AdminCardProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
noPadding?: boolean;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
export default function AdminCard({ children, className = "", noPadding = false, style }: AdminCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl border bg-white shadow-[var(--admin-shadow-sm)] ${noPadding ? "" : "p-5"} ${className}`}
|
||||
style={{
|
||||
borderColor: 'var(--admin-border)',
|
||||
...style
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type AdminCardHeaderProps = {
|
||||
title: string;
|
||||
action?: ReactNode;
|
||||
};
|
||||
|
||||
export function AdminCardHeader({ title, action }: AdminCardHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text-primary)]">{title}</h3>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type AdminCardFooterProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function AdminCardFooter({ children }: AdminCardFooterProps) {
|
||||
return (
|
||||
<div className="mt-4 pt-4 border-t border-[var(--admin-border-light)]">{children}</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type AdminContainerProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function AdminContainer({ children, className = "" }: AdminContainerProps) {
|
||||
return (
|
||||
<div className={`mx-auto max-w-6xl ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import GlassModal from "@/components/admin/GlassModal";
|
||||
|
||||
type AdminDeleteConfirmProps = {
|
||||
title: string;
|
||||
itemName: string;
|
||||
description?: string;
|
||||
onConfirm: () => Promise<void> | void;
|
||||
onCancel?: () => void;
|
||||
deleteLabel?: string;
|
||||
};
|
||||
|
||||
export default function AdminDeleteConfirm({
|
||||
title,
|
||||
itemName,
|
||||
description,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
deleteLabel = "Delete",
|
||||
}: AdminDeleteConfirmProps) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
setOpen(false);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setOpen(false);
|
||||
onCancel?.();
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<GlassModal title={title} onClose={handleCancel}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-[var(--admin-text-secondary)]">
|
||||
Are you sure you want to delete <span className="font-semibold text-[var(--admin-text-primary)]">"{itemName}"</span>?
|
||||
</p>
|
||||
{description && (
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">{description}</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--admin-border-light)]">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="rounded-xl border border-[var(--admin-border)] px-4 py-2 text-sm font-semibold text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={deleting}
|
||||
className="rounded-xl bg-[var(--admin-danger)] px-4 py-2 text-sm font-bold text-white hover:opacity-90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{deleting ? "Deleting..." : deleteLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassModal>
|
||||
);
|
||||
}
|
||||
|
||||
// Hook for managing delete confirmation state
|
||||
export function useDeleteConfirm() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
const confirmDelete = (id: string, name: string) => setDeleteTarget({ id, name });
|
||||
const cancelDelete = () => setDeleteTarget(null);
|
||||
|
||||
return { deleteTarget, confirmDelete, cancelDelete };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type AdminEmptyStateProps = {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function AdminEmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className = ""
|
||||
}: AdminEmptyStateProps) {
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center py-16 px-4 text-center ${className}`}>
|
||||
{icon ? (
|
||||
<div className="mb-4 text-[var(--admin-text-muted)]">{icon}</div>
|
||||
) : (
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[var(--admin-bg-subtle)]">
|
||||
<svg className="h-8 w-8 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold text-[var(--admin-text-primary)]">{title}</h3>
|
||||
{description && <p className="mt-1 text-sm text-[var(--admin-text-muted)] max-w-sm">{description}</p>}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
type StatusOption = "all" | "active" | "inactive" | string;
|
||||
|
||||
type AdminFilterBarProps = {
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
statusFilter?: StatusOption;
|
||||
onStatusChange?: (value: StatusOption) => void;
|
||||
statusOptions?: { value: StatusOption; label: string }[];
|
||||
count?: number;
|
||||
countLabel?: string;
|
||||
viewMode?: "table" | "cards";
|
||||
onViewModeChange?: (mode: "table" | "cards") => void;
|
||||
children?: React.ReactNode;
|
||||
onAddClick?: () => void;
|
||||
addLabel?: string;
|
||||
};
|
||||
|
||||
export default function AdminFilterBar({
|
||||
search,
|
||||
onSearchChange,
|
||||
searchPlaceholder = "Search...",
|
||||
statusFilter,
|
||||
onStatusChange,
|
||||
statusOptions,
|
||||
count,
|
||||
countLabel = "items",
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
children,
|
||||
onAddClick,
|
||||
addLabel = "Add",
|
||||
}: AdminFilterBarProps) {
|
||||
const defaultOptions = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "inactive", label: "Inactive" },
|
||||
];
|
||||
|
||||
const options = statusOptions ?? defaultOptions;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-4 shadow-[var(--admin-shadow-sm)]">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[12rem]">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder={searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white py-2 pl-10 pr-4 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] placeholder:text-[var(--admin-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Tabs */}
|
||||
{onStatusChange && (
|
||||
<div className="flex gap-1 rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] p-1">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => onStatusChange(opt.value)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition-all ${
|
||||
statusFilter === opt.value
|
||||
? "bg-white text-[var(--admin-accent-text)] shadow-[var(--admin-shadow-sm)] border border-[var(--admin-accent-light)]"
|
||||
: "text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-white/50"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Count */}
|
||||
{count !== undefined && (
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
{count} {countLabel}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* View Toggle */}
|
||||
{onViewModeChange && viewMode && (
|
||||
<div className="flex gap-1 rounded-xl border border-[var(--admin-border)] bg-white p-1">
|
||||
<button
|
||||
onClick={() => onViewModeChange("table")}
|
||||
className={`rounded-lg p-1.5 transition-all ${
|
||||
viewMode === "table"
|
||||
? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] border border-[var(--admin-accent)] shadow-[var(--admin-shadow-sm)]"
|
||||
: "text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
|
||||
}`}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onViewModeChange("cards")}
|
||||
className={`rounded-lg p-1.5 transition-all ${
|
||||
viewMode === "cards"
|
||||
? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] border border-[var(--admin-accent)] shadow-[var(--admin-shadow-sm)]"
|
||||
: "text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
|
||||
}`}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom children (e.g. extra filters) */}
|
||||
{children}
|
||||
|
||||
{/* Add Button */}
|
||||
{onAddClick && (
|
||||
<button
|
||||
onClick={onAddClick}
|
||||
className="ml-auto rounded-xl bg-[var(--admin-accent)] px-4 py-2 text-xs font-bold text-white hover:bg-[var(--admin-accent-hover)] transition-colors shadow-[var(--admin-shadow-sm)]"
|
||||
>
|
||||
+ {addLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
type AdminInputProps = {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helpText?: string;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function AdminInput({
|
||||
label,
|
||||
error,
|
||||
helpText,
|
||||
required,
|
||||
className = "",
|
||||
children
|
||||
}: AdminInputProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && (
|
||||
<label className="block text-xs font-semibold text-[var(--admin-text-secondary)] mb-1.5">
|
||||
{label}
|
||||
{required && <span className="text-[var(--admin-danger)] ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
{children}
|
||||
{helpText && !error && <p className="mt-1 text-[10px] text-[var(--admin-text-muted)]">{helpText}</p>}
|
||||
{error && <p className="mt-1 text-xs text-[var(--admin-danger)]">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Styled text input wrapper
|
||||
type AdminTextInputProps = {
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
placeholder?: string;
|
||||
type?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AdminTextInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = "text",
|
||||
disabled,
|
||||
className = ""
|
||||
}: AdminTextInputProps) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={`w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] placeholder:text-[var(--admin-text-muted)] disabled:bg-[var(--admin-bg-subtle)] disabled:text-[var(--admin-text-muted)] ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Styled textarea wrapper
|
||||
type AdminTextareaProps = {
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AdminTextarea({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
rows = 3,
|
||||
disabled,
|
||||
className = ""
|
||||
}: AdminTextareaProps) {
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
disabled={disabled}
|
||||
className={`w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] placeholder:text-[var(--admin-text-muted)] resize-none disabled:bg-[var(--admin-bg-subtle)] disabled:text-[var(--admin-text-muted)] ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Styled select wrapper
|
||||
type AdminSelectProps = {
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||
options: { value: string; label: string }[];
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AdminSelect({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
disabled,
|
||||
className = ""
|
||||
}: AdminSelectProps) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
className={`w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] disabled:bg-[var(--admin-bg-subtle)] disabled:text-[var(--admin-text-muted)] ${className}`}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
// Styled checkbox wrapper
|
||||
type AdminCheckboxProps = {
|
||||
checked: boolean;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AdminCheckbox({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
disabled,
|
||||
className = ""
|
||||
}: AdminCheckboxProps) {
|
||||
return (
|
||||
<label className={`flex items-center gap-3 cursor-pointer ${disabled ? "opacity-50 cursor-not-allowed" : ""} ${className}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-[var(--admin-accent)]"
|
||||
/>
|
||||
<span className="text-sm font-medium text-[var(--admin-text-secondary)]">{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading spinner
|
||||
type AdminSpinnerProps = {
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AdminSpinner({ size = "md", className = "" }: AdminSpinnerProps) {
|
||||
const sizeClasses = {
|
||||
sm: "h-4 w-4 border-2",
|
||||
md: "h-6 w-6 border-2",
|
||||
lg: "h-8 w-8 border-3",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`animate-spin rounded-full border-[var(--admin-accent)] border-t-transparent ${sizeClasses[size]} ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading overlay
|
||||
type AdminLoadingOverlayProps = {
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export function AdminLoadingOverlay({ message = "Loading..." }: AdminLoadingOverlayProps) {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/80 backdrop-blur-sm z-10">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<AdminSpinner size="lg" />
|
||||
<span className="text-sm text-[var(--admin-text-muted)]">{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type AdminLayoutProps = {
|
||||
children: ReactNode;
|
||||
maxWidth?: "sm" | "md" | "lg" | "xl" | "2xl" | "full";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const maxWidthClasses = {
|
||||
sm: "max-w-sm",
|
||||
md: "max-w-md",
|
||||
lg: "max-w-lg",
|
||||
xl: "max-w-xl",
|
||||
"2xl": "max-w-6xl",
|
||||
full: "max-w-full",
|
||||
};
|
||||
|
||||
export default function AdminLayout({ children, maxWidth = "2xl", className = "" }: AdminLayoutProps) {
|
||||
return (
|
||||
<main className="min-h-screen admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className={`mx-auto px-6 py-10 ${maxWidthClasses[maxWidth]} ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
maxWidth?: string;
|
||||
};
|
||||
|
||||
export default function AdminModal({ title, subtitle, onClose, children, maxWidth = "max-w-md" }: Props) {
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
|
||||
>
|
||||
<div
|
||||
className={`relative w-full ${maxWidth} rounded-2xl`}
|
||||
style={{
|
||||
backgroundColor: "#ffffff",
|
||||
border: "1px solid var(--admin-border)",
|
||||
boxShadow: "0 25px 50px -12px rgba(60, 56, 37, 0.35), 0 12px 24px -8px rgba(60, 56, 37, 0.2)",
|
||||
}}
|
||||
>
|
||||
{/* Accent bar */}
|
||||
<div className="absolute top-0 left-0 right-0 h-1 rounded-t-2xl overflow-hidden"
|
||||
style={{ background: "linear-gradient(90deg, var(--admin-accent) 0%, var(--admin-accent-hover) 100%)" }}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5" style={{ borderBottom: "1px solid var(--admin-border-light)" }}>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-[var(--admin-text-primary)]" style={{ letterSpacing: "-0.02em" }}>{title}</h2>
|
||||
{subtitle && <p className="mt-0.5 text-sm text-[var(--admin-text-muted)]">{subtitle}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full transition-all hover:bg-[var(--admin-bg-subtle)]"
|
||||
style={{ backgroundColor: "rgba(60, 56, 37, 0.04)" }}
|
||||
>
|
||||
<svg className="h-5 w-5 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type AdminPageHeaderProps = {
|
||||
breadcrumb?: { label: string; href?: string }[];
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
};
|
||||
|
||||
export default function AdminPageHeader({ breadcrumb, title, description, actions }: AdminPageHeaderProps) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
{breadcrumb && breadcrumb.length > 0 && (
|
||||
<nav className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)] mb-4">
|
||||
{breadcrumb.map((item, i) => (
|
||||
<span key={i} className="flex items-center gap-2">
|
||||
{item.href ? (
|
||||
<Link href={item.href} className="hover:text-[var(--admin-text-secondary)] transition-colors">{item.label}</Link>
|
||||
) : (
|
||||
<span className="text-[var(--admin-text-secondary)]">{item.label}</span>
|
||||
)}
|
||||
{i < breadcrumb.length - 1 && <span>/</span>}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-[var(--admin-text-primary)] tracking-tight">{title}</h1>
|
||||
{description && <p className="mt-1 text-sm text-[var(--admin-text-muted)]">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-3">{actions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
type AdminPaginationProps = {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function AdminPagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
className = ""
|
||||
}: AdminPaginationProps) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-center gap-2 ${className}`}>
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 0}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: totalPages }, (_, i) => i).map((page) => {
|
||||
// Show first, last, current, and adjacent pages
|
||||
const showPage =
|
||||
page === 0 ||
|
||||
page === totalPages - 1 ||
|
||||
Math.abs(page - currentPage) <= 1;
|
||||
|
||||
// Show ellipsis
|
||||
const showEllipsisBefore = page === 1 && currentPage > 2;
|
||||
const showEllipsisAfter = page === totalPages - 2 && currentPage < totalPages - 3;
|
||||
|
||||
if (!showPage && !showEllipsisBefore && !showEllipsisAfter) return null;
|
||||
|
||||
if (showEllipsisBefore || showEllipsisAfter) {
|
||||
return (
|
||||
<span key={`ellipsis-${page}`} className="px-2 text-[var(--admin-text-muted)]">
|
||||
...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => onPageChange(page)}
|
||||
className={`flex h-8 min-w-[2rem] items-center justify-center rounded-lg text-sm font-medium transition-colors ${
|
||||
page === currentPage
|
||||
? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] border border-[var(--admin-accent)]"
|
||||
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
|
||||
}`}
|
||||
>
|
||||
{page + 1}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages - 1}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple previous/next pagination
|
||||
type AdminSimplePaginationProps = {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AdminSimplePagination({
|
||||
page,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
className = ""
|
||||
}: AdminSimplePaginationProps) {
|
||||
return (
|
||||
<div className={`flex items-center justify-between ${className}`}>
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
Page {page + 1} of {totalPages}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page === 0}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
type StatItem = {
|
||||
label: string;
|
||||
value: number;
|
||||
variant?: "default" | "success" | "warning" | "info";
|
||||
};
|
||||
|
||||
type AdminStatsBarProps = {
|
||||
stats: StatItem[];
|
||||
};
|
||||
|
||||
const variantClasses = {
|
||||
default: "bg-[var(--admin-border-light)] border-[var(--admin-border)] text-[var(--admin-text-secondary)]",
|
||||
success: "bg-[var(--admin-accent-light)] border-[var(--admin-accent)] text-[var(--admin-accent-text)]",
|
||||
warning: "bg-[var(--admin-warning-light)] border-[var(--admin-warning)] text-[var(--admin-text-primary)]",
|
||||
info: "bg-[var(--admin-border)] border-[var(--admin-text-muted)] text-[var(--admin-text-secondary)]",
|
||||
};
|
||||
|
||||
export default function AdminStatsBar({ stats }: AdminStatsBarProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-6">
|
||||
{stats.map((stat, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="text-sm text-[var(--admin-text-muted)]">{stat.label}</span>
|
||||
<span className={`rounded-full border px-2.5 py-0.5 text-sm font-bold ${variantClasses[stat.variant ?? "default"]}`}>
|
||||
{stat.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type Column<T> = {
|
||||
key: string;
|
||||
header: string;
|
||||
render?: (item: T) => ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type AdminTableProps<T> = {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
keyExtractor: (item: T) => string;
|
||||
onRowClick?: (item: T) => void;
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function AdminTable<T extends Record<string, unknown>>({
|
||||
data,
|
||||
columns,
|
||||
keyExtractor,
|
||||
onRowClick,
|
||||
emptyMessage = "No items found",
|
||||
className = "",
|
||||
}: AdminTableProps<T>) {
|
||||
return (
|
||||
<div className={`overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-[var(--admin-shadow-sm)] ${className}`}>
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
|
||||
{columns.map((col) => (
|
||||
<th key={col.key} className={`px-5 py-3.5 text-[10px] font-bold uppercase tracking-widest text-[var(--admin-text-muted)] ${col.className ?? ""}`}>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border-light)]">
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-5 py-16 text-center text-sm text-[var(--admin-text-muted)]">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((item) => (
|
||||
<tr
|
||||
key={keyExtractor(item)}
|
||||
className={`hover:bg-[var(--admin-bg-subtle)]/40 transition-colors ${onRowClick ? "cursor-pointer" : ""}`}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className={`px-5 py-3.5 ${col.className ?? ""}`}>
|
||||
{col.render ? col.render(item) : String(item[col.key] ?? "")}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper for status badges
|
||||
export function TableStatusBadge({ status }: { status: "active" | "inactive" | "pending" | "completed" }) {
|
||||
const config = {
|
||||
active: { bg: "bg-[var(--admin-accent-light)]", text: "text-[var(--admin-accent-text)]", dot: "bg-[var(--admin-accent)]" },
|
||||
inactive: { bg: "bg-[var(--admin-border-light)]", text: "text-[var(--admin-text-muted)]", dot: "bg-[var(--admin-text-muted)]" },
|
||||
pending: { bg: "bg-[var(--admin-warning-light)]", text: "text-[var(--admin-warning)]", dot: "bg-[var(--admin-warning)]" },
|
||||
completed: { bg: "bg-[var(--admin-border)]", text: "text-[var(--admin-text-secondary)]", dot: "bg-[var(--admin-info)]" },
|
||||
};
|
||||
const { bg, text, dot } = config[status];
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[10px] font-bold ${bg} ${text}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${dot}`} />
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Core components
|
||||
export { default as AdminLayout } from "./AdminLayout";
|
||||
export { default as AdminContainer } from "./AdminContainer";
|
||||
export { default as AdminPageHeader } from "./AdminPageHeader";
|
||||
export { default as AdminCard, AdminCardHeader, AdminCardFooter } from "./AdminCard";
|
||||
export { default as AdminStatsBar } from "./AdminStatsBar";
|
||||
export { default as AdminFilterBar } from "./AdminFilterBar";
|
||||
export { default as AdminTable, TableStatusBadge } from "./AdminTable";
|
||||
export { default as AdminEmptyState } from "./AdminEmptyState";
|
||||
export { default as AdminDeleteConfirm, useDeleteConfirm } from "./AdminDeleteConfirm";
|
||||
export { default as AdminActionMenu, AdminActionButton } from "./AdminActionMenu";
|
||||
export { default as AdminPagination, AdminSimplePagination } from "./AdminPagination";
|
||||
export { default as AdminBadge, AdminStatusBadge, AdminCountBadge } from "./AdminBadge";
|
||||
|
||||
// Form elements
|
||||
export { AdminInput, AdminTextInput, AdminTextarea, AdminSelect, AdminCheckbox, AdminSpinner, AdminLoadingOverlay } from "./AdminFormElements";
|
||||
|
||||
// Modal component
|
||||
export { default as AdminModal } from "./AdminModal";
|
||||
|
||||
// Re-export GlassModal for backward compatibility
|
||||
export { default as GlassModal } from "@/components/admin/GlassModal";
|
||||
@@ -4,19 +4,51 @@ import { useState } from "react";
|
||||
import { searchHarvestLots, HarvestLot } from "@/actions/route-trace/lots";
|
||||
import StatusBadge from "./StatusBadge";
|
||||
import Link from "next/link";
|
||||
import QRScanModal from "./QRScanModal";
|
||||
|
||||
function getAgeStatus(harvestDate: string): { label: string; className: string } | null {
|
||||
const harvested = new Date(harvestDate + "T00:00:00");
|
||||
const now = new Date();
|
||||
const days = Math.floor((now.getTime() - harvested.getTime()) / (1000 * 60 * 60 * 24));
|
||||
if (days < 0) return null;
|
||||
if (days <= 3) return { label: `${days}d`, className: "bg-green-100 text-green-700" };
|
||||
if (days <= 7) return { label: `${days}d`, className: "bg-amber-100 text-amber-700" };
|
||||
if (days <= 14) return { label: `${days}d`, className: "bg-orange-100 text-orange-700" };
|
||||
return { label: `${days}d`, className: "bg-red-100 text-red-700" };
|
||||
}
|
||||
|
||||
// One-color outline icons
|
||||
const Icons = {
|
||||
camera: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
|
||||
<circle cx="12" cy="13" r="3" />
|
||||
</svg>
|
||||
),
|
||||
search: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.3-4.3"/>
|
||||
</svg>
|
||||
),
|
||||
plus: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 12h14M12 5v14"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export default function AdminLookupPage({ brandId }: { brandId: string }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<HarvestLot[]>([]);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [scanMode, setScanMode] = useState(false);
|
||||
const [showScanModal, setShowScanModal] = useState(false);
|
||||
|
||||
async function handleSearch(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
setLoading(true);
|
||||
setScanMode(false);
|
||||
const res = await searchHarvestLots(brandId, query.trim());
|
||||
setResults(res.success ? res.lots : []);
|
||||
setSearched(true);
|
||||
@@ -25,8 +57,6 @@ export default function AdminLookupPage({ brandId }: { brandId: string }) {
|
||||
|
||||
function handleScanResult(lotNumber: string) {
|
||||
setQuery(lotNumber);
|
||||
setScanMode(false);
|
||||
// Trigger search immediately
|
||||
setLoading(true);
|
||||
searchHarvestLots(brandId, lotNumber).then((res) => {
|
||||
setResults(res.success ? res.lots : []);
|
||||
@@ -37,8 +67,25 @@ export default function AdminLookupPage({ brandId }: { brandId: string }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Scan button */}
|
||||
<button
|
||||
onClick={() => setShowScanModal(true)}
|
||||
className="w-full rounded-xl bg-emerald-600 px-5 py-4 text-sm font-bold text-white hover:bg-emerald-700 transition-colors flex items-center justify-center gap-2 shadow-sm"
|
||||
>
|
||||
<span className="text-white">{Icons.camera("h-5 w-5")}</span>
|
||||
Scan QR Code
|
||||
</button>
|
||||
|
||||
{/* QRScanModal */}
|
||||
{showScanModal && (
|
||||
<QRScanModal
|
||||
onClose={() => setShowScanModal(false)}
|
||||
onScanResult={handleScanResult}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Search card */}
|
||||
<div className="rounded-2xl border border-stone-200 bg-white shadow-sm">
|
||||
<div className="rounded-2xl border border-stone-200 bg-white">
|
||||
<div className="border-b border-stone-100 px-6 py-5">
|
||||
<h2 className="text-lg font-semibold text-stone-900">Trace Lookup</h2>
|
||||
<p className="mt-1 text-sm text-stone-500">Search by lot number or crop type to find a harvest lot.</p>
|
||||
@@ -54,56 +101,19 @@ export default function AdminLookupPage({ brandId }: { brandId: string }) {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !query.trim()}
|
||||
className="rounded-xl bg-emerald-600 px-5 py-3 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
className="rounded-xl bg-emerald-600 px-5 py-3 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{loading ? "Searching..." : "🔍 Search"}
|
||||
{loading ? "Searching..." : <>{Icons.search("h-4 w-4")} Search</>}
|
||||
</button>
|
||||
</form>
|
||||
{scanMode && (
|
||||
<div className="px-6 pb-6">
|
||||
<div className="rounded-xl border-2 border-dashed border-stone-300 bg-stone-50 p-8 text-center">
|
||||
<div className="text-4xl mb-3">📷</div>
|
||||
<p className="text-sm font-semibold text-stone-700">Camera scan ready</p>
|
||||
<p className="text-xs text-stone-400 mt-1">Point camera at a Route Trace QR code to look up a lot</p>
|
||||
<div className="mt-4 flex justify-center gap-3">
|
||||
<button
|
||||
onClick={() => setScanMode(false)}
|
||||
className="rounded-lg border border-stone-200 bg-white px-4 py-2 text-sm font-medium text-stone-600 hover:bg-stone-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Simulate scan with manual entry fallback
|
||||
const input = prompt("Enter lot number from QR scan:");
|
||||
if (input) handleScanResult(input.trim());
|
||||
}}
|
||||
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700"
|
||||
>
|
||||
Enter Manually
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!scanMode && (
|
||||
<div className="px-6 pb-6">
|
||||
<button
|
||||
onClick={() => setScanMode(true)}
|
||||
className="rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
📷 Scan QR Code
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{searched && (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
|
||||
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden">
|
||||
{results.length === 0 ? (
|
||||
<div className="p-10 text-center">
|
||||
<div className="text-3xl mb-3">🔍</div>
|
||||
<div className="text-stone-300 mb-3">{Icons.search("h-10 w-10")}</div>
|
||||
<p className="text-sm text-stone-500">No lots found for "{query}"</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,12 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
// One-color outline icons
|
||||
const Icons = {
|
||||
clipboard: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||
<path d="M12 11h4M12 16h4M8 11h.01M8 16h.01"/>
|
||||
</svg>
|
||||
),
|
||||
refresh: (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 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
|
||||
<path d="M21 3v5h-5"/>
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
|
||||
<path d="M8 16H3v5"/>
|
||||
</svg>
|
||||
),
|
||||
package: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16.5 9.4 7.55 4.24"/>
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.29 7 12 12 20.71 7"/>
|
||||
<line x1="12" x2="12" y1="22" y2="12"/>
|
||||
</svg>
|
||||
),
|
||||
check: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 6 9 17l-5-5"/>
|
||||
</svg>
|
||||
),
|
||||
alert: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/>
|
||||
<path d="M12 9v4"/>
|
||||
<path d="M12 17h.01"/>
|
||||
</svg>
|
||||
),
|
||||
clock: (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"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
),
|
||||
scale: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"/>
|
||||
<path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"/>
|
||||
<path d="M7 21h10"/>
|
||||
<path d="M12 3v18"/>
|
||||
<path d="M3 7h2c2 0 5-1 7-2 2 1 5 2 7 2h2"/>
|
||||
</svg>
|
||||
),
|
||||
chart: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" x2="12" y1="20" y2="10"/>
|
||||
<line x1="18" x2="18" y1="20" y2="4"/>
|
||||
<line x1="6" x2="6" y1="20" y2="16"/>
|
||||
</svg>
|
||||
),
|
||||
search: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.3-4.3"/>
|
||||
</svg>
|
||||
),
|
||||
download: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" x2="12" y1="15" y2="3"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export default function FsmaReportModal({ brandId }: Props) {
|
||||
interface LotCompliance {
|
||||
lot_id: string;
|
||||
lot_number: string;
|
||||
crop_type: string;
|
||||
variety: string | null;
|
||||
harvest_date: string;
|
||||
field_location: string | null;
|
||||
field_block: string | null;
|
||||
worker_name: string | null;
|
||||
packer_name: string | null;
|
||||
quantity_lbs: number | null;
|
||||
quantity_used_lbs: number | null;
|
||||
yield_estimate_lbs: number | null;
|
||||
yield_unit: string | null;
|
||||
bin_id: string | null;
|
||||
status: string;
|
||||
event_count: number;
|
||||
has_traceability: boolean;
|
||||
compliance_status: "compliant" | "non_compliant" | "pending";
|
||||
issues: string[];
|
||||
}
|
||||
|
||||
interface ComplianceSnapshot {
|
||||
lots: LotCompliance[];
|
||||
summary: {
|
||||
total_lots: number;
|
||||
compliant: number;
|
||||
non_compliant: number;
|
||||
pending: number;
|
||||
total_weight: number;
|
||||
used_weight: number;
|
||||
remaining_weight: number;
|
||||
crops: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<string, { bg: string; text: string; dot: string }> = {
|
||||
compliant: { bg: "bg-green-50", text: "text-green-700", dot: "bg-green-500" },
|
||||
non_compliant: { bg: "bg-red-50", text: "text-red-700", dot: "bg-red-500" },
|
||||
pending: { bg: "bg-amber-50", text: "text-amber-700", dot: "bg-amber-500" },
|
||||
};
|
||||
|
||||
const LOT_STATUS_CONFIG: Record<string, { label: string; bg: string; text: string }> = {
|
||||
active: { label: "Active", bg: "bg-green-50", text: "text-green-700" },
|
||||
in_transit: { label: "In Transit", bg: "bg-amber-50", text: "text-amber-700" },
|
||||
at_shed: { label: "At Shed", bg: "bg-blue-50", text: "text-blue-700" },
|
||||
packed: { label: "Packed", bg: "bg-purple-50", text: "text-purple-700" },
|
||||
delivered: { label: "Delivered", bg: "bg-stone-100", text: "text-stone-600" },
|
||||
};
|
||||
|
||||
function ComplianceBadge({ status }: { status: "compliant" | "non_compliant" | "pending" }) {
|
||||
const cfg = STATUS_BADGE[status];
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold ${cfg.bg} ${cfg.text}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${cfg.dot}`} />
|
||||
{status === "compliant" ? "Compliant" : status === "non_compliant" ? "Issue" : "Pending"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({ icon, label, value, subtext, color }: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string | number;
|
||||
subtext?: string;
|
||||
color: "green" | "red" | "amber" | "blue" | "stone";
|
||||
}) {
|
||||
const colors = {
|
||||
green: "bg-green-50 border-green-100 text-green-800",
|
||||
red: "bg-red-50 border-red-100 text-red-800",
|
||||
amber: "bg-amber-50 border-amber-100 text-amber-800",
|
||||
blue: "bg-blue-50 border-blue-100 text-blue-800",
|
||||
stone: "bg-stone-50 border-stone-200 text-stone-800",
|
||||
};
|
||||
return (
|
||||
<div className={`rounded-xl border px-4 py-3 ${colors[color]}`}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm">{icon}</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider opacity-70">{label}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-black mt-0.5">{value}</p>
|
||||
{subtext && <p className="text-[10px] opacity-60 mt-0.5">{subtext}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FsmaReportModal({ brandId }: { brandId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [startDate, setStartDate] = useState(() => {
|
||||
const d = new Date();
|
||||
@@ -15,77 +172,421 @@ export default function FsmaReportModal({ brandId }: Props) {
|
||||
});
|
||||
const [endDate, setEndDate] = useState(() => new Date().toISOString().split("T")[0]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<ComplianceSnapshot | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [filterStatus, setFilterStatus] = useState<string>("all");
|
||||
const [showOnlyIssues, setShowOnlyIssues] = useState(false);
|
||||
|
||||
async function handleDownload() {
|
||||
useEffect(() => {
|
||||
if (open && startDate && endDate) {
|
||||
fetchComplianceData();
|
||||
}
|
||||
}, [open, startDate, endDate]);
|
||||
|
||||
async function fetchComplianceData() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const url = `/api/route-trace/fsma-report?brandId=${brandId}&startDate=${startDate}&endDate=${endDate}&format=csv`;
|
||||
window.location.href = url;
|
||||
const res = await fetch(
|
||||
`/api/route-trace/fsma-compliance?brandId=${brandId}&startDate=${startDate}&endDate=${endDate}`
|
||||
);
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
} else {
|
||||
setData(null);
|
||||
}
|
||||
} catch {
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
const url = `/api/route-trace/fsma-report?brandId=${brandId}&startDate=${startDate}&endDate=${endDate}&format=csv`;
|
||||
window.location.href = url;
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
const filteredLots = data?.lots.filter((lot) => {
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
lot.lot_number.toLowerCase().includes(search.toLowerCase()) ||
|
||||
lot.crop_type.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(lot.field_location ?? "").toLowerCase().includes(search.toLowerCase());
|
||||
const matchesStatus = filterStatus === "all" || lot.status === filterStatus;
|
||||
const matchesIssues = !showOnlyIssues || lot.compliance_status !== "compliant";
|
||||
return matchesSearch && matchesStatus && matchesIssues;
|
||||
}) ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="rounded-xl border border-stone-200 bg-white px-5 py-3 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
📋 FSMA Report
|
||||
{Icons.clipboard("h-4 w-4 mr-1.5")} FSMA Report
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setOpen(false)}>
|
||||
<div className="w-full max-w-sm rounded-2xl bg-white shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="border-b border-stone-100 px-6 py-4">
|
||||
<h3 className="text-base font-bold text-stone-900">📋 FSMA Compliance Report</h3>
|
||||
<p className="text-xs text-stone-400 mt-0.5">One-up / one-down traceability for a date range</p>
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30"
|
||||
onClick={(e) => e.target === e.currentTarget && setOpen(false)}
|
||||
style={{ backdropFilter: "blur(4px)" }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.2) 100%)",
|
||||
backdropFilter: "blur(60px) saturate(180%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Modal card */}
|
||||
<div
|
||||
className="relative w-full max-w-5xl rounded-2xl max-h-[90vh] flex flex-col"
|
||||
style={{
|
||||
background: "rgba(255, 255, 255, 0.92)",
|
||||
backdropFilter: "blur(40px) saturate(200%)",
|
||||
WebkitBackdropFilter: "blur(40px) saturate(200%)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.5)",
|
||||
boxShadow: `
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 12px 24px -8px rgba(0, 0, 0, 0.15)
|
||||
`,
|
||||
}}
|
||||
>
|
||||
{/* Top gradient border */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 rounded-t-2xl overflow-hidden"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, rgba(120, 113, 108, 0.1) 0%, transparent 100%)",
|
||||
height: "1px",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-6 py-5"
|
||||
style={{ borderBottom: "1px solid rgba(0, 0, 0, 0.06)" }}
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-stone-950" style={{ letterSpacing: "-0.02em" }}>
|
||||
<div className="flex items-center gap-2"><span className="text-lg">{Icons.clipboard("h-5 w-5")}</span> FSMA Compliance Snapshot</div>
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-stone-500">
|
||||
One-up / one-down traceability report
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full transition-all"
|
||||
style={{ background: "rgba(0, 0, 0, 0.04)", color: "rgba(0, 0, 0, 0.4)" }}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-600 mb-1.5">Start Date</label>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{/* Date Range Selector */}
|
||||
<div className="flex items-end gap-4 mb-6">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-semibold text-stone-500 mb-1.5 uppercase tracking-wider">
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-emerald-600"
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-stone-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-600 mb-1.5">End Date</label>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-semibold text-stone-500 mb-1.5 uppercase tracking-wider">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-3 text-sm outline-none focus:border-emerald-600"
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-stone-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchComplianceData}
|
||||
className="rounded-xl bg-emerald-600 px-4 py-2.5 text-sm font-bold text-white hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
{Icons.refresh("h-4 w-4 mr-1.5")} Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl bg-stone-50 border border-stone-200 px-4 py-3">
|
||||
<p className="text-xs text-stone-500">Includes all lots harvested between the selected dates with full trace events.</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="flex items-center gap-3 text-stone-500">
|
||||
<svg className="h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Loading compliance data…</span>
|
||||
</div>
|
||||
</div>
|
||||
) : data ? (
|
||||
<>
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-3 md:grid-cols-6 gap-3 mb-6">
|
||||
<SummaryCard
|
||||
icon={Icons.package("h-4 w-4")}
|
||||
label="Total Lots"
|
||||
value={data.summary.total_lots}
|
||||
color="stone"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={Icons.check("h-4 w-4")}
|
||||
label="Compliant"
|
||||
value={data.summary.compliant}
|
||||
subtext={`${data.summary.total_lots > 0 ? Math.round((data.summary.compliant / data.summary.total_lots) * 100) : 0}%`}
|
||||
color="green"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={Icons.alert("h-4 w-4")}
|
||||
label="Issues"
|
||||
value={data.summary.non_compliant}
|
||||
color="red"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={Icons.clock("h-4 w-4")}
|
||||
label="Pending"
|
||||
value={data.summary.pending}
|
||||
color="amber"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={Icons.scale("h-4 w-4")}
|
||||
label="Total Weight"
|
||||
value={data.summary.total_weight > 0 ? `${(data.summary.total_weight / 1000).toFixed(1)}k` : "—"}
|
||||
subtext={data.summary.total_weight > 0 ? "lbs" : ""}
|
||||
color="blue"
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={Icons.chart("h-4 w-4")}
|
||||
label="Crops"
|
||||
value={data.summary.crops.length}
|
||||
color="stone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400">{Icons.search("h-4 w-4")}</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search lot number, crop, field…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 rounded-xl border border-stone-200 bg-white text-sm outline-none focus:border-stone-400"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm outline-none focus:border-stone-400"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="in_transit">In Transit</option>
|
||||
<option value="at_shed">At Shed</option>
|
||||
<option value="packed">Packed</option>
|
||||
<option value="delivered">Delivered</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-2 rounded-xl border border-red-200 bg-red-50 px-3 py-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showOnlyIssues}
|
||||
onChange={(e) => setShowOnlyIssues(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-xs font-semibold text-red-700 flex items-center gap-1">{Icons.alert("h-3.5 w-3.5")} Issues only</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-xl border border-stone-200 bg-white overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-stone-100 bg-stone-50/50">
|
||||
<th className="px-4 py-3 text-left text-[10px] font-bold text-stone-500 uppercase tracking-widest">
|
||||
Compliance
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[10px] font-bold text-stone-500 uppercase tracking-widest">
|
||||
Lot #
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[10px] font-bold text-stone-500 uppercase tracking-widest">
|
||||
Crop
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[10px] font-bold text-stone-500 uppercase tracking-widest">
|
||||
Harvest Date
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[10px] font-bold text-stone-500 uppercase tracking-widest">
|
||||
Field
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-[10px] font-bold text-stone-500 uppercase tracking-widest">
|
||||
Weight
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[10px] font-bold text-stone-500 uppercase tracking-widest">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-[10px] font-bold text-stone-500 uppercase tracking-widest">
|
||||
Events
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[10px] font-bold text-stone-500 uppercase tracking-widest">
|
||||
Issues
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredLots.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-4 py-12 text-center text-sm text-stone-400">
|
||||
{data.summary.total_lots === 0
|
||||
? "No lots found for this date range"
|
||||
: "No lots match your filters"}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredLots.map((lot) => {
|
||||
const lotStatusCfg = LOT_STATUS_CONFIG[lot.status] ?? LOT_STATUS_CONFIG.active;
|
||||
return (
|
||||
<tr
|
||||
key={lot.lot_id}
|
||||
className="border-b border-stone-50 last:border-0 hover:bg-stone-50/40 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<ComplianceBadge status={lot.compliance_status} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-sm font-bold text-stone-900">
|
||||
{lot.lot_number}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm font-semibold text-stone-700">
|
||||
{lot.crop_type}
|
||||
</span>
|
||||
{lot.variety && (
|
||||
<span className="ml-1 text-xs text-stone-400">{lot.variety}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm text-stone-600">
|
||||
{formatDate(lot.harvest_date)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm text-stone-600">
|
||||
{lot.field_location ?? "—"}
|
||||
</span>
|
||||
{lot.field_block && lot.field_block !== "N/A" && (
|
||||
<span className="ml-1 text-xs text-stone-400">
|
||||
· Block {lot.field_block}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className="text-sm font-semibold text-stone-700">
|
||||
{lot.quantity_lbs
|
||||
? Number(lot.quantity_lbs).toLocaleString()
|
||||
: "—"}
|
||||
</span>
|
||||
<span className="text-[10px] text-stone-400 ml-1">
|
||||
{lot.yield_unit ?? "lbs"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[10px] font-bold ${lotStatusCfg.bg} ${lotStatusCfg.text}`}>
|
||||
<span className="h-1 w-1 rounded-full bg-current" />
|
||||
{lotStatusCfg.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`inline-flex items-center justify-center rounded-full h-6 w-6 text-xs font-bold ${
|
||||
lot.event_count > 0 ? "bg-emerald-50 text-emerald-700" : "bg-stone-100 text-stone-500"
|
||||
}`}>
|
||||
{lot.event_count}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{lot.issues.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{lot.issues.map((issue, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center rounded bg-red-50 px-1.5 py-0.5 text-[10px] font-medium text-red-600"
|
||||
>
|
||||
{issue}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-stone-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
<p className="mt-3 text-xs text-stone-400 text-center">
|
||||
Showing {filteredLots.length} of {data.summary.total_lots} lots
|
||||
{data.summary.crops.length > 0 && ` • Crops: ${data.summary.crops.join(", ")}`}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<div className="text-stone-300 mb-3">{Icons.clipboard("h-12 w-12")}</div>
|
||||
<p className="text-sm text-stone-500">No compliance data available</p>
|
||||
<p className="text-xs text-stone-400 mt-1">Select a date range and try again</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 border-t border-stone-100 px-6 py-4">
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-xl border border-stone-200 px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={loading}
|
||||
className="rounded-xl bg-emerald-600 px-5 py-2 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{loading ? "Preparing..." : "📥 Download CSV"}
|
||||
</button>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className="flex items-center justify-between px-6 py-4"
|
||||
style={{ borderTop: "1px solid rgba(0, 0, 0, 0.06)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-stone-400">
|
||||
FSMA Food Safety Modernization Act — Produce Traceability
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-xl border border-stone-200 px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={loading || !data}
|
||||
className="rounded-xl bg-blue-600 px-5 py-2 text-sm font-bold text-white hover:bg-blue-700 disabled:opacity-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-sm font-semibold">{Icons.download("h-4 w-4")} Download CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,25 @@ import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createHarvestLot } from "@/actions/route-trace/lots";
|
||||
|
||||
// One-color outline icons
|
||||
const Icons = {
|
||||
plant: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 22V12M12 12C12 12 7 10 7 5c0-2.5 2.5-5 5-5s5 2.5 5 5c0 5-5 7-5 7z" />
|
||||
</svg>
|
||||
),
|
||||
chevronUp: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m18 15-6-6-6 6"/>
|
||||
</svg>
|
||||
),
|
||||
chevronDown: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m6 9 6 6 6-6"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export default function LotCreateForm({ brandId }: { brandId: string }) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
@@ -59,7 +78,7 @@ export default function LotCreateForm({ brandId }: { brandId: string }) {
|
||||
<div className="border-b border-stone-100 px-6 py-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-green-100">
|
||||
<span className="text-base">🌱</span>
|
||||
<span className="text-green-600">{Icons.plant("h-5 w-5")}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-stone-900">New Harvest Lot</h2>
|
||||
@@ -262,7 +281,7 @@ export default function LotCreateForm({ brandId }: { brandId: string }) {
|
||||
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-stone-400 hover:text-stone-600"
|
||||
>
|
||||
<span>Notes</span>
|
||||
<span>{notesOpen ? "▲" : "▼"}</span>
|
||||
<span className="text-stone-500">{notesOpen ? Icons.chevronUp("h-4 w-4") : Icons.chevronDown("h-4 w-4")}</span>
|
||||
</button>
|
||||
{notesOpen && (
|
||||
<textarea
|
||||
@@ -282,7 +301,7 @@ export default function LotCreateForm({ brandId }: { brandId: string }) {
|
||||
disabled={isPending || !crop_type || !harvest_date || !field_location}
|
||||
className="rounded-xl bg-green-600 px-8 py-4 text-base font-bold text-white hover:bg-green-700 transition-colors disabled:opacity-50 flex items-center gap-2 shadow-sm"
|
||||
>
|
||||
{isPending ? "Creating..." : "🌱 Create Lot"}
|
||||
{isPending ? "Creating..." : <><span className="inline-flex items-center gap-1.5">{Icons.plant("h-4 w-4")} Create Lot</span></>}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,67 @@ import Link from "next/link";
|
||||
import StatusBadge from "./StatusBadge";
|
||||
import { HaulingLot, updateHarvestLotStatus } from "@/actions/route-trace/lots";
|
||||
|
||||
// One-color outline icons matching the design system
|
||||
const Icons = {
|
||||
clipboard: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||
<path d="M12 11h4M12 16h4M8 11h.01M8 16h.01"/>
|
||||
</svg>
|
||||
),
|
||||
search: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.3-4.3"/>
|
||||
</svg>
|
||||
),
|
||||
plus: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 12h14M12 5v14"/>
|
||||
</svg>
|
||||
),
|
||||
truck: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2"/>
|
||||
<path d="M15 18H9"/>
|
||||
<path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.624l-3.48-4.35A1 1 0 0 0 17.52 8H14"/>
|
||||
<circle cx="17" cy="18" r="2"/>
|
||||
<circle cx="7" cy="18" r="2"/>
|
||||
</svg>
|
||||
),
|
||||
package: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16.5 9.4 7.55 4.24"/>
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.29 7 12 12 20.71 7"/>
|
||||
<line x1="12" x2="12" y1="22" y2="12"/>
|
||||
</svg>
|
||||
),
|
||||
printer: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/>
|
||||
<path d="M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6"/>
|
||||
<rect x="6" y="14" width="12" height="8" rx="1"/>
|
||||
</svg>
|
||||
),
|
||||
file: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" x2="8" y1="13" y2="13"/>
|
||||
<line x1="16" x2="8" y1="17" y2="17"/>
|
||||
<line x1="10" x2="8" y1="9" y2="9"/>
|
||||
</svg>
|
||||
),
|
||||
camera: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
|
||||
<circle cx="12" cy="13" r="3" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ value: "", label: "All" },
|
||||
{ value: "active", label: "Active" },
|
||||
@@ -105,40 +166,70 @@ export default function LotListTable({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filter bar */}
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex gap-1 rounded-xl border border-stone-200 bg-white p-1">
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
<div className="space-y-5">
|
||||
{/* Search and filters */}
|
||||
<div className="rounded-2xl border border-stone-200 bg-white">
|
||||
<div className="border-b border-stone-100 px-6 py-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent-light)] shadow-sm">
|
||||
{Icons.clipboard("w-5 h-5 text-[var(--admin-accent)]")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-stone-900">All Lots</h2>
|
||||
<p className="text-sm text-stone-500">{filtered.length} lot{filtered.length !== 1 ? "s" : ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/route-trace/lots/new"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-bold text-white hover:bg-emerald-700 transition-colors shadow-sm"
|
||||
>
|
||||
{Icons.plus("h-4 w-4")}
|
||||
<span>New Lot</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Filter bar */}
|
||||
<div className="flex gap-1 rounded-xl border border-stone-200 bg-stone-50 p-1">
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => setFilter(f.value)}
|
||||
className={`rounded-lg px-3 py-2 text-xs font-semibold transition-colors flex-1 ${
|
||||
filter === f.value
|
||||
? "bg-emerald-600 text-white"
|
||||
: "text-stone-600 hover:bg-white"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Search */}
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400">{Icons.search("h-4 w-4")}</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search lot #, crop, field, or bin..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 pl-9 pr-4 py-3 text-base outline-none focus:border-emerald-600 focus:bg-white transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => setFilter(f.value)}
|
||||
className={`rounded-lg px-3 py-2 text-xs font-semibold transition-colors ${
|
||||
filter === f.value
|
||||
? "bg-emerald-600 text-white"
|
||||
: "text-stone-600 hover:bg-stone-50"
|
||||
onClick={() => { setShowBulk(!showBulk); setSelected(new Set()); }}
|
||||
className={`rounded-xl border px-4 py-3 text-sm font-semibold transition-colors flex items-center gap-2 ${
|
||||
showBulk ? "border-emerald-600 bg-emerald-600 text-white" : "border-stone-200 text-stone-600 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points={showBulk ? "9 11 12 14 22 4" : "3 6 9 12 15 18"}/>
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Bulk {showBulk ? "On" : "Off"}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search lot #, crop, field, or bin..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-56 rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-emerald-600"
|
||||
/>
|
||||
<button
|
||||
onClick={() => { setShowBulk(!showBulk); setSelected(new Set()); }}
|
||||
className={`rounded-xl border px-4 py-2.5 text-sm font-semibold transition-colors ${
|
||||
showBulk ? "border-emerald-600 bg-emerald-600 text-white" : "border-stone-200 text-stone-600 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
☑ Bulk {showBulk ? "On" : "Off"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -149,33 +240,38 @@ export default function LotListTable({
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={handleBulkMarkLoaded}
|
||||
className="rounded-xl bg-amber-600 hover:bg-amber-500 px-4 py-2 text-sm font-bold text-white transition-colors"
|
||||
className="rounded-xl bg-amber-600 hover:bg-amber-500 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
🚚 Mark Loaded
|
||||
{Icons.truck("h-4 w-4")}
|
||||
<span>Mark Loaded</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBulkMarkUsed}
|
||||
className="rounded-xl bg-amber-600 hover:bg-amber-500 px-4 py-2 text-sm font-bold text-white transition-colors"
|
||||
className="rounded-xl bg-amber-600 hover:bg-amber-500 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
📦 Mark as Used
|
||||
{Icons.package("h-4 w-4")}
|
||||
<span>Mark as Used</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleBulkStickers("field")}
|
||||
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors"
|
||||
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
🖨 Field Stickers
|
||||
{Icons.printer("h-4 w-4")}
|
||||
<span>Field Stickers</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleBulkStickers("shed")}
|
||||
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors"
|
||||
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
🖨 Shed Stickers
|
||||
{Icons.printer("h-4 w-4")}
|
||||
<span>Shed Stickers</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBulkExport}
|
||||
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors"
|
||||
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
📄 {selected.size === 1 ? "Report" : `${selected.size} Reports`}
|
||||
{Icons.file("h-4 w-4")}
|
||||
<span>{selected.size === 1 ? "Report" : `${selected.size} Reports`}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelected(new Set())}
|
||||
@@ -190,10 +286,11 @@ export default function LotListTable({
|
||||
{/* Table */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white py-16 text-center">
|
||||
<p className="text-sm text-stone-400">No lots found</p>
|
||||
<div className="text-stone-300 mb-3">{Icons.search("h-10 w-10")}</div>
|
||||
<p className="text-sm text-stone-500">No lots found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-2xl border border-stone-200 bg-white shadow-sm">
|
||||
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-stone-100 bg-stone-50">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user