From 15e939ad7e156638ac809d9b6e21589f452ae467 Mon Sep 17 00:00:00 2001 From: default Date: Tue, 2 Jun 2026 02:21:11 +0000 Subject: [PATCH] Admin AI Tools page: unified design system styling, provider selector with OpenAI turd emoji, free-text model input with exact ID warning --- src/actions/ai/preferences.ts | 77 + src/actions/communications/import-contacts.ts | 163 +++ src/actions/payments.ts | 2 + src/actions/products/upload-image.ts | 7 +- src/actions/stripe-connect.ts | 278 ++++ src/actions/time-tracking/index.ts | 19 +- src/app/admin/advanced/page.tsx | 34 + .../admin/communications/analytics/page.tsx | 25 +- .../communications/campaigns/[id]/page.tsx | 33 +- src/app/admin/communications/compose/page.tsx | 30 +- .../admin/communications/contacts/page.tsx | 43 +- src/app/admin/communications/logs/page.tsx | 40 +- src/app/admin/communications/page.tsx | 39 +- .../admin/communications/segments/page.tsx | 26 +- .../admin/communications/settings/page.tsx | 56 +- .../communications/templates/[id]/page.tsx | 39 +- .../admin/communications/templates/page.tsx | 35 +- src/app/admin/layout.tsx | 7 +- src/app/admin/orders/[id]/page.tsx | 192 +-- src/app/admin/orders/page.tsx | 36 +- src/app/admin/page.tsx | 300 +--- src/app/admin/products/page.tsx | 101 +- src/app/admin/route-trace/lookup/page.tsx | 6 + src/app/admin/route-trace/lots/[id]/page.tsx | 31 +- src/app/admin/route-trace/lots/new/page.tsx | 17 +- src/app/admin/route-trace/lots/page.tsx | 13 +- src/app/admin/route-trace/page.tsx | 75 +- src/app/admin/route-trace/settings/page.tsx | 6 + src/app/admin/settings/ai/AIClient.tsx | 1239 +++++++++++------ src/app/admin/settings/ai/page.tsx | 8 +- src/app/admin/settings/apps/page.tsx | 93 +- src/app/admin/settings/page.tsx | 37 +- src/app/admin/stops/page.tsx | 75 +- src/app/admin/time-tracking/page.tsx | 27 +- src/app/admin/time-tracking/settings/page.tsx | 4 +- .../api/route-trace/fsma-compliance/route.ts | 204 +++ src/app/api/route-trace/fsma-report/route.ts | 33 +- src/app/api/stripe/oauth/callback/route.ts | 101 ++ src/app/api/stripe/oauth/route.ts | 42 + src/app/trace/[lotNumber]/page.tsx | 15 + src/components/admin/AIProviderPanel.tsx | 306 ++-- src/components/admin/AddStopModal.tsx | 474 +++---- src/components/admin/AdminOrdersPanel.tsx | 684 ++++----- src/components/admin/AdminSidebar.tsx | 157 +-- src/components/admin/AdminStopsPanel.tsx | 359 +++++ src/components/admin/AdvancedAIPanel.tsx | 572 ++++++++ src/components/admin/AdvancedIntegrations.tsx | 384 +++++ src/components/admin/AdvancedPayments.tsx | 471 +++++++ .../admin/AdvancedSettingsClient.tsx | 253 ++++ src/components/admin/AdvancedShipping.tsx | 212 +++ src/components/admin/AdvancedSquareSync.tsx | 184 +++ src/components/admin/BrandFeatureCards.tsx | 162 ++- src/components/admin/CampaignListPanel.tsx | 394 ++++-- src/components/admin/CommunicationsNav.tsx | 135 +- src/components/admin/CommunicationsPage.tsx | 250 +++- src/components/admin/ContactImportForm.tsx | 316 +++-- src/components/admin/ContactListPanel.tsx | 187 ++- src/components/admin/DashboardClient.tsx | 376 +++++ src/components/admin/DashboardHeader.tsx | 20 +- src/components/admin/DriverPickupPanel.tsx | 273 ++-- src/components/admin/GlassModal.tsx | 112 ++ .../admin/HarvestReach/AnalyticsDashboard.tsx | 174 ++- .../HarvestReach/MatchingCustomersPanel.tsx | 111 +- .../admin/HarvestReach/SegmentBuilderPage.tsx | 36 +- .../HarvestReach/SegmentBuilderPanel.tsx | 73 +- .../admin/HarvestReach/SegmentEditModal.tsx | 75 +- .../admin/HarvestReach/SegmentListSidebar.tsx | 74 +- src/components/admin/MessageLogPanel.tsx | 433 ++++-- src/components/admin/OrderEditForm.tsx | 213 +-- src/components/admin/OrderPaymentSection.tsx | 92 +- src/components/admin/OrderPickupAction.tsx | 59 +- src/components/admin/PaymentSettingsForm.tsx | 65 +- src/components/admin/ProductsClient.tsx | 885 ++++++++++++ src/components/admin/ScheduleImportModal.tsx | 510 ++++--- src/components/admin/SettingsClient.tsx | 227 ++- src/components/admin/SettingsSections.tsx | 489 +++---- src/components/admin/StopsHeaderActions.tsx | 12 +- src/components/admin/TemplateEditForm.tsx | 390 ++++-- .../admin/TimeTrackingAdminPanel.tsx | 1038 +++++++++++--- .../admin/TimeTrackingSettingsClient.tsx | 56 +- .../admin/design-system/AdminActionMenu.tsx | 107 ++ .../admin/design-system/AdminBadge.tsx | 74 + .../admin/design-system/AdminCard.tsx | 48 + .../admin/design-system/AdminContainer.tsx | 16 + .../design-system/AdminDeleteConfirm.tsx | 80 ++ .../admin/design-system/AdminEmptyState.tsx | 36 + .../admin/design-system/AdminFilterBar.tsx | 132 ++ .../admin/design-system/AdminFormElements.tsx | 191 +++ .../admin/design-system/AdminLayout.tsx | 28 + .../admin/design-system/AdminModal.tsx | 68 + .../admin/design-system/AdminPageHeader.tsx | 39 + .../admin/design-system/AdminPagination.tsx | 122 ++ .../admin/design-system/AdminStatsBar.tsx | 33 + .../admin/design-system/AdminTable.tsx | 84 ++ src/components/admin/design-system/index.tsx | 22 + .../route-trace/AdminLookupPage.tsx | 102 +- .../route-trace/FsmaReportModal.tsx | 581 +++++++- src/components/route-trace/LotCreateForm.tsx | 25 +- src/components/route-trace/LotDetailPanel.tsx | 995 ++++++++----- src/components/route-trace/LotListTable.tsx | 181 ++- src/components/route-trace/QRScanModal.tsx | 244 ++-- .../route-trace/QuickNewLotDrawer.tsx | 405 ------ .../route-trace/QuickNewLotModal.tsx | 172 +++ .../route-trace/RouteTraceDashboard.tsx | 499 ++++--- src/components/route-trace/RouteTracePage.tsx | 173 ++- .../route-trace/RouteTraceSettings.tsx | 147 ++ src/components/route-trace/StatusBadge.tsx | 74 +- .../route-trace/StickerPreviewModal.tsx | 31 +- src/lib/admin-permissions.ts | 11 +- src/lib/route-trace-data.ts | 59 + src/styles/admin-design-system.css | 328 +++++ supabase/migrations/083_stripe_connect.sql | 140 ++ .../migrations/099_contact_imports_bucket.sql | 36 + .../migrations/143_enable_route_trace.sql | 4 + .../144_time_tracking_worker_number_rls.sql | 116 ++ .../145_create_product_images_bucket.sql | 13 + 116 files changed, 14991 insertions(+), 5326 deletions(-) create mode 100644 src/actions/ai/preferences.ts create mode 100644 src/actions/communications/import-contacts.ts create mode 100644 src/actions/stripe-connect.ts create mode 100644 src/app/admin/advanced/page.tsx create mode 100644 src/app/api/route-trace/fsma-compliance/route.ts create mode 100644 src/app/api/stripe/oauth/callback/route.ts create mode 100644 src/app/api/stripe/oauth/route.ts create mode 100644 src/components/admin/AdminStopsPanel.tsx create mode 100644 src/components/admin/AdvancedAIPanel.tsx create mode 100644 src/components/admin/AdvancedIntegrations.tsx create mode 100644 src/components/admin/AdvancedPayments.tsx create mode 100644 src/components/admin/AdvancedSettingsClient.tsx create mode 100644 src/components/admin/AdvancedShipping.tsx create mode 100644 src/components/admin/AdvancedSquareSync.tsx create mode 100644 src/components/admin/DashboardClient.tsx create mode 100644 src/components/admin/GlassModal.tsx create mode 100644 src/components/admin/ProductsClient.tsx create mode 100644 src/components/admin/design-system/AdminActionMenu.tsx create mode 100644 src/components/admin/design-system/AdminBadge.tsx create mode 100644 src/components/admin/design-system/AdminCard.tsx create mode 100644 src/components/admin/design-system/AdminContainer.tsx create mode 100644 src/components/admin/design-system/AdminDeleteConfirm.tsx create mode 100644 src/components/admin/design-system/AdminEmptyState.tsx create mode 100644 src/components/admin/design-system/AdminFilterBar.tsx create mode 100644 src/components/admin/design-system/AdminFormElements.tsx create mode 100644 src/components/admin/design-system/AdminLayout.tsx create mode 100644 src/components/admin/design-system/AdminModal.tsx create mode 100644 src/components/admin/design-system/AdminPageHeader.tsx create mode 100644 src/components/admin/design-system/AdminPagination.tsx create mode 100644 src/components/admin/design-system/AdminStatsBar.tsx create mode 100644 src/components/admin/design-system/AdminTable.tsx create mode 100644 src/components/admin/design-system/index.tsx delete mode 100644 src/components/route-trace/QuickNewLotDrawer.tsx create mode 100644 src/components/route-trace/QuickNewLotModal.tsx create mode 100644 src/components/route-trace/RouteTraceSettings.tsx create mode 100644 src/lib/route-trace-data.ts create mode 100644 src/styles/admin-design-system.css create mode 100644 supabase/migrations/083_stripe_connect.sql create mode 100644 supabase/migrations/099_contact_imports_bucket.sql create mode 100644 supabase/migrations/143_enable_route_trace.sql create mode 100644 supabase/migrations/144_time_tracking_worker_number_rls.sql create mode 100644 supabase/migrations/145_create_product_images_bucket.sql diff --git a/src/actions/ai/preferences.ts b/src/actions/ai/preferences.ts new file mode 100644 index 0000000..5d19b40 --- /dev/null +++ b/src/actions/ai/preferences.ts @@ -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." }; + } +} \ No newline at end of file diff --git a/src/actions/communications/import-contacts.ts b/src/actions/communications/import-contacts.ts new file mode 100644 index 0000000..e45454a --- /dev/null +++ b/src/actions/communications/import-contacts.ts @@ -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 { + 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 { + 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; +}; \ No newline at end of file diff --git a/src/actions/payments.ts b/src/actions/payments.ts index b66e05a..c182359 100644 --- a/src/actions/payments.ts +++ b/src/actions/payments.ts @@ -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, diff --git a/src/actions/products/upload-image.ts b/src/actions/products/upload-image.ts index d7cb0e5..1535964 100644 --- a/src/actions/products/upload-image.ts +++ b/src/actions/products/upload-image.ts @@ -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__") { diff --git a/src/actions/stripe-connect.ts b/src/actions/stripe-connect.ts new file mode 100644 index 0000000..bf413f9 --- /dev/null +++ b/src/actions/stripe-connect.ts @@ -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 }; + } +} \ No newline at end of file diff --git a/src/actions/time-tracking/index.ts b/src/actions/time-tracking/index.ts index cec0277..f5555e9 100644 --- a/src/actions/time-tracking/index.ts +++ b/src/actions/time-tracking/index.ts @@ -18,12 +18,15 @@ function rpcBody(body: Record) { 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 ({ 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) => ({ + 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( diff --git a/src/app/admin/advanced/page.tsx b/src/app/admin/advanced/page.tsx new file mode 100644 index 0000000..f8fd438 --- /dev/null +++ b/src/app/admin/advanced/page.tsx @@ -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 ( + + ); +} \ No newline at end of file diff --git a/src/app/admin/communications/analytics/page.tsx b/src/app/admin/communications/analytics/page.tsx index 1033a8b..af8aba7 100644 --- a/src/app/admin/communications/analytics/page.tsx +++ b/src/app/admin/communications/analytics/page.tsx @@ -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 ( -
-
- -
-
- ); +export default function AnalyticsPage() { + redirect("/admin/communications"); } \ No newline at end of file diff --git a/src/app/admin/communications/campaigns/[id]/page.tsx b/src/app/admin/communications/campaigns/[id]/page.tsx index c9b01d2..566960d 100644 --- a/src/app/admin/communications/campaigns/[id]/page.tsx +++ b/src/app/admin/communications/campaigns/[id]/page.tsx @@ -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 ( -
-
-
-

Harvest Reach

-
- - -
-
+ ); } \ No newline at end of file diff --git a/src/app/admin/communications/compose/page.tsx b/src/app/admin/communications/compose/page.tsx index e6e7241..1c0ad62 100644 --- a/src/app/admin/communications/compose/page.tsx +++ b/src/app/admin/communications/compose/page.tsx @@ -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 ( -
-
- -
-
+ ); } \ No newline at end of file diff --git a/src/app/admin/communications/contacts/page.tsx b/src/app/admin/communications/contacts/page.tsx index c019da2..9ef6b8a 100644 --- a/src/app/admin/communications/contacts/page.tsx +++ b/src/app/admin/communications/contacts/page.tsx @@ -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 ( -
-
-
-

Harvest Reach

-

- Manage email campaigns, templates, contacts, and message history. -

-
- - -
-
- ); -} +export default function ContactsPage() { + redirect("/admin/communications"); +} \ No newline at end of file diff --git a/src/app/admin/communications/logs/page.tsx b/src/app/admin/communications/logs/page.tsx index 532675a..6402e0b 100644 --- a/src/app/admin/communications/logs/page.tsx +++ b/src/app/admin/communications/logs/page.tsx @@ -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 ( -
-
-
-

Harvest Reach

-

- Manage email campaigns, templates, and message history. -

-
- - -
-
- ); +export default function LogsPage() { + redirect("/admin/communications"); } \ No newline at end of file diff --git a/src/app/admin/communications/page.tsx b/src/app/admin/communications/page.tsx index f2745c1..9160d50 100644 --- a/src/app/admin/communications/page.tsx +++ b/src/app/admin/communications/page.tsx @@ -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 ( -
-
- {/* Breadcrumb */} - - -
-

Harvest Reach

-

- Manage email campaigns, templates, and message history. -

-
- - -
-
+ ); } \ No newline at end of file diff --git a/src/app/admin/communications/segments/page.tsx b/src/app/admin/communications/segments/page.tsx index 7fc18e4..e44ce72 100644 --- a/src/app/admin/communications/segments/page.tsx +++ b/src/app/admin/communications/segments/page.tsx @@ -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 ( -
-
- -
-
- ); +export default function SegmentsPage() { + redirect("/admin/communications"); } \ No newline at end of file diff --git a/src/app/admin/communications/settings/page.tsx b/src/app/admin/communications/settings/page.tsx index 0e1183b..d062e1e 100644 --- a/src/app/admin/communications/settings/page.tsx +++ b/src/app/admin/communications/settings/page.tsx @@ -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 ( -
-
-
-

Harvest Reach

-

- Manage email campaigns, templates, and message history. -

+
+
+ {/* Back button */} + + + + + Back to Harvest Reach + + + {/* Header */} +
+
+ + + + +
+
+

Harvest Reach Settings

+

Configure email and SMS integration

+
- + {/* Settings Form */} +
+ +
-
+ ); } \ No newline at end of file diff --git a/src/app/admin/communications/templates/[id]/page.tsx b/src/app/admin/communications/templates/[id]/page.tsx index b6c5fdf..bef597f 100644 --- a/src/app/admin/communications/templates/[id]/page.tsx +++ b/src/app/admin/communications/templates/[id]/page.tsx @@ -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 ( -
-
-
-

Harvest Reach

-
- - -
-
+ ); } \ No newline at end of file diff --git a/src/app/admin/communications/templates/page.tsx b/src/app/admin/communications/templates/page.tsx index e26c5c5..4ad5d76 100644 --- a/src/app/admin/communications/templates/page.tsx +++ b/src/app/admin/communications/templates/page.tsx @@ -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 ( -
-
-
-

Harvest Reach

-

- Manage email campaigns, templates, and message history. -

-
- - -
-
- ); +export default function TemplatesPage() { + redirect("/admin/communications"); } \ No newline at end of file diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index dcdc25e..7f5ce5c 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -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 ( <> -
- +
+
); @@ -24,7 +25,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN return ( <> -
+
{children}
diff --git a/src/app/admin/orders/[id]/page.tsx b/src/app/admin/orders/[id]/page.tsx index 878beb5..62043e2 100644 --- a/src/app/admin/orders/[id]/page.tsx +++ b/src/app/admin/orders/[id]/page.tsx @@ -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 ( -
+
-

Order not found

← Back to Orders +
+

Order not found

+
); @@ -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 ( -
-
- - ← Back to Orders - - - {/* Customer info */} -
-
+
+
+ {/* Header */} +
+
+ + + + +
-

- Customer -

-

- {order.customer_name} -

-
-
- - {order.pickup_complete ? "Picked Up" : "Pending"} - - +

Order Details

+

{formatDate(order.created_at)}

+
+ + {order.pickup_complete ? "✓ Picked Up" : "⏳ Pending"} + + {order.payment_processor && ( + + {order.payment_processor} + + )} +
+
-
+ {/* Customer info */} +
+
+
+

Customer

+

{order.customer_name}

+
+ +
+ +
{order.customer_phone && (
-

Phone

-

{order.customer_phone}

+

Phone

+

{order.customer_phone}

)} {order.customer_email && (
-

Email

-

{order.customer_email}

+

Email

+

{order.customer_email}

)}
@@ -105,84 +123,76 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps) {/* Stop info */} {order.stops && ( -
-

Stop

-

+

+

Pickup Location

+

{order.stops.city}, {order.stops.state}

-

{order.stops.date}

+

{formatDate(order.stops.date)}

)} {/* Order items */} -
-

Order Items

+
+

Order Items

{order.order_items && order.order_items.length > 0 ? ( -
+
{order.order_items.map((item: any) => (
-

+

{item.products?.name ?? "Unknown Product"}

-

Qty: {item.quantity}

+

Qty: {item.quantity} × {formatCurrency(Number(item.price))}

-

- ${(Number(item.price) * item.quantity).toFixed(2)} +

+ {formatCurrency(Number(item.price) * item.quantity)}

))}
) : ( -

No items found.

+

No items found

)} -
+ {/* Totals */} +
Subtotal - ${subtotal.toFixed(2)} + {formatCurrency(subtotal)}
- {Number(order.tax_amount ?? 0) > 0 && ( + {taxAmount > 0 && (
- Tax ({order.tax_rate ? `${(Number(order.tax_rate) * 100).toFixed(3)}%` : ""}) - ${Number(order.tax_amount).toFixed(2)} + Tax + {formatCurrency(taxAmount)}
)} {discount_amount > 0 && ( - <> -
- Discount - -${discount_amount.toFixed(2)} -
+
+ Discount + -{formatCurrency(discount_amount)} {order.discount_reason && ( -

{order.discount_reason}

+ ({order.discount_reason}) )} - +
)} -
+
Total - ${(subtotal + Number(order.tax_amount ?? 0) - discount_amount).toFixed(2)} + {formatCurrency(total)}
- {order.tax_location && ( -

Tax sourced from: {order.tax_location}

- )}
{/* Payment & Refunds */} -
-

- Payment & Refunds -

-

- Record payment details and manage refunds for this order. -

+
+

Payment & Refunds

+

Record payment details and manage refunds

-
+
{/* Edit form */} -
-

Edit Order

-

- Update customer details, pricing, and status. -

+
+

Edit Order

+

Update customer details, pricing, and status

-
+
- {/* Internal notes display */} + {/* Internal notes */} {order.internal_notes && ( -
-

Internal Notes

-

{order.internal_notes}

+
+

Internal Notes

+

{order.internal_notes}

)}
); -} +} \ No newline at end of file diff --git a/src/app/admin/orders/page.tsx b/src/app/admin/orders/page.tsx index d1a0a19..3c4ac7e 100644 --- a/src/app/admin/orders/page.tsx +++ b/src/app/admin/orders/page.tsx @@ -29,12 +29,34 @@ export default async function AdminOrdersPage() { : orders; return ( -
- -
+
+ {/* Header */} +
+
+
+ + + + + +
+
+

Orders

+

Manage customer orders and pickup status

+
+
+
+ + {/* Content */} +
+
+ +
+
+
); } \ No newline at end of file diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 1ce294f..ecf73a6 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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 = {}; + let enabledAddons: Record = {}; 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 ( -
-
- {/* Header */} - - - {/* Usage bar */} -
-
- - {planTier.charAt(0).toUpperCase() + planTier.slice(1)} Plan - - {adminUser?.brand_id ? "Tuxedo Corn" : "All Brands"} -
-
- {[ - { 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 }) => ( -
-
- {label} - {value} -
-
-
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)}%` }} - /> -
-
- ))} -
-
- - {/* Grouped sections */} - {allGroups.map(({ key, label, cols, sections: groupSections }) => { - if (groupSections.length === 0) return null; - return ( -
-
-

{label}

-
-
-
- {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 ( - -
-
- - - -
- {isAddon && !isEnabled && ( - - Add-on - - )} - {isAddon && isEnabled && ( - - Active - - )} - {isProminent && ( - - Core - - )} -
- -

- {section.title} -

- -

- {section.description} -

- - {isAddon && !isEnabled && section.upgradeText && ( -

{section.upgradeText}

- )} - - ); - })} -
-
- ); - })} -
-
+ ); } \ No newline at end of file diff --git a/src/app/admin/products/page.tsx b/src/app/admin/products/page.tsx index ef84d7d..f8f649a 100644 --- a/src/app/admin/products/page.tsx +++ b/src/app/admin/products/page.tsx @@ -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) => ( + + + + + + + ), +}; export default async function AdminProductsPage() { const adminUser = await getAdminUser(); @@ -13,18 +21,17 @@ export default async function AdminProductsPage() { if (!adminUser) return ; if (!adminUser.can_manage_products) { - redirect("/admin/pickup"); + return ( +
+
+

Access Denied

+

You do not have permission to manage products.

+
+
+ ); } - 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 ( -
+
- -

Error loading products

-
+          

Error loading products

+
             {error.message}
           
@@ -71,36 +70,26 @@ export default async function AdminProductsPage() { } return ( -
-
- {/* Breadcrumb */} - - -
-
-

Products

-

- Manage your product catalog. -

+
+ {/* Header */} +
+
+
+ {Icons.package("h-5 w-5 sm:h-6 sm:w-6 text-white")} +
+
+

Products

+

Manage your product catalog

- - - Add Product - -
- -
- -
-
+ + {/* Content */} +
+
+ +
+
+
); } \ No newline at end of file diff --git a/src/app/admin/route-trace/lookup/page.tsx b/src/app/admin/route-trace/lookup/page.tsx index d24f5fe..a839346 100644 --- a/src/app/admin/route-trace/lookup/page.tsx +++ b/src/app/admin/route-trace/lookup/page.tsx @@ -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"); diff --git a/src/app/admin/route-trace/lots/[id]/page.tsx b/src/app/admin/route-trace/lots/[id]/page.tsx index 80455d7..e18686e 100644 --- a/src/app/admin/route-trace/lots/[id]/page.tsx +++ b/src/app/admin/route-trace/lots/[id]/page.tsx @@ -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 ( -
+
-
+
@@ -34,10 +53,10 @@ export default async function LotDetailPage({ params }: { params: Promise<{ id: } return ( -
+
+
diff --git a/src/app/admin/route-trace/lots/page.tsx b/src/app/admin/route-trace/lots/page.tsx index 9171b46..f9d6e2b 100644 --- a/src/app/admin/route-trace/lots/page.tsx +++ b/src/app/admin/route-trace/lots/page.tsx @@ -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([ diff --git a/src/app/admin/route-trace/page.tsx b/src/app/admin/route-trace/page.tsx index b47370d..fd9c343 100644 --- a/src/app/admin/route-trace/page.tsx +++ b/src/app/admin/route-trace/page.tsx @@ -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) => ( + + + + + + + + + ), +}; + 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 ( - +
+ {/* Header */} +
+
+
+
+
+ {Icons.routeTrace("h-5 w-5 sm:h-6 sm:w-6 text-white")} +
+
+

Route Trace

+

Field-to-delivery lot traceability

+
+
+
+
+
+ + {/* Content */} +
+ +
+
); } \ No newline at end of file diff --git a/src/app/admin/route-trace/settings/page.tsx b/src/app/admin/route-trace/settings/page.tsx index 9bd3ce3..7b233f0 100644 --- a/src/app/admin/route-trace/settings/page.tsx +++ b/src/app/admin/route-trace/settings/page.tsx @@ -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"); diff --git a/src/app/admin/settings/ai/AIClient.tsx b/src/app/admin/settings/ai/AIClient.tsx index 30db6c7..e75e5c2 100644 --- a/src/app/admin/settings/ai/AIClient.tsx +++ b/src/app/admin/settings/ai/AIClient.tsx @@ -2,7 +2,213 @@ import { useState, useEffect } from "react"; import Link from "next/link"; -import { getBrandSettings } from "@/actions/brand-settings"; +import { AdminCard } from "@/components/admin/design-system"; +import { setAIProviderSettings } from "@/actions/integrations/ai-providers"; + +// ── Header Icon Component ───────────────────────────────────────────────────── + +function AIHeaderIcon() { + return ( +
+ + + +
+ ); +} + +// ── Tool Card Component ─────────────────────────────────────────────────────── + +type ToolCardProps = { + tool: AITool; + isConnected: boolean; + onOpen: (tool: AITool) => void; +}; + +function ToolCard({ tool, isConnected, onOpen }: ToolCardProps) { + const isReady = tool.status === "available" && isConnected; + + return ( + +
+ {tool.icon} +
+
+

{tool.title}

+ {tool.badge && ( + + {tool.badge} + + )} + {tool.status === "available" && ( + + {isConnected ? "Ready" : "Disabled"} + + )} + {tool.status === "experimental" && ( + + Experimental + + )} + {tool.status === "coming_soon" && ( + + Coming Soon + + )} +
+ {tool.module} +

{tool.description}

+
+
+
+ {tool.status === "available" && ( + + )} + {tool.status === "coming_soon" && ( + + )} + {tool.status === "experimental" && ( + + )} +
+
+ ); +} + +// ── Connection Status Banner ─────────────────────────────────────────────────── + +function ConnectionStatusBanner({ isConnected, availableCount, onConfigure }: { + isConnected: boolean; + availableCount: number; + onConfigure: () => void; +}) { + if (isConnected) { + return ( + +
+
+ + + +
+
+

AI Connected

+

{availableCount} tool{availableCount !== 1 ? "s" : ""} ready to use

+
+ + Active + +
+
+ ); + } + + return ( + +
+
+ + + +
+
+

AI Not Configured

+

Add your API key to enable AI tools.

+
+ +
+
+ ); +} type ToolStatus = "available" | "coming_soon" | "experimental"; @@ -84,10 +290,195 @@ const AI_TOOLS: AITool[] = [ }, ]; +type Provider = "openai" | "anthropic" | "google" | "xai" | "custom"; + type Props = { isConnected: boolean; brandId: string; brandName: string; + provider?: Provider; + model?: string; + customEndpoint?: string; +}; + +// ── Provider Selector ───────────────────────────────────────────────────────── + +type ProviderOption = { + id: Provider; + name: string; + icon: string; + description: string; +}; + +const PROVIDERS: ProviderOption[] = [ + { id: "openai", name: "OpenAI", icon: "💩", description: "GPT-4, GPT-4o, o1" }, + { id: "anthropic", name: "Anthropic", icon: "🧠", description: "Claude 3.5 Sonnet, Opus" }, + { id: "google", name: "Google", icon: "🔴", description: "Gemini 2.0 Flash, Pro" }, + { id: "xai", name: "xAI", icon: "⚡", description: "Grok-2, Grok-1.5" }, + { id: "custom", name: "Custom", icon: "🔧", description: "Bring your own endpoint" }, +]; + +// ── Model Input ─────────────────────────────────────────────────────────────── + +function ModelInput({ + currentModel, + brandId, + onChange +}: { + currentModel: string; + brandId: string; + onChange: (model: string) => void; +}) { + const [customModel, setCustomModel] = useState(currentModel); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(null); + + const handleSave = async () => { + if (!customModel.trim()) return; + setSaving(true); + setError(null); + + const result = await setAIProviderSettings(brandId, { model: customModel.trim() }); + if (result.success) { + onChange(customModel.trim()); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + } else { + setError(result.error ?? "Failed to save"); + } + setSaving(false); + }; + + return ( + +
+ 🤖 +

+ Model +

+
+
+ setCustomModel(e.target.value)} + placeholder="e.g., gpt-4o, claude-3-5-sonnet-20241002" + className="flex-1 px-4 py-2.5 rounded-xl text-sm" + style={{ + border: '1px solid var(--admin-border)', + backgroundColor: 'var(--admin-card-bg)', + color: 'var(--admin-text-primary)', + }} + /> + +
+

+ ⚠️ Model ID must be exact — check the provider's documentation for the correct identifier. +

+ {error &&

{error}

} +
+ ); +} + +// ── Provider Selector Component ───────────────────────────────────────────── + +function ProviderSelector({ + currentProvider, + currentModel, + brandId, + onSelect +}: { + currentProvider: Provider; + currentModel: string; + brandId: string; + onSelect: (provider: Provider) => void; +}) { + const handleSelect = async (provider: Provider) => { + onSelect(provider); + await setAIProviderSettings(brandId, { provider }); + }; + + return ( +
+
+ 💩 +

+ AI Provider +

+
+
+ {PROVIDERS.map((provider) => ( + + ))} +
+

+ Current model: {currentModel} +

+
+ ); +} + +// ── Form Input Styles (Shared) ──────────────────────────────────────────────── + +const inputBaseClass = "w-full rounded-xl px-4 py-2.5 text-sm outline-none transition-colors"; +const inputStyle = { + border: '1px solid var(--admin-border)', + backgroundColor: 'var(--admin-card-bg)', + color: 'var(--admin-text-primary)', +}; +const inputFocusStyle = { + borderColor: 'var(--admin-accent)', + boxShadow: '0 0 0 3px rgba(202, 117, 67, 0.1)', +}; + +const textareaStyle = { ...inputStyle }; +const textareaFocusStyle = { ...inputFocusStyle }; + +const btnPrimaryClass = "rounded-xl px-5 py-2.5 text-sm font-bold text-white transition-all"; +const btnPrimaryStyle = { + background: 'linear-gradient(135deg, #ca7543 0%, #d4865a 100%)', + boxShadow: '0 2px 8px rgba(202, 117, 67, 0.25)', +}; + +const labelStyle = { + display: 'block', + fontSize: '0.875rem', + fontWeight: 500, + marginBottom: '0.25rem', + color: 'var(--admin-text-primary)', }; // ── Campaign Writer Tool ────────────────────────────────────────────────────── @@ -123,31 +514,37 @@ function CampaignWriterTool({ brandId, brandName }: { brandId: string; brandName return (
- +