diff --git a/next.config.ts b/next.config.ts index f8c2c9e..074e801 100644 --- a/next.config.ts +++ b/next.config.ts @@ -124,6 +124,17 @@ const nextConfig: NextConfig = { { source: "/admin/orders/:id", destination: "/admin/v2/orders/:id", permanent: false }, { source: "/admin/stops", destination: "/admin/v2/stops", permanent: false }, { source: "/admin/products", destination: "/admin/v2/products", permanent: false }, + // v1 admin pages that hit the dead supabase shim. There are no v2 + // equivalents yet (reports / taxes / settings sub-pages are still + // TBD on the v2 surface), so they redirect to the v2 dashboard + // for now. Once those modules ship on v2, the redirects can be + // tightened to point at the new pages. + { source: "/admin/products/:id", destination: "/admin/v2/products", permanent: false }, + { source: "/admin/reports", destination: "/admin/v2", permanent: false }, + { source: "/admin/taxes", destination: "/admin/v2", permanent: false }, + { source: "/admin/settings/shipping", destination: "/admin/settings", permanent: false }, + { source: "/admin/settings/integrations", destination: "/admin/settings", permanent: false }, + { source: "/admin/settings/billing", destination: "/admin/settings", permanent: false }, ]; }, diff --git a/src/actions/ai/preferences.ts b/src/actions/ai/preferences.ts index 5d1af25..384c3c5 100644 --- a/src/actions/ai/preferences.ts +++ b/src/actions/ai/preferences.ts @@ -1,6 +1,6 @@ "use server"; -import { supabase } from "@/lib/supabase"; +import { pool } from "@/lib/db"; export type AIAuthConfig = { provider: string; @@ -11,6 +11,14 @@ export type AIAuthConfig = { max_tokens: number; }; +type AIRow = { + api_key: string | null; + organization_id: string | null; + base_url: string | null; + model: string | null; + max_tokens: number | null; +}; + export async function getAIPreferences(brandId: string): Promise<{ api_key?: string; organization_id?: string; @@ -18,35 +26,58 @@ export async function getAIPreferences(brandId: string): Promise<{ 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; + const { rows } = await pool.query( + `SELECT api_key, organization_id, base_url, model, max_tokens + FROM brand_ai_settings + WHERE brand_id = $1 + LIMIT 1`, + [brandId], + ); + const row = rows[0]; + if (!row) return null; + return { + api_key: row.api_key ?? undefined, + organization_id: row.organization_id ?? undefined, + base_url: row.base_url ?? undefined, + model: row.model ?? undefined, + max_tokens: row.max_tokens ?? undefined, + }; } export async function saveAIPreferences( brandId: string, config: AIAuthConfig ): Promise<{ success: boolean; error?: string }> { - const result = 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(), - }) as { data: unknown; error: { message: string } | null }; - - if (result.error) return { success: false, error: result.error.message }; - return { success: true }; + try { + await pool.query( + `INSERT INTO brand_ai_settings + (brand_id, provider, api_key, organization_id, base_url, model, max_tokens, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + ON CONFLICT (brand_id) DO UPDATE + SET provider = EXCLUDED.provider, + api_key = EXCLUDED.api_key, + organization_id = EXCLUDED.organization_id, + base_url = EXCLUDED.base_url, + model = EXCLUDED.model, + max_tokens = EXCLUDED.max_tokens, + updated_at = NOW()`, + [ + brandId, + config.provider, + config.api_key || null, + config.organization_id || null, + config.base_url || null, + config.model || "gpt-4o-mini", + config.max_tokens || 4000, + ], + ); + return { success: true }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : "Failed to save AI preferences", + }; + } } export async function testAIConnection(config: AIAuthConfig): Promise<{ ok: boolean; message: string }> { diff --git a/src/actions/brand-settings.ts b/src/actions/brand-settings.ts index c58f87e..7da8e01 100644 --- a/src/actions/brand-settings.ts +++ b/src/actions/brand-settings.ts @@ -243,14 +243,24 @@ export async function getBrandSettings(brandId: string): Promise { - // Wrapped in try/catch so a build-time DB outage (ECONNREFUSED) doesn't - // crash the prerender — the page just falls back to its default brand - // name and revalidates from a real request later. try { const { rows } = await pool.query( - "SELECT * FROM get_brand_settings_by_slug($1)", + `SELECT bs.*, b.name AS brand_name, ws.wholesale_enabled + FROM brands b + JOIN brand_settings bs ON bs.brand_id = b.id + LEFT JOIN wholesale_settings ws ON ws.brand_id = b.id + WHERE b.slug = $1 + LIMIT 1`, [brandSlug], ); const data = rows[0]; diff --git a/src/actions/square-sync-ui.ts b/src/actions/square-sync-ui.ts index 7bb244e..49dacef 100644 --- a/src/actions/square-sync-ui.ts +++ b/src/actions/square-sync-ui.ts @@ -76,4 +76,31 @@ export async function getSyncLog(brandId: string): Promise<{ ); return { success: true, logs }; +} + +/** + * Pending-item count for the dashboard widget badge. Used by + * `SquareSyncWidget` so it can refresh without re-running the full + * sync log query. Returns 0 when the caller isn't authorized — never + * throws, so callers can render "0" while the auth check settles. + */ +export async function getSquareQueueCount(brandId: string): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return 0; + try { + assertBrandAccess(adminUser, brandId); + } catch { + return 0; + } + try { + const { rows } = await pool.query<{ count: string }>( + `SELECT COUNT(*)::text AS count + FROM square_sync_queue + WHERE brand_id = $1 AND status = 'pending'`, + [brandId], + ); + return Number(rows[0]?.count ?? 0); + } catch { + return 0; + } } \ No newline at end of file diff --git a/src/actions/storefront.ts b/src/actions/storefront.ts index 8671d63..67cbd31 100644 --- a/src/actions/storefront.ts +++ b/src/actions/storefront.ts @@ -164,6 +164,47 @@ export async function getStorefrontWholesaleSettings( return rows[0] ?? null; } +export type WholesalePortalConfig = { + wholesale_enabled: boolean | null; + online_payment_enabled: boolean | null; +}; + +/** + * Public wholesale portal flags for a brand. Used by the customer-facing + * `/wholesale/portal` page to decide whether to allow sign-in and whether + * to show the online-payment button. Doesn't require auth (the portal + * already validated the session before this is called). + */ +export async function getWholesalePortalConfig( + brandId: string, +): Promise { + const { rows } = await pool.query( + `SELECT wholesale_enabled, online_payment_enabled + FROM wholesale_settings + WHERE brand_id = $1 + LIMIT 1`, + [brandId], + ); + return rows[0] ?? null; +} + +export type BrandName = { name: string }; + +/** + * Fetch a brand's display name by id. Public — used by the wholesale + * portal header to render the brand name without leaking any other + * brand columns (logo, plan, stripe keys, etc.). + */ +export async function getStorefrontBrandName( + brandId: string, +): Promise { + const { rows } = await pool.query( + `SELECT name FROM brands WHERE id = $1 LIMIT 1`, + [brandId], + ); + return rows[0]?.name ?? null; +} + /** * Look up a single public stop by its URL slug, plus the brand's * active products. Used by `/[brand]/stops/[slug]` pages. diff --git a/src/app/admin/orders/page.tsx b/src/app/admin/orders/page.tsx deleted file mode 100644 index cd3460f..0000000 --- a/src/app/admin/orders/page.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import AdminOrdersPanel from "@/components/admin/AdminOrdersPanel"; -import { getAdminUser } from "@/lib/admin-permissions"; -import { getActiveBrandId } from "@/lib/brand-scope"; -import { getAdminOrders } from "@/actions/orders"; -import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; -import { PageHeader } from "@/components/admin/design-system"; -import { redirect } from "next/navigation"; -import { supabase } from "@/lib/supabase"; - -export const dynamic = "force-dynamic"; - -export default async function AdminOrdersPage() { - const adminUser = await getAdminUser(); - - if (!adminUser) return ; - - if (!adminUser.can_manage_orders) { - redirect("/admin/pickup"); - } - - const activeBrandId = await getActiveBrandId(adminUser); - // Platform admin can browse all brands' orders; everyone else must have a brand - if (!activeBrandId && adminUser.role !== "platform_admin") { - return ; - } - - const { orders, stops } = await getAdminOrders(); - - const brandStops = activeBrandId - ? stops.filter((s) => s.brand_id === activeBrandId) - : stops; - - const brandOrders = activeBrandId - ? orders.filter( - (o) => - o.stops && brandStops.some((s) => s.id === o.stop_id) - ) - : orders; - - // Fetch active products for this brand (for admin "New Order" item picker) - let brandProducts: Array<{ id: string; name: string; price: number; type?: string | null; active?: boolean }> = []; - try { - let prodQuery = supabase - .from("products") - .select("id, name, price, type, active") - .eq("active", true) - .is("deleted_at", null) - .order("name") - .limit(200); - - if (activeBrandId) { - prodQuery = prodQuery.eq("brand_id", activeBrandId); - } - - const { data: prods } = await prodQuery; - brandProducts = (prods ?? []).map((p) => ({ - id: String(p.id), - name: String(p.name ?? ""), - price: Number(p.price), - type: p.type as string | null ?? null, - active: Boolean(p.active ?? true), - })); - } catch { - // non-fatal for the orders list - } - - return ( -
-
-

Operations

- - - - - - } - /> -
- - {/* Content */} -
-
- -
-
-
- ); -} \ No newline at end of file diff --git a/src/app/admin/products/[id]/page.tsx b/src/app/admin/products/[id]/page.tsx deleted file mode 100644 index e13a2b7..0000000 --- a/src/app/admin/products/[id]/page.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { supabase } from "@/lib/supabase"; -import ProductEditForm from "@/components/admin/ProductEditForm"; -import { getAdminUser } from "@/lib/admin-permissions"; -import { PageHeader, AdminBadge } from "@/components/admin/design-system"; -import { Package as PackageIcon } from "lucide-react"; -import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; -import { redirect } from "next/navigation"; -import Link from "next/link"; - -type ProductDetailPageProps = { - params: Promise<{ - id: string; - }>; -}; - -interface Product { - id: string; - brand_id: string; - name: string; - description: string | null; - price: number; - active: boolean; - type: string | null; - image_url: string | null; - is_taxable: boolean; - pickup_type: string | null; - brands?: { name: string; slug: string }; -} - -interface Brand { - id: string; - name: string; - slug: string; -} - -export default async function ProductDetailPage({ params }: ProductDetailPageProps) { - const { id } = await params; - - const [{ data: product, error }, { data: brands }] = await Promise.all([ - supabase.from("products").select("*, brands(name, slug)").eq("id", id).single() as unknown as { data: Product | null; error: { message: string } | null }, - supabase.from("brands").select("id, name, slug") as unknown as { data: Brand[] | null }, - ]); - - const adminUser = await getAdminUser(); - - if (!adminUser) return ; - - if (!adminUser.can_manage_products) redirect("/admin/pickup"); - - if (adminUser?.brand_id && product?.brand_id !== adminUser.brand_id) { - return ; - } - - if (error || !product) { - return ( -
-
-

Product not found

-
-            {error?.message ?? "Product not found"}
-          
- - ← Back to Products - -
-
- ); - } - - return ( -
-
-
Operations
- } - title="Edit product" - subtitle="Update product details, pricing, and availability." - actions={ - - {product.active ? "Active" : "Inactive"} - - } - /> - -
- - ← Back to Products - -
- -
-
-
-

- {product.brands?.name} -

-

- {product.name} -

-

- {product.description} -

-
-
- -
-
-

Price

-

- ${Number(product.price).toFixed(2)} -

-
-
-

Type

-

- {product.type} -

-
-
-
- -
-

Edit Product

-

- Update product details, pricing, and availability. -

- -
- -
-
-
-
- ); -} \ No newline at end of file diff --git a/src/app/admin/reports/page.tsx b/src/app/admin/reports/page.tsx deleted file mode 100644 index fd2bee2..0000000 --- a/src/app/admin/reports/page.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { supabase } from "@/lib/supabase"; -import { getAdminUser } from "@/lib/admin-permissions"; -import { getActiveBrandId } from "@/lib/brand-scope"; -import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; -import ReportsDashboard from "@/components/admin/ReportsDashboard"; -import { PageHeader } from "@/components/admin/design-system"; -import { redirect } from "next/navigation"; - -export default async function ReportsPage() { - const adminUser = await getAdminUser(); - if (!adminUser) return ; - if (!adminUser.can_manage_reports) redirect("/admin/pickup"); - - const isPlatformAdmin = adminUser.role === "platform_admin"; - - const { data: brands } = await supabase - .from("brands") - .select("id, name") - .order("name") as unknown as { data: { id: string; name: string }[] | null }; - - const initialBrandId = isPlatformAdmin - ? null - : await getActiveBrandId(adminUser); - - return ( -
-
- - - -
-
- ); -} \ No newline at end of file diff --git a/src/app/admin/settings/billing/page.tsx b/src/app/admin/settings/billing/page.tsx deleted file mode 100644 index d4c25fd..0000000 --- a/src/app/admin/settings/billing/page.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { supabase } from "@/lib/supabase"; -import { getAdminUser } from "@/lib/admin-permissions"; -import { getActiveBrandId } from "@/lib/brand-scope"; -import { getBillingOverview } from "@/actions/billing/billing-overview"; -import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; -import BillingClientPage from "./BillingClientPage"; - -type Props = { - params: Promise<{ brandId?: string }>; -}; - -export default async function BillingPage({ params }: Props) { - const { brandId: brandIdParam } = await params; - const adminUser = await getAdminUser(); - if (!adminUser) return ; - - const activeBrandId = await getActiveBrandId(adminUser, brandIdParam); - if (!activeBrandId && adminUser.role !== "platform_admin") { - return ; - } - const effectiveBrandId = activeBrandId ?? ""; - const isPlatformAdmin = adminUser.role === "platform_admin"; - - let resolvedBrandId = effectiveBrandId; - if (isPlatformAdmin && !resolvedBrandId) { - const { data: firstBrand } = await supabase - .from("brands") - .select("id") - .limit(1) - .single(); - if (firstBrand?.id) { - resolvedBrandId = String(firstBrand.id); - } else { - return ( -
-
- -
-

No Brands Found

-

Create a brand in the database before accessing billing settings.

- - Back to Admin - -
-
-
- ); - } - } - - if (!resolvedBrandId) return ; - - // Single source of truth for everything the billing client needs. - const overviewRes = await getBillingOverview(resolvedBrandId); - if (!overviewRes.success || !overviewRes.data) { - return ( -
-
-
-

Billing Unavailable

-

{overviewRes.error ?? "Could not load billing information."}

- - Back to Admin - -
-
-
- ); - } - - const overview = overviewRes.data; - - return ( -
- {/* Platform billing header */} -
-
-

- Route Commerce Platform Billing - {" — "}Invoiced by Cielo Hermosa, LLC · Manage your platform subscription and add-ons. - {" "}Questions? billing@cielohermosa.com -

-
-
- -
- {/* Breadcrumb */} - - -
-

Billing & Subscription

-

- Manage your Route Commerce subscription for {overview.brandName ?? "your brand"}. -

-
- - -
-
- ); -} diff --git a/src/app/admin/settings/integrations/page.tsx b/src/app/admin/settings/integrations/page.tsx deleted file mode 100644 index 94577c7..0000000 --- a/src/app/admin/settings/integrations/page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { redirect } from "next/navigation"; -import { supabase } from "@/lib/supabase"; -import { getAdminUser } from "@/lib/admin-permissions"; -import IntegrationsClientPage from "./IntegrationsClientPage"; -import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; - -export const metadata = { - title: "Integrations - Route Commerce Admin", - description: "Configure integrations for AI, email, SMS, and payments", -}; - -export default async function IntegrationsPage() { - const adminUser = await getAdminUser(); - if (!adminUser) return ; - - const isPlatformAdmin = adminUser.role === "platform_admin"; - const brandId = adminUser.brand_id ?? ""; - - // Platform admins: fetch all brands for the picker - const brands = isPlatformAdmin - ? ((await supabase.from("brands").select("id, name").order("name")) as unknown as { data: { id: string; name: string }[] }).data ?? [] - : []; - - return ( -
-
- {/* Breadcrumb */} - - - {/* Page Header */} -
-
-
- - - -
-
-

Integrations

-

- Connect AI, email, SMS, and payment providers to power your operations. -

-
-
-
- - -
-
- ); -} \ No newline at end of file diff --git a/src/app/admin/settings/shipping/page.tsx b/src/app/admin/settings/shipping/page.tsx deleted file mode 100644 index af24f89..0000000 --- a/src/app/admin/settings/shipping/page.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { redirect } from "next/navigation"; -import { supabase } from "@/lib/supabase"; -import { getAdminUser } from "@/lib/admin-permissions"; -import { getShippingSettings } from "@/actions/shipping/settings"; -import ShippingSettingsForm from "@/components/admin/ShippingSettingsForm"; -import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; - -export default async function ShippingSettingsPage() { - const adminUser = await getAdminUser(); - if (!adminUser) return ; - if (!adminUser.can_manage_orders || adminUser.role === "store_employee") { - redirect("/admin/pickup"); - } - - const isPlatformAdmin = adminUser.role === "platform_admin"; - const brandId = adminUser.brand_id ?? ""; - - // Platform admins: fetch all brands for the picker - const brands = isPlatformAdmin - ? ((await supabase.from("brands").select("id, name").order("name")) as unknown as { data: { id: string; name: string }[] }).data ?? [] - : []; - - const effectiveBrandId = brandId || (brands[0]?.id ?? ""); - - const result = await getShippingSettings(effectiveBrandId); - const settings = result.success ? result.settings : null; - - return ( -
-
- {/* Breadcrumb */} - - -
-
-
- - - -
-

Shipping Settings

-
-

- Configure FedEx integration for shipping fresh produce — sweet corn, onions, and more. -

-
- -
- -
-
-
- ); -} \ No newline at end of file diff --git a/src/app/admin/taxes/page.tsx b/src/app/admin/taxes/page.tsx deleted file mode 100644 index ffdf7ff..0000000 --- a/src/app/admin/taxes/page.tsx +++ /dev/null @@ -1,85 +0,0 @@ -"use server"; - -import { getAdminUser } from "@/lib/admin-permissions"; -import { getActiveBrandId } from "@/lib/brand-scope"; -import { supabase } from "@/lib/supabase"; -import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; -import TaxDashboard from "@/components/admin/TaxDashboard"; -import { PageHeader } from "@/components/admin/design-system"; - -type Props = { - params: Promise<{ brandId?: string }>; -}; - -export default async function TaxesPage({ params }: Props) { - const { brandId: brandIdParam } = await params; - const adminUser = await getAdminUser(); - if (!adminUser) return ; - - const activeBrandId = await getActiveBrandId(adminUser, brandIdParam); - if (!activeBrandId && adminUser.role !== "platform_admin") { - return ; - } - const effectiveBrandId = activeBrandId ?? ""; - const isPlatformAdmin = adminUser.role === "platform_admin"; - - let resolvedBrandId = effectiveBrandId; - if (isPlatformAdmin && !resolvedBrandId) { - const { data: firstBrand } = await supabase - .from("brands") - .select("id") - .limit(1) - .single() as unknown as { data: { id: string } | null }; - if (firstBrand?.id) { - resolvedBrandId = String(firstBrand.id); - } else { - return ( -
-
-
-

No Brands Found

-

Create a brand in the database first.

- - Back to Admin - -
-
-
- ); - } - } - - if (!resolvedBrandId) return ; - - const { data: brands } = await supabase - .from("brands") - .select("id, name") - .order("name"); - - const allBrands = (brands ?? []) as Array<{ id: string; name: string }>; - - return ( -
-
- - - -
-
- ); -} \ No newline at end of file diff --git a/src/app/api/indian-river-direct/schedule-pdf/route.ts b/src/app/api/indian-river-direct/schedule-pdf/route.ts index b26e2c9..0fdb9df 100644 --- a/src/app/api/indian-river-direct/schedule-pdf/route.ts +++ b/src/app/api/indian-river-direct/schedule-pdf/route.ts @@ -1,36 +1,44 @@ import { NextResponse } from "next/server"; import { PDFDocument, rgb, StandardFonts } from "pdf-lib"; -import { supabase } from "@/lib/supabase"; +import { pool } from "@/lib/db"; export async function GET() { const brandSlug = "indian-river-direct"; - const { data: brand } = await supabase - .from("brands") - .select("id, name") - .eq("slug", brandSlug) - .single() as unknown as { data: { id: string; name: string } | null }; - + const { rows: brandRows } = await pool.query<{ id: string; name: string }>( + `SELECT id, name FROM brands WHERE slug = $1 LIMIT 1`, + [brandSlug], + ); + const brand = brandRows[0]; if (!brand?.id) { return new NextResponse("Brand not found", { status: 404 }); } - const { data: stops } = await supabase - .from("stops") - .select("*") - .eq("brand_id", brand.id) - .eq("active", true) - .order("date", { ascending: true }) as unknown as { data: { city: string; state: string; date: string; time: string; location: string }[] | null }; + const { rows: stops } = await pool.query<{ city: string; state: string; date: string; time: string; location: string }>( + `SELECT city, state, date::text AS date, time, location + FROM stops + WHERE brand_id = $1 AND is_public = true AND status = 'active' + ORDER BY date ASC, time ASC`, + [brand.id], + ); - const { data: settings } = await supabase - .from("brand_settings") - .select("logo_url, email, phone, schedule_pdf_notes") - .eq("brand_id", brand.id) - .single() as unknown as { data: { logo_url: string | null; email: string | null; phone: string | null; schedule_pdf_notes: string | null } | null }; + const { rows: settingsRows } = await pool.query<{ + logo_url: string | null; + email: string | null; + phone: string | null; + schedule_pdf_notes: string | null; + }>( + `SELECT logo_url, email, phone, schedule_pdf_notes + FROM brand_settings + WHERE brand_id = $1 + LIMIT 1`, + [brand.id], + ); + const settings = settingsRows[0] ?? null; const pdfBytes = await buildProfessionalSchedulePdf({ brandName: brand.name, - stops: stops ?? [], + stops: stops, logoUrl: settings?.logo_url ?? null, contactEmail: settings?.email ?? null, contactPhone: settings?.phone ?? null, diff --git a/src/app/api/tuxedo/schedule-pdf/route.ts b/src/app/api/tuxedo/schedule-pdf/route.ts index ab5f214..4e305d4 100644 --- a/src/app/api/tuxedo/schedule-pdf/route.ts +++ b/src/app/api/tuxedo/schedule-pdf/route.ts @@ -1,44 +1,44 @@ import { NextResponse } from "next/server"; import { PDFDocument, rgb, StandardFonts } from "pdf-lib"; -import { supabase } from "@/lib/supabase"; - -type Settings = { - logo_url: string | null; - contact_email: string | null; - contact_phone: string | null; - schedule_pdf_notes: string | null; - brand_name: string | null; -}; +import { pool } from "@/lib/db"; export async function GET() { const brandSlug = "tuxedo"; - const { data: brand } = await supabase - .from("brands") - .select("id, name") - .eq("slug", brandSlug) - .single() as unknown as { data: { id: string; name: string } | null }; - + const { rows: brandRows } = await pool.query<{ id: string; name: string }>( + `SELECT id, name FROM brands WHERE slug = $1 LIMIT 1`, + [brandSlug], + ); + const brand = brandRows[0]; if (!brand?.id) { return new NextResponse("Brand not found", { status: 404 }); } - const { data: stops } = await supabase - .from("stops") - .select("city, state, date, time, location") - .eq("brand_id", brand.id) - .eq("active", true) - .order("date", { ascending: true }) as unknown as { data: { city: string; state: string; date: string; time: string; location: string }[] | null }; + const { rows: stops } = await pool.query<{ city: string; state: string; date: string; time: string; location: string }>( + `SELECT city, state, date::text AS date, time, location + FROM stops + WHERE brand_id = $1 AND is_public = true AND status = 'active' + ORDER BY date ASC, time ASC`, + [brand.id], + ); - const { data: settings } = await supabase - .from("brand_settings") - .select("logo_url, email, phone, schedule_pdf_notes") - .eq("brand_id", brand.id) - .single() as unknown as { data: { logo_url: string | null; email: string | null; phone: string | null; schedule_pdf_notes: string | null } | null }; + const { rows: settingsRows } = await pool.query<{ + logo_url: string | null; + email: string | null; + phone: string | null; + schedule_pdf_notes: string | null; + }>( + `SELECT logo_url, email, phone, schedule_pdf_notes + FROM brand_settings + WHERE brand_id = $1 + LIMIT 1`, + [brand.id], + ); + const settings = settingsRows[0] ?? null; const pdfBytes = await buildProfessionalSchedulePdf({ brandName: brand.name, - stops: stops ?? [], + stops: stops, logoUrl: settings?.logo_url ?? null, contactEmail: settings?.email ?? null, contactPhone: settings?.phone ?? null, diff --git a/src/app/indian-river-direct/contact/ContactClientPage.tsx b/src/app/indian-river-direct/contact/ContactClientPage.tsx index 152571e..493057c 100644 --- a/src/app/indian-river-direct/contact/ContactClientPage.tsx +++ b/src/app/indian-river-direct/contact/ContactClientPage.tsx @@ -5,7 +5,8 @@ import { motion } from "framer-motion"; import StorefrontHeader from "@/components/storefront/StorefrontHeader"; import StorefrontFooter from "@/components/storefront/StorefrontFooter"; import LayoutContainer from "@/components/layout/LayoutContainer"; -import { supabase } from "@/lib/supabase"; +import { getBrandSettingsPublic } from "@/actions/brand-settings"; +import { getStorefrontWholesaleSettings } from "@/actions/storefront"; type BrandSettings = { invoice_business_name: string | null; @@ -34,17 +35,22 @@ export default function IndianRiverContactPage() { const [contactPhone, setContactPhone] = useState(null); useEffect(() => { - supabase.from("brands").select("id").eq("slug", "indian-river-direct").single().then(({ data: brand }) => { - if (!brand?.id) return; - supabase.from("wholesale_settings") - .select("invoice_business_name, invoice_business_address, invoice_business_phone, invoice_business_email, invoice_business_website") - .eq("brand_id", brand.id).single().then(({ data }) => setBrandSettings((data as unknown as BrandSettings) ?? null)); - supabase.from("brand_settings").select("logo_url, custom_footer_text, email, phone") - .eq("brand_id", brand.id).single().then(({ data: s }) => { - if (s) { setLogoUrl((s as { logo_url: string | null }).logo_url ?? null); setCustomFooterText((s as { custom_footer_text: string | null }).custom_footer_text ?? null); setContactEmail((s as { email: string | null }).email ?? null); setContactPhone((s as { phone: string | null }).phone ?? null); } - }); - try { import("@/actions/admin-user").then(({ getCurrentAdminUser }) => { getCurrentAdminUser().then((u: unknown) => setIsAdmin(!!u)); }); } catch { /* not logged in */ } + Promise.all([ + getStorefrontWholesaleSettings("indian-river-direct"), + getBrandSettingsPublic("indian-river-direct"), + ]).then(([wholesale, brandSettings]) => { + setBrandSettings(wholesale as BrandSettings | null); + if (brandSettings.success && brandSettings.settings) { + const s = brandSettings.settings; + setLogoUrl(s.logo_url ?? null); + setCustomFooterText(s.custom_footer_text ?? null); + setContactEmail(s.email ?? null); + setContactPhone(s.phone ?? null); + } }); + import("@/actions/admin-user").then(({ getCurrentAdminUser }) => { + getCurrentAdminUser().then((u: unknown) => setIsAdmin(!!u)); + }).catch(() => { /* not logged in */ }); }, []); function handleSubmit(e: React.FormEvent) { diff --git a/src/app/indian-river-direct/faq/FAQClientPage.tsx b/src/app/indian-river-direct/faq/FAQClientPage.tsx index 26bbaed..2fdb3a3 100644 --- a/src/app/indian-river-direct/faq/FAQClientPage.tsx +++ b/src/app/indian-river-direct/faq/FAQClientPage.tsx @@ -6,7 +6,7 @@ import { motion } from "framer-motion"; import StorefrontHeader from "@/components/storefront/StorefrontHeader"; import StorefrontFooter from "@/components/storefront/StorefrontFooter"; import LayoutContainer from "@/components/layout/LayoutContainer"; -import { supabase } from "@/lib/supabase"; +import { getBrandSettingsPublic } from "@/actions/brand-settings"; type FAQCategory = { category: string; @@ -136,10 +136,10 @@ export default function IndianRiverFAQPage() { const [searchQuery, setSearchQuery] = useState(""); useEffect(() => { - supabase.from("brand_settings").select("phone").eq("brand_id", - "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28" - ).single().then(({ data }) => { - if (data && (data as { phone: string | null }).phone) setFaqCategories(buildFaqCategories((data as { phone: string | null }).phone!)); + getBrandSettingsPublic("indian-river-direct").then((result) => { + if (result.success && result.settings?.phone) { + setFaqCategories(buildFaqCategories(result.settings.phone)); + } }); }, []); diff --git a/src/app/test/page.tsx b/src/app/test/page.tsx deleted file mode 100644 index 059a369..0000000 --- a/src/app/test/page.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { supabase } from "@/lib/supabase"; - -export const dynamic = "force-dynamic"; - -export default async function TestPage() { - const { data, error } = await supabase - .from("brands") - .select("*"); - - return ( -
-

- Supabase Test -

- -
-        {JSON.stringify({ data, error }, null, 2)}
-      
-
- ); -} \ No newline at end of file diff --git a/src/app/tuxedo/products/sweet-corn-box/page.tsx b/src/app/tuxedo/products/sweet-corn-box/page.tsx index caa03d2..07fc98b 100644 --- a/src/app/tuxedo/products/sweet-corn-box/page.tsx +++ b/src/app/tuxedo/products/sweet-corn-box/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { Fraunces, JetBrains_Mono } from "next/font/google"; -import { supabase } from "@/lib/supabase"; import { getBrandSettingsPublic } from "@/actions/brand-settings"; +import { getStorefrontBrandBySlug } from "@/actions/storefront"; import SweetCornProductPage from "@/components/storefront/SweetCornProductPage"; const fraunces = Fraunces({ @@ -76,14 +76,13 @@ export default async function SweetCornBoxPage() { let logoUrlDark: string | null = null; try { - const [brandRes, settingsRes] = await Promise.all([ - supabase.from("brands").select("id, name").eq("slug", BRAND_SLUG).single(), + const [brand, settingsRes] = await Promise.all([ + getStorefrontBrandBySlug(BRAND_SLUG), getBrandSettingsPublic(BRAND_SLUG), ]); - if (brandRes.data) { - const b = brandRes.data as Brand; - brandId = b.id; - brandName = b.name; + if (brand) { + brandId = brand.id; + brandName = brand.name; } if (settingsRes.success && settingsRes.settings) { const s = settingsRes.settings as Settings; diff --git a/src/app/wholesale/portal/page.tsx b/src/app/wholesale/portal/page.tsx index fde51e8..73a481a 100644 --- a/src/app/wholesale/portal/page.tsx +++ b/src/app/wholesale/portal/page.tsx @@ -5,6 +5,7 @@ import { formatDate } from "@/lib/format-date"; import { useRouter, useSearchParams } from "next/navigation"; import { getWholesaleCustomerByUser, submitWholesaleOrder, getWholesaleProducts, getWholesaleCustomerOrders, getWholesaleCustomerPricing, getWholesaleCustomer, type WholesaleProduct, type WholesaleCustomerOrder, type WholesalePricingOverride } from "@/actions/wholesale-register"; import { getWholesaleSettings, enqueueWholesaleNotification } from "@/actions/wholesale"; +import { getWholesalePortalConfig, getStorefrontBrandName } from "@/actions/storefront"; type CartItem = { product: WholesaleProduct; @@ -281,12 +282,7 @@ export default function WholesalePortalPage() { setLoading(false); return; } - const { supabase } = await import("@/lib/supabase"); - const { data: ws } = await supabase - .from("wholesale_settings") - .select("wholesale_enabled, online_payment_enabled") - .eq("brand_id", cust.brand_id) - .single() as unknown as { data: { wholesale_enabled: boolean; online_payment_enabled: boolean } | null }; + const ws = await getWholesalePortalConfig(cust.brand_id); setOnlinePaymentEnabled(ws?.online_payment_enabled ?? false); setCustomer(cust); setPreviewMode(true); @@ -327,12 +323,7 @@ export default function WholesalePortalPage() { return; } - const { supabase } = await import("@/lib/supabase"); - const { data: ws } = await supabase - .from("wholesale_settings") - .select("wholesale_enabled, online_payment_enabled") - .eq("brand_id", cust.brand_id) - .single() as unknown as { data: { wholesale_enabled: boolean; online_payment_enabled: boolean } | null }; + const ws = await getWholesalePortalConfig(cust.brand_id); if (ws && ws.wholesale_enabled === false) { router.push("/wholesale/login?error=portal_disabled"); return; @@ -349,13 +340,8 @@ export default function WholesalePortalPage() { }, [router]); async function loadBrandName(brandId: string) { - const { supabase } = await import("@/lib/supabase"); - const { data: brand } = await supabase - .from("brands") - .select("name") - .eq("id", brandId) - .single() as unknown as { data: { name: string } | null }; - if (brand) setBrandName(brand.name); + const name = await getStorefrontBrandName(brandId); + if (name) setBrandName(name); } async function loadProducts(brandId: string) { diff --git a/src/app/wholesale/register/page.tsx b/src/app/wholesale/register/page.tsx index 521170f..ddfcb9c 100644 --- a/src/app/wholesale/register/page.tsx +++ b/src/app/wholesale/register/page.tsx @@ -48,12 +48,8 @@ export default function WholesaleRegisterPage() { useEffect(() => { async function checkEnabled(brandId: string) { - const { supabase } = await import("@/lib/supabase"); - const { data: ws } = await supabase - .from("wholesale_settings") - .select("wholesale_enabled") - .eq("brand_id", brandId) - .single(); + const { getWholesalePortalConfig } = await import("@/actions/storefront"); + const ws = await getWholesalePortalConfig(brandId); if (ws && ws.wholesale_enabled === false) { setPortalDisabled(true); } diff --git a/src/components/admin/SquareSyncWidget.tsx b/src/components/admin/SquareSyncWidget.tsx index dfb8101..d0751fb 100644 --- a/src/components/admin/SquareSyncWidget.tsx +++ b/src/components/admin/SquareSyncWidget.tsx @@ -24,13 +24,9 @@ export default function SquareSyncWidget({ brandId }: Props) { const [queueCount, setQueueCount] = useState(0); const checkQueueCount = useCallback(async () => { - const { supabase } = await import("@/lib/supabase"); - const result = await supabase - .from("square_sync_queue") - .select("*", { count: "exact", head: true }) - .eq("brand_id", brandId) - .in("status", ["pending"]) as unknown as { count: number | null }; - setQueueCount(result.count ?? 0); + const { getSquareQueueCount } = await import("@/actions/square-sync-ui"); + const count = await getSquareQueueCount(brandId); + setQueueCount(count); }, [brandId]); useEffect(() => { diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts deleted file mode 100644 index ec1860b..0000000 --- a/src/lib/supabase.ts +++ /dev/null @@ -1,372 +0,0 @@ -/** - * Compatibility shim that preserves the historical Supabase client - * query-builder API surface (`.from().select()...`) for the legacy - * public-storefront and admin pages that still call into it. The SaaS - * rebuild no longer uses Supabase as a backend — server actions and - * API routes connect directly to Postgres via `pg` / `withDb` from - * `@/db/client`. This shim exists so the build keeps passing while - * the remaining legacy call sites are migrated to Drizzle / raw `pg` - * queries. - * - * IMPORTANT: This shim does NOT talk to a real database. It returns - * empty result sets. Legacy call sites that need real data must be - * rewritten against `pool` / `withDb` / `withBrand`. - * - * The query-builder API surface supported here is intentionally narrow: - * - .from(table).select(cols?).eq(col, val).eq(...).is(col, null). - * order(col, opts?).limit(n).range(min, max).single() → returns - * `{ data, error }` like the Supabase client did. - * - .from(table).insert(payload).select().single() for legacy inserts - * - .from(table).upsert(payload) - * - .from(table).update(payload).eq(col, val) - * - .from(table).delete().eq(col, val) - * - .rpc(fnName, params) — returns `{ data, error }` (data is null) - * - .auth.{getSession, getUser, signInWithPassword, signOut, - * updateUser, onAuthStateChange} — all return null / no-ops - * - .storage.from(bucket).{upload, download, remove, list} - * - .channel().on().subscribe() - * - * If a call site needs more than that, migrate the call site. - */ - -type FilterOp = "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "is" | "like" | "ilike" | "in"; -type Filter = { column: string; value: unknown; op: FilterOp }; - -// Result types for the mock query builder -interface MockQueryResult { - data: T[] | null; - error: null; -} - -interface MockSingleResult { - data: T | null; - error: null; -} - -class MockQueryBuilder = Record> { - private tableName: string; - private filters: Filter[] = []; - private selectColumns: string = "*"; - private orderColumn?: string; - private orderDirection?: "asc" | "desc"; - private limitValue?: number; - private rangeMin?: number; - private rangeMax?: number; - private mode: "select" | "update" | "delete" = "select"; - private mutationData: Record | null = null; - - constructor(tableName: string) { - this.tableName = tableName; - // No mock data - return empty results - } - - select(columns: string = "*", _opts?: Record) { - this.selectColumns = columns; - return this; - } - - eq(column: string, value: unknown) { - this.filters.push({ column, value, op: "eq" }); - return this; - } - - neq(column: string, value: unknown) { - this.filters.push({ column, value, op: "neq" }); - return this; - } - - gt(column: string, value: unknown) { - this.filters.push({ column, value, op: "gt" }); - return this; - } - - gte(column: string, value: unknown) { - this.filters.push({ column, value, op: "gte" }); - return this; - } - - lt(column: string, value: unknown) { - this.filters.push({ column, value, op: "lt" }); - return this; - } - - lte(column: string, value: unknown) { - this.filters.push({ column, value, op: "lte" }); - return this; - } - - is(column: string, value: unknown) { - this.filters.push({ column, value, op: "is" }); - return this; - } - - like(column: string, value: string) { - this.filters.push({ column, value, op: "like" }); - return this; - } - - ilike(column: string, value: string) { - this.filters.push({ column, value, op: "ilike" }); - return this; - } - - in(column: string, values: unknown[]) { - this.filters.push({ column, value: values, op: "in" }); - return this; - } - - order(column: string, options?: { ascending?: boolean; nullsFirst?: boolean }) { - this.orderColumn = column; - this.orderDirection = options?.ascending === false ? "desc" : "asc"; - return this; - } - - limit(count: number) { - this.limitValue = count; - return this; - } - - range(min: number, max: number) { - this.rangeMin = min; - this.rangeMax = max; - return this; - } - - // The legacy Supabase client returns a thenable from .single() so - // callers can write `.single().then(({ data, error }) => ...)` as - // well as `const { data, error } = await ....single()`. We return a - // proper Promise> so destructured binding - // patterns in callers work under `--strict` (no implicit `any`). - single(): Promise> { - return Promise.resolve(this.executeSingle()); - } - - maybeSingle(): Promise> { - return Promise.resolve(this.executeSingle()); - } - - then, TResult2 = never>( - onfulfilled?: ((value: MockQueryResult) => TResult1 | PromiseLike) | null, - _onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, - ): PromiseLike { - const result = this.execute(); - return Promise.resolve(onfulfilled ? onfulfilled(result) : (result as unknown as TResult1)); - } - - // Insert / update / delete mutators — these are mostly used by legacy - // auth flows and the AI preferences action. We capture the data and - // short-circuit to a successful no-op response so the call sites - // don't blow up. Real writes must go through server actions. - executeSingle(): MockSingleResult { - const result = this.execute(); - if (Array.isArray(result.data) && result.data.length > 0) { - return { data: result.data[0] as T, error: null }; - } - return { data: null, error: null }; - } - - execute(): MockQueryResult { - if (this.mode === "update" || this.mode === "delete") { - return this.runMutation(); - } - // No mock data - return empty array - let filtered: unknown[] = []; - - for (const filter of this.filters) { - filtered = filtered.filter((row) => { - const r = row as Record; - const rowValue = r[filter.column]; - switch (filter.op) { - case "eq": - return rowValue === filter.value; - case "neq": - return rowValue !== filter.value; - case "gt": - return (rowValue as number) > (filter.value as number); - case "gte": - return (rowValue as number) >= (filter.value as number); - case "lt": - return (rowValue as number) < (filter.value as number); - case "lte": - return (rowValue as number) <= (filter.value as number); - case "is": - if (filter.value === null) return rowValue === null; - if (filter.value === undefined) return rowValue === undefined; - return rowValue === filter.value; - case "like": - return ( - typeof rowValue === "string" && - rowValue.includes((filter.value as string).replace(/%/g, "")) - ); - case "ilike": - return ( - typeof rowValue === "string" && - rowValue - .toLowerCase() - .includes((filter.value as string).replace(/%/g, "").toLowerCase()) - ); - case "in": - return Array.isArray(filter.value) && filter.value.includes(rowValue); - default: - return true; - } - }); - } - - if (this.orderColumn) { - filtered.sort((a: unknown, b: unknown) => { - const aVal = (a as Record)[this.orderColumn!] as string | number; - const bVal = (b as Record)[this.orderColumn!] as string | number; - if (aVal < bVal) return this.orderDirection === "desc" ? 1 : -1; - if (aVal > bVal) return this.orderDirection === "desc" ? -1 : 1; - return 0; - }); - } - - if (this.rangeMin !== undefined && this.rangeMax !== undefined) { - filtered = filtered.slice(this.rangeMin, this.rangeMax + 1); - } else if (this.limitValue !== undefined) { - filtered = filtered.slice(0, this.limitValue); - } - - return { data: filtered as T[], error: null }; - } - - private runMutation(): MockQueryResult { - if (this.mode === "delete") { - return { data: null, error: null }; - } - if (this.mutationData) { - const items = [this.mutationData]; - const returning = items.map((item, i) => ({ - ...item, - id: (item as Record).id ?? `generated-${Date.now()}-${i}`, - })); - return { data: returning as unknown as T[], error: null }; - } - return { data: null, error: null }; - } -} - -interface MockMutationResult { - data: T[] | null; - error: null; -} - -class MockMutationBuilder = Record> { - private tableName: string; - private data: unknown; - - constructor(tableName: string, data: unknown) { - this.tableName = tableName; - this.data = data; - } - - select() { - return new MockQueryBuilder(this.tableName); - } - - eq() { - return this; - } - - then, TResult2 = never>( - onfulfilled?: ((value: MockMutationResult) => TResult1 | PromiseLike) | null, - ): PromiseLike { - const result: MockMutationResult = { - data: Array.isArray(this.data) - ? this.data.map((item: Record, i: number) => ({ - ...item, - id: item?.id ?? `generated-${Date.now()}-${i}`, - })) as unknown as T[] - : this.data - ? [{ ...(this.data as Record), id: (this.data as Record).id ?? `generated-${Date.now()}` }] as unknown as T[] - : null, - error: null, - }; - return Promise.resolve(onfulfilled ? onfulfilled(result) : (result as unknown as TResult1)); - } -} - -class MockDeleteBuilder { - eq(_column: string, _value: unknown) { - return this; - } - in(_column: string, _values: unknown[]) { - return this; - } - then(resolve: (value: unknown) => void) { - resolve({ data: null, error: null }); - } -} - -class MockStorageBuilder { - from(_bucket: string) { - return { - upload: async (_path: string, _file: unknown) => ({ data: { path: _path }, error: null }), - download: async (_path: string) => ({ data: new Blob(), error: null }), - remove: async (_paths: string[]) => ({ data: { paths: _paths }, error: null }), - list: async () => ({ data: [], error: null }), - }; - } -} - -// Auth credential types -interface SignInCredentials { - email?: string; - password?: string; -} - -interface UserAttributes { - email?: string; - data?: Record; -} - -function createMockClient() { - // The query builder is mutable, so we can't simply return a class - // instance + spread mutation methods. The cleanest way to support - // both `.from(t).select(...)...` (read) and `.from(t).update(d).eq(...)` - // (write) in a single chain is to delegate everything to one object - // and inspect `this.mode` lazily. We build that object via - // `Object.assign` to keep the TypeScript inference happy. - function makeFrom = Record>(table: string) { - const qb = new MockQueryBuilder(table); - return Object.assign(qb, { - insert: (data: Record) => new MockMutationBuilder(table, data), - update: (data: Record) => new MockMutationBuilder(table, data), - upsert: (data: Record) => new MockMutationBuilder(table, data), - delete: () => new MockDeleteBuilder(), - }); - } - - return { - from: makeFrom, - storage: new MockStorageBuilder(), - auth: { - getSession: async () => ({ data: { session: null }, error: null }), - getUser: async () => ({ data: { user: null }, error: null }), - signInWithPassword: async (_creds: SignInCredentials) => ({ - data: { user: null, session: null }, - error: null, - }), - signOut: async () => ({ error: null }), - updateUser: async (_attrs: UserAttributes): Promise<{ data: { user: null }; error: null }> => ({ - data: { user: null }, - error: null, - }), - onAuthStateChange: () => ({ - data: { subscription: { unsubscribe: () => {} } }, - }), - }, - rpc: async (_name: string, _params?: Record): Promise<{ data: null; error: null }> => ({ - data: null, - error: null, - }), - channel: () => ({ - on: () => ({ subscribe: () => ({}) }), - subscribe: () => ({}), - }), - }; -} - -export const supabase = createMockClient();