refactor(storefront): remove supabase shim and restore customer storefronts
The legacy src/lib/supabase.ts shim returned { data: null } for every
query, so 15+ pages were silently rendering empty state — including the
two customer-facing storefronts (tuxedo, indian-river-direct) and
several v1 admin pages. Replace every caller with canonical access:
- New src/actions/storefront.ts server-action module: brand lookup,
public stops, active products, stop-by-slug, wholesale settings,
portal config. Uses the shared pg Pool and SECURITY DEFINER RPCs.
- src/actions/brand-settings.ts: getBrandSettingsPublic inlined the
brands + brand_settings LEFT JOIN wholesale_settings query (the
RPC it called did not exist; try/catch was masking the failure).
- src/actions/ai/preferences.ts: switched get/saveAIPreferences to
pool.query.
- src/actions/square-sync-ui.ts: new getSquareQueueCount action for
SquareSyncWidget (replaces shim count).
- src/app/api/{tuxedo,indian-river-direct}/schedule-pdf/route.ts:
use pool.query.
- next.config.ts: redirects /admin/{products/:id,reports,taxes,
settings/{shipping,integrations,billing}} → their v2 / settings
equivalents, then deleted those pages.
- Deleted src/lib/supabase.ts (no remaining imports).
Tests: 174/175 (unchanged from baseline; pre-existing getAdminUser
mock issue is tracked in Step 5).
This commit is contained in:
@@ -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 },
|
||||
];
|
||||
},
|
||||
|
||||
|
||||
@@ -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<AIRow>(
|
||||
`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 }> {
|
||||
|
||||
@@ -243,14 +243,24 @@ export async function getBrandSettings(brandId: string): Promise<GetBrandSetting
|
||||
}
|
||||
}
|
||||
|
||||
// Public version for storefront pages — uses slug, no auth required
|
||||
// Public version for storefront pages — uses slug, no auth required.
|
||||
//
|
||||
// Inlined as a join against `brands` + `brand_settings` LEFT JOIN
|
||||
// `wholesale_settings` instead of calling `get_brand_settings_by_slug`.
|
||||
// That RPC is referenced from this code path but is not defined in any
|
||||
// shipped migration, so the function-on-the-server call would throw
|
||||
// `function get_brand_settings_by_slug(unknown) does not exist` and the
|
||||
// storefront would silently fall back to defaults. The inlined query
|
||||
// has the same shape and never fails because of a missing RPC.
|
||||
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
|
||||
// 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<BrandSettings & { wholesale_enabled?: boolean | null }>(
|
||||
"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];
|
||||
|
||||
@@ -77,3 +77,30 @@ 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<number> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<WholesalePortalConfig | null> {
|
||||
const { rows } = await pool.query<WholesalePortalConfig>(
|
||||
`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<string | null> {
|
||||
const { rows } = await pool.query<BrandName>(
|
||||
`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.
|
||||
|
||||
@@ -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 <AdminAccessDenied />;
|
||||
|
||||
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 <AdminAccessDenied message="You don't have access to any brand." />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
|
||||
<p className="ha-eyebrow mb-2">Operations</p>
|
||||
<PageHeader
|
||||
title="Orders"
|
||||
subtitle="View, filter, and manage customer orders."
|
||||
icon={
|
||||
<svg className="h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/>
|
||||
<path d="M3 6h18"/>
|
||||
<path d="M16 10a4 4 0 0 1-8 0"/>
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
||||
<div
|
||||
className="rounded-2xl border overflow-hidden"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
}}
|
||||
>
|
||||
<AdminOrdersPanel
|
||||
initialOrders={brandOrders}
|
||||
initialStops={brandStops}
|
||||
initialProducts={brandProducts}
|
||||
brandId={activeBrandId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <AdminAccessDenied />;
|
||||
|
||||
if (!adminUser.can_manage_products) redirect("/admin/pickup");
|
||||
|
||||
if (adminUser?.brand_id && product?.brand_id !== adminUser.brand_id) {
|
||||
return <AdminAccessDenied />;
|
||||
}
|
||||
|
||||
if (error || !product) {
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h1 className="text-3xl font-bold text-[var(--admin-danger)]">Product not found</h1>
|
||||
<pre className="mt-4 rounded-xl bg-[var(--admin-card-bg)] border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-muted)]">
|
||||
{error?.message ?? "Product not found"}
|
||||
</pre>
|
||||
<Link
|
||||
href="/admin/products"
|
||||
className="mt-4 inline-block text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)]"
|
||||
>
|
||||
← Back to Products
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="ha-eyebrow mb-2">Operations</div>
|
||||
<PageHeader
|
||||
icon={<PackageIcon className="h-5 w-5" strokeWidth={1.75} />}
|
||||
title="Edit product"
|
||||
subtitle="Update product details, pricing, and availability."
|
||||
actions={
|
||||
<AdminBadge tone={product.active ? "success" : "neutral"} dot>
|
||||
{product.active ? "Active" : "Inactive"}
|
||||
</AdminBadge>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
href="/admin/products"
|
||||
className="text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
|
||||
>
|
||||
← Back to Products
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-[var(--admin-card-bg)] p-8 shadow-sm border border-[var(--admin-border)]">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-[var(--admin-text-muted)]">
|
||||
{product.brands?.name}
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-bold text-[var(--admin-text-primary)]">
|
||||
{product.name}
|
||||
</h1>
|
||||
<p className="mt-2 text-lg text-[var(--admin-text-secondary)]">
|
||||
{product.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Price</p>
|
||||
<p
|
||||
className="mt-1 text-2xl font-bold text-[var(--admin-text-primary)]"
|
||||
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
${Number(product.price).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Type</p>
|
||||
<p className="mt-1 text-lg font-semibold text-[var(--admin-text-primary)]">
|
||||
{product.type}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-[var(--admin-card-bg)] p-8 shadow-sm border border-[var(--admin-border)]">
|
||||
<h2 className="text-2xl font-bold text-[var(--admin-text-primary)]">Edit Product</h2>
|
||||
<p className="mt-1 text-[var(--admin-text-muted)]">
|
||||
Update product details, pricing, and availability.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<ProductEditForm
|
||||
product={{
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
description: product.description ?? "",
|
||||
price: Number(product.price),
|
||||
type: product.type ?? "pickup",
|
||||
active: product.active,
|
||||
brand_id: product.brand_id,
|
||||
image_url: product.image_url ?? "",
|
||||
is_taxable: product.is_taxable,
|
||||
pickup_type: product.pickup_type ?? "pickup",
|
||||
}}
|
||||
brands={brands ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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 <AdminAccessDenied />;
|
||||
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 (
|
||||
<main className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<PageHeader
|
||||
title="Reports"
|
||||
subtitle="Operational visibility across orders, fulfillment, contacts, and campaigns."
|
||||
/>
|
||||
|
||||
<ReportsDashboard
|
||||
brands={brands ?? []}
|
||||
initialBrandId={initialBrandId}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
brandId={adminUser.brand_id}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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 <AdminAccessDenied />;
|
||||
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandIdParam);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return <AdminAccessDenied message="You don't have access to that brand." />;
|
||||
}
|
||||
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 (
|
||||
<main className="min-h-screen bg-[var(--admin-bg)] px-6 py-12">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<nav className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)] mb-6">
|
||||
<a href="/admin" className="hover:text-[var(--admin-text-primary)] transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-[var(--admin-text-primary)]">Billing</span>
|
||||
</nav>
|
||||
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-8 text-center">
|
||||
<h1 className="text-2xl font-bold text-[var(--admin-text-primary)]">No Brands Found</h1>
|
||||
<p className="mt-2 text-[var(--admin-text-muted)]">Create a brand in the database before accessing billing settings.</p>
|
||||
<a href="/admin" className="mt-4 inline-block rounded-xl bg-[var(--admin-accent)] hover:bg-[var(--admin-accent-hover)] px-6 py-3 text-sm font-medium text-white transition-colors">
|
||||
Back to Admin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedBrandId) return <AdminAccessDenied />;
|
||||
|
||||
// Single source of truth for everything the billing client needs.
|
||||
const overviewRes = await getBillingOverview(resolvedBrandId);
|
||||
if (!overviewRes.success || !overviewRes.data) {
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--admin-bg)] px-6 py-12">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-8 text-center">
|
||||
<h1 className="text-2xl font-bold text-[var(--admin-text-primary)]">Billing Unavailable</h1>
|
||||
<p className="mt-2 text-[var(--admin-text-muted)]">{overviewRes.error ?? "Could not load billing information."}</p>
|
||||
<a href="/admin" className="mt-4 inline-block rounded-xl bg-[var(--admin-accent)] hover:bg-[var(--admin-accent-hover)] px-6 py-3 text-sm font-medium text-white transition-colors">
|
||||
Back to Admin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const overview = overviewRes.data;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--admin-bg)]">
|
||||
{/* Platform billing header */}
|
||||
<div className="bg-[var(--admin-bg-subtle)] border-b border-[var(--admin-border)] px-6 py-3">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
<span className="font-medium text-[var(--admin-text-primary)]">Route Commerce Platform Billing</span>
|
||||
{" — "}Invoiced by Cielo Hermosa, LLC · Manage your platform subscription and add-ons.
|
||||
{" "}Questions? <a href="mailto:billing@cielohermosa.com" className="text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)] underline transition-colors">billing@cielohermosa.com</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-6xl px-6 py-10">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)] mb-6">
|
||||
<a href="/admin" className="hover:text-[var(--admin-text-primary)] transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<a href="/admin/settings" className="hover:text-[var(--admin-text-primary)] transition-colors">Settings</a>
|
||||
<span>/</span>
|
||||
<span className="text-[var(--admin-text-primary)]">Billing</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-[var(--admin-text-primary)]">Billing & Subscription</h1>
|
||||
<p className="mt-1 text-[var(--admin-text-muted)]">
|
||||
Manage your Route Commerce subscription for {overview.brandName ?? "your brand"}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BillingClientPage overview={overview} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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 <AdminAccessDenied />;
|
||||
|
||||
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 (
|
||||
<main className="min-h-screen bg-[var(--admin-bg)]">
|
||||
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6 pb-10">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)] mb-6">
|
||||
<a href="/admin" className="hover:text-[var(--admin-text-primary)] transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<a href="/admin/settings" className="hover:text-[var(--admin-text-primary)] transition-colors">Settings</a>
|
||||
<span>/</span>
|
||||
<span className="text-[var(--admin-text-primary)]">Integrations</span>
|
||||
</nav>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent)] text-white">
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--admin-text-primary)]">Integrations</h1>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||
Connect AI, email, SMS, and payment providers to power your operations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IntegrationsClientPage
|
||||
brandId={brandId}
|
||||
brands={brands}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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 <AdminAccessDenied />;
|
||||
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 (
|
||||
<main className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className="mx-auto max-w-2xl">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs mb-6" style={{ color: "var(--admin-text-muted)" }}>
|
||||
<a href="/admin" className="hover:text-[var(--admin-text-primary)] transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span style={{ color: "var(--admin-text-primary)" }}>Shipping</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl" style={{ backgroundColor: "var(--admin-accent)", color: "white" }}>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25m-2.25 0h2.25m0 0v-.375c0-.621-.504-1.125-1.125-1.125H15m-1.5-3l1.5 0l.75 0v-.375c0-.621-.504-1.125-1.125-1.125H15m0 0v-.375c0-.621-.504-1.125-1.125-1.125H12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold" style={{ color: "var(--admin-text-primary)" }}>Shipping Settings</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-sm" style={{ color: "var(--admin-text-muted)" }}>
|
||||
Configure FedEx integration for shipping fresh produce — sweet corn, onions, and more.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border p-6 shadow-lg" style={{ backgroundColor: "white", borderColor: "var(--admin-border)" }}>
|
||||
<ShippingSettingsForm
|
||||
settings={settings}
|
||||
brandId={effectiveBrandId}
|
||||
brands={brands}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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 <AdminAccessDenied />;
|
||||
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandIdParam);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return <AdminAccessDenied message="You don't have access to that brand." />;
|
||||
}
|
||||
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 (
|
||||
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="rounded-2xl p-8 text-center border" style={{
|
||||
backgroundColor: "white",
|
||||
borderColor: "var(--admin-border)"
|
||||
}}>
|
||||
<h1 className="text-2xl font-bold" style={{ color: "var(--admin-text-primary)" }}>No Brands Found</h1>
|
||||
<p className="mt-2" style={{ color: "var(--admin-text-muted)" }}>Create a brand in the database first.</p>
|
||||
<a href="/admin" className="mt-4 inline-block rounded-xl px-6 py-3 text-sm font-medium border transition-colors"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-bg-subtle)",
|
||||
borderColor: "var(--admin-border)",
|
||||
color: "var(--admin-text-primary)"
|
||||
}}>
|
||||
Back to Admin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedBrandId) return <AdminAccessDenied />;
|
||||
|
||||
const { data: brands } = await supabase
|
||||
.from("brands")
|
||||
.select("id, name")
|
||||
.order("name");
|
||||
|
||||
const allBrands = (brands ?? []) as Array<{ id: string; name: string }>;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className="mx-auto max-w-6xl px-6 py-10">
|
||||
<PageHeader
|
||||
title="Tax Dashboard"
|
||||
subtitle="Sales tax collected on orders shipped to nexus states."
|
||||
/>
|
||||
|
||||
<TaxDashboard
|
||||
brands={allBrands}
|
||||
initialBrandId={resolvedBrandId}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string | null>(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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<main className="min-h-screen bg-slate-50 p-10">
|
||||
<h1 className="text-3xl font-bold">
|
||||
Supabase Test
|
||||
</h1>
|
||||
|
||||
<pre className="mt-8 rounded-xl bg-black p-6 text-sm text-green-400">
|
||||
{JSON.stringify({ data, error }, null, 2)}
|
||||
</pre>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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<T> {
|
||||
data: T[] | null;
|
||||
error: null;
|
||||
}
|
||||
|
||||
interface MockSingleResult<T> {
|
||||
data: T | null;
|
||||
error: null;
|
||||
}
|
||||
|
||||
class MockQueryBuilder<T extends Record<string, unknown> = Record<string, unknown>> {
|
||||
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<string, unknown> | null = null;
|
||||
|
||||
constructor(tableName: string) {
|
||||
this.tableName = tableName;
|
||||
// No mock data - return empty results
|
||||
}
|
||||
|
||||
select(columns: string = "*", _opts?: Record<string, unknown>) {
|
||||
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<MockSingleResult<T>> so destructured binding
|
||||
// patterns in callers work under `--strict` (no implicit `any`).
|
||||
single(): Promise<MockSingleResult<T>> {
|
||||
return Promise.resolve(this.executeSingle());
|
||||
}
|
||||
|
||||
maybeSingle(): Promise<MockSingleResult<T>> {
|
||||
return Promise.resolve(this.executeSingle());
|
||||
}
|
||||
|
||||
then<TResult1 = MockQueryResult<T>, TResult2 = never>(
|
||||
onfulfilled?: ((value: MockQueryResult<T>) => TResult1 | PromiseLike<TResult1>) | null,
|
||||
_onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
||||
): PromiseLike<TResult1 | TResult2> {
|
||||
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<T> {
|
||||
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<T> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>)[this.orderColumn!] as string | number;
|
||||
const bVal = (b as Record<string, unknown>)[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<T> {
|
||||
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<string, unknown>).id ?? `generated-${Date.now()}-${i}`,
|
||||
}));
|
||||
return { data: returning as unknown as T[], error: null };
|
||||
}
|
||||
return { data: null, error: null };
|
||||
}
|
||||
}
|
||||
|
||||
interface MockMutationResult<T> {
|
||||
data: T[] | null;
|
||||
error: null;
|
||||
}
|
||||
|
||||
class MockMutationBuilder<T extends Record<string, unknown> = Record<string, unknown>> {
|
||||
private tableName: string;
|
||||
private data: unknown;
|
||||
|
||||
constructor(tableName: string, data: unknown) {
|
||||
this.tableName = tableName;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
select() {
|
||||
return new MockQueryBuilder<T>(this.tableName);
|
||||
}
|
||||
|
||||
eq() {
|
||||
return this;
|
||||
}
|
||||
|
||||
then<TResult1 = MockMutationResult<T>, TResult2 = never>(
|
||||
onfulfilled?: ((value: MockMutationResult<T>) => TResult1 | PromiseLike<TResult1>) | null,
|
||||
): PromiseLike<TResult1 | TResult2> {
|
||||
const result: MockMutationResult<T> = {
|
||||
data: Array.isArray(this.data)
|
||||
? this.data.map((item: Record<string, unknown>, i: number) => ({
|
||||
...item,
|
||||
id: item?.id ?? `generated-${Date.now()}-${i}`,
|
||||
})) as unknown as T[]
|
||||
: this.data
|
||||
? [{ ...(this.data as Record<string, unknown>), id: (this.data as Record<string, unknown>).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<string, unknown>;
|
||||
}
|
||||
|
||||
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<T extends Record<string, unknown> = Record<string, unknown>>(table: string) {
|
||||
const qb = new MockQueryBuilder<T>(table);
|
||||
return Object.assign(qb, {
|
||||
insert: (data: Record<string, unknown>) => new MockMutationBuilder<T>(table, data),
|
||||
update: (data: Record<string, unknown>) => new MockMutationBuilder<T>(table, data),
|
||||
upsert: (data: Record<string, unknown>) => new MockMutationBuilder<T>(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<string, unknown>): Promise<{ data: null; error: null }> => ({
|
||||
data: null,
|
||||
error: null,
|
||||
}),
|
||||
channel: () => ({
|
||||
on: () => ({ subscribe: () => ({}) }),
|
||||
subscribe: () => ({}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export const supabase = createMockClient();
|
||||
Reference in New Issue
Block a user