feat(storage): MinIO object storage, Neon Auth, Supabase removal
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s
- Add MinIO/S3-compatible storage client (src/lib/storage.ts) with uploadObject, deleteObject, presigned URL helpers, and BUCKETS constant - Wire product images, brand logos, and water log photos to MinIO via the new storage client - Migrate forgot-password to Neon Auth (remove Supabase /auth/v1/recover call) - Migrate send-scheduled cron to direct Postgres + Resend (remove Supabase Edge Function proxy) - Add logoUrl to email types (OrderReceipt, Welcome, PasswordReset) and pass brand_settings.logo_url from all call sites - Update email templates to use dynamic logoUrl instead of hardcoded Supabase bucket URLs - Remove hardcoded Supabase URLs from TuxedoVideoHero, TuxedoAboutPage, TimeTrackingFieldClient; use brand_settings props + local public/ fallback - Download brand logos (3) and tuxedo-hero.mp4 (36MB) from Supabase bucket to public/ for local development - Add MinIO env vars to .env.example (endpoint, access key, secret, buckets) - Fix TimeTrackingFieldClient to destructure logoUrl and brandAccent props - Fix admin/users.ts logoUrl type (null → undefined for optional string) - Remove stale sb- cookie from wholesale-auth - Migrate tuxedo/about page to remove supabase import and use pool query for wholesale_settings lookup
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getAdminOrderDetail } from "@/actions/orders";
|
||||
import { getAdminOrderDetail, type AdminOrder } from "@/actions/orders";
|
||||
import OrderEditForm from "@/components/admin/OrderEditForm";
|
||||
import OrderPaymentSection from "@/components/admin/OrderPaymentSection";
|
||||
import OrderPickupAction from "@/components/admin/OrderPickupAction";
|
||||
@@ -14,6 +14,15 @@ type OrderDetailPageProps = {
|
||||
}>;
|
||||
};
|
||||
|
||||
type OrderItem = {
|
||||
id: string;
|
||||
quantity: number;
|
||||
price: number | string;
|
||||
products?: {
|
||||
name: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
function formatCurrency(amount: number) {
|
||||
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
|
||||
}
|
||||
@@ -139,7 +148,7 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
|
||||
|
||||
{order.order_items && order.order_items.length > 0 ? (
|
||||
<div className="mt-4 divide-y divide-stone-100">
|
||||
{order.order_items.map((item: any) => (
|
||||
{order.order_items.map((item: OrderItem) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between py-3"
|
||||
@@ -214,7 +223,7 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
|
||||
<p className="mt-1 text-sm text-stone-500">Update customer details, pricing, and status</p>
|
||||
|
||||
<div className="mt-5">
|
||||
<OrderEditForm order={order as any} brandId={brandId} />
|
||||
<OrderEditForm order={order as unknown as Parameters<typeof OrderEditForm>[0]["order"]} brandId={brandId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -53,12 +53,12 @@ export default async function AdminOrdersPage() {
|
||||
}
|
||||
|
||||
const { data: prods } = await prodQuery;
|
||||
brandProducts = (prods ?? []).map((p: any) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
brandProducts = (prods ?? []).map((p) => ({
|
||||
id: String(p.id),
|
||||
name: String(p.name ?? ""),
|
||||
price: Number(p.price),
|
||||
type: p.type ?? null,
|
||||
active: p.active ?? true,
|
||||
type: p.type as string | null ?? null,
|
||||
active: Boolean(p.active ?? true),
|
||||
}));
|
||||
} catch {
|
||||
// non-fatal for the orders list
|
||||
|
||||
@@ -38,7 +38,7 @@ export default async function AdminPage() {
|
||||
.limit(1)
|
||||
.single();
|
||||
if (firstBrand?.id) {
|
||||
dashboardBrandId = firstBrand.id;
|
||||
dashboardBrandId = String(firstBrand.id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[admin/page] supabase brands lookup failed:", err);
|
||||
|
||||
@@ -4,6 +4,10 @@ import { getAdminOrders } from "@/actions/orders";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Pre-compute cutoff time to avoid impure function in render
|
||||
const PICKUP_WINDOW_MS = 72 * 60 * 60 * 1000;
|
||||
const pickedUpCutoff = new Date(Date.now() - PICKUP_WINDOW_MS);
|
||||
|
||||
export default async function DriverPickupPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
const { orders, stops } = await getAdminOrders();
|
||||
@@ -26,8 +30,7 @@ export default async function DriverPickupPage() {
|
||||
(o) =>
|
||||
o.pickup_complete &&
|
||||
o.pickup_completed_at &&
|
||||
new Date(o.pickup_completed_at) >
|
||||
new Date(Date.now() - 72 * 60 * 60 * 1000)
|
||||
new Date(o.pickup_completed_at) > pickedUpCutoff
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,12 +11,32 @@ type ProductDetailPageProps = {
|
||||
}>;
|
||||
};
|
||||
|
||||
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(),
|
||||
supabase.from("brands").select("id, name, slug"),
|
||||
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();
|
||||
@@ -109,14 +129,14 @@ export default async function ProductDetailPage({ params }: ProductDetailPagePro
|
||||
product={{
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
description: product.description ?? "",
|
||||
price: Number(product.price),
|
||||
type: product.type,
|
||||
type: product.type ?? "pickup",
|
||||
active: product.active,
|
||||
brand_id: product.brand_id,
|
||||
image_url: product.image_url,
|
||||
image_url: product.image_url ?? "",
|
||||
is_taxable: product.is_taxable,
|
||||
pickup_type: product.pickup_type,
|
||||
pickup_type: product.pickup_type ?? "pickup",
|
||||
}}
|
||||
brands={brands ?? []}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { asc, eq, sql } from "drizzle-orm";
|
||||
import { withPlatformAdmin, withTenant } from "@/db/client";
|
||||
import { products, productImages, tenants } from "@/db/schema";
|
||||
import { withPlatformAdmin, withBrand } from "@/db/client";
|
||||
import { products, productImages, brands } from "@/db/schema";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
@@ -53,14 +53,14 @@ export default async function AdminProductsPage() {
|
||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
||||
|
||||
// Platform admins need a brand picker for new products
|
||||
let brands: { id: string; name: string }[] = [];
|
||||
let brandOptions: { id: string; name: string }[] = [];
|
||||
if (isPlatformAdmin) {
|
||||
const result = await getBrands();
|
||||
brands = result.brands ?? [];
|
||||
brandOptions = result.brands ?? [];
|
||||
}
|
||||
|
||||
// Query products + their first image. The new SaaS schema:
|
||||
// * renamed `brand_id` → `tenant_id` on products
|
||||
// * renamed `brand_id` → `brand_id` on products
|
||||
// * renamed `price` (numeric) → `price_cents` (integer)
|
||||
// * moved `image_url` off the products table into a separate
|
||||
// `product_images` table (keyed by `product_id` + `position`)
|
||||
@@ -71,7 +71,7 @@ export default async function AdminProductsPage() {
|
||||
let productsList: ProductRow[] = [];
|
||||
let queryError: string | null = null;
|
||||
try {
|
||||
const baseQuery = (db: Parameters<Parameters<typeof withTenant>[1]>[0]) =>
|
||||
const baseQuery = (db: Parameters<Parameters<typeof withBrand>[1]>[0]) =>
|
||||
db
|
||||
.select({
|
||||
id: products.id,
|
||||
@@ -79,13 +79,13 @@ export default async function AdminProductsPage() {
|
||||
description: products.description,
|
||||
priceCents: products.priceCents,
|
||||
active: products.active,
|
||||
tenantId: products.tenantId,
|
||||
tenantName: tenants.name,
|
||||
brandId: products.brandId,
|
||||
brandName: brands.name,
|
||||
firstImageKey: productImages.storageKey,
|
||||
firstImagePosition: productImages.position,
|
||||
})
|
||||
.from(products)
|
||||
.leftJoin(tenants, eq(tenants.id, products.tenantId))
|
||||
.leftJoin(brands, eq(brands.id, products.brandId))
|
||||
// Pull only the lowest-position image per product. The
|
||||
// `position` index on product_images (product_id, position) makes
|
||||
// this cheap; a real-world scale would replace it with a
|
||||
@@ -105,7 +105,7 @@ export default async function AdminProductsPage() {
|
||||
const rows = isPlatformAdmin
|
||||
? await withPlatformAdmin((db) => baseQuery(db))
|
||||
: brandId
|
||||
? await withTenant(brandId, (db) => baseQuery(db))
|
||||
? await withBrand(brandId, (db) => baseQuery(db))
|
||||
: [];
|
||||
|
||||
// Resolve each row's first image to a public URL. Until object-store
|
||||
@@ -120,7 +120,7 @@ export default async function AdminProductsPage() {
|
||||
type: "pickup", // legacy column not in new schema
|
||||
active: r.active,
|
||||
image_url: r.firstImageKey, // storage key, not a public URL yet
|
||||
brand_id: r.tenantId,
|
||||
brand_id: r.brandId,
|
||||
is_taxable: false, // legacy column not in new schema
|
||||
available_from: null,
|
||||
available_until: null,
|
||||
@@ -147,7 +147,7 @@ export default async function AdminProductsPage() {
|
||||
<ProductsClient
|
||||
products={productsList}
|
||||
brandId={brandId ?? undefined}
|
||||
brands={brands}
|
||||
brands={brandOptions}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ export default async function ReportsPage() {
|
||||
const { data: brands } = await supabase
|
||||
.from("brands")
|
||||
.select("id, name")
|
||||
.order("name");
|
||||
.order("name") as unknown as { data: { id: string; name: string }[] | null };
|
||||
|
||||
const initialBrandId = isPlatformAdmin
|
||||
? null
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import { getRouteTraceLotDetail, getLotOrders } from "@/actions/route-trace/lots";
|
||||
@@ -26,13 +25,9 @@ export default async function LotDetailPage({ params }: { params: Promise<{ id:
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
const isDevMode = !!devSession;
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const enabled = isDevMode ? true : await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
|
||||
|
||||
const [detailResult, ordersResult] = await Promise.all([
|
||||
@@ -68,4 +63,4 @@ export default async function LotDetailPage({ params }: { params: Promise<{ id:
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import { getRouteTraceLots } from "@/actions/route-trace/lots";
|
||||
@@ -22,13 +21,9 @@ export default async function LotsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
const isDevMode = !!devSession;
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const enabled = isDevMode ? true : await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
|
||||
|
||||
const [statsResult, lotsResult, haulingResult, yieldResult, invResult, eventsResult] = await Promise.all([
|
||||
@@ -62,4 +57,4 @@ export default async function LotsPage() {
|
||||
lots={allLots}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import {
|
||||
@@ -41,14 +40,9 @@ export default async function RouteTraceDashboardPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
const isDevMode = !!devSession;
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
// Bypass feature check in dev mode (dev_session cookie present)
|
||||
const enabled = isDevMode ? true : await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
|
||||
|
||||
const [statsResult, lotsResult, haulingResult, yieldResult, invResult, eventsResult] = await Promise.all([
|
||||
@@ -110,4 +104,4 @@ export default async function RouteTraceDashboardPage() {
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export default async function BillingPage({ params }: Props) {
|
||||
.limit(1)
|
||||
.single();
|
||||
if (firstBrand?.id) {
|
||||
resolvedBrandId = firstBrand.id;
|
||||
resolvedBrandId = String(firstBrand.id);
|
||||
} else {
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--admin-bg)] px-6 py-12">
|
||||
|
||||
@@ -18,7 +18,7 @@ export default async function IntegrationsPage() {
|
||||
|
||||
// Platform admins: fetch all brands for the picker
|
||||
const brands = isPlatformAdmin
|
||||
? (await supabase.from("brands").select("id, name").order("name")).data ?? []
|
||||
? ((await supabase.from("brands").select("id, name").order("name")) as unknown as { data: { id: string; name: string }[] }).data ?? []
|
||||
: [];
|
||||
|
||||
return (
|
||||
|
||||
@@ -17,7 +17,7 @@ export default async function ShippingSettingsPage() {
|
||||
|
||||
// Platform admins: fetch all brands for the picker
|
||||
const brands = isPlatformAdmin
|
||||
? (await supabase.from("brands").select("id, name").order("name")).data ?? []
|
||||
? ((await supabase.from("brands").select("id, name").order("name")) as unknown as { data: { id: string; name: string }[] }).data ?? []
|
||||
: [];
|
||||
|
||||
const effectiveBrandId = brandId || (brands[0]?.id ?? "");
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function SquareSyncSettingsClient({ settings, logs, brandId }: Pr
|
||||
setError(null);
|
||||
const result = await savePaymentSettings({
|
||||
brandId,
|
||||
provider: (settings?.provider as any) || null,
|
||||
provider: (settings?.provider ?? null) as "stripe" | "square" | "manual" | null,
|
||||
squareSyncEnabled,
|
||||
squareInventoryMode,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getPaymentSettings } from "@/actions/payments";
|
||||
import { getPaymentSettings, type PaymentSettings } from "@/actions/payments";
|
||||
import { getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
|
||||
import SquareSyncSettingsClient from "./SquareSyncSettingsClient";
|
||||
|
||||
@@ -26,7 +26,7 @@ export default async function SquareSyncSettingsPage() {
|
||||
|
||||
return (
|
||||
<SquareSyncSettingsClient
|
||||
settings={settings as any}
|
||||
settings={settings as PaymentSettings}
|
||||
logs={logs}
|
||||
brandId={brandId}
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,38 @@ type StopDetailPageProps = {
|
||||
}>;
|
||||
};
|
||||
|
||||
interface Stop {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
address: string | null;
|
||||
zip: string | null;
|
||||
date: string;
|
||||
time: string;
|
||||
cutoff_date: string | null;
|
||||
cutoff_time: string | null;
|
||||
status: string;
|
||||
location: string;
|
||||
slug: string;
|
||||
active: boolean;
|
||||
brands?: { name: string; slug: string };
|
||||
}
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
price: number;
|
||||
image_url?: string | null;
|
||||
}
|
||||
|
||||
interface ProductStop {
|
||||
id: string;
|
||||
product_id: string;
|
||||
products?: Product;
|
||||
}
|
||||
|
||||
export default async function StopDetailPage({ params }: StopDetailPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
@@ -21,8 +53,8 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
|
||||
.from("stops")
|
||||
.select("*, brands(name, slug)")
|
||||
.eq("id", id)
|
||||
.single(),
|
||||
supabase.from("brands").select("id, name, slug"),
|
||||
.single() as unknown as { data: Stop | null; error: { message: string } | null },
|
||||
supabase.from("brands").select("id, name, slug") as unknown as { data: { id: string; name: string; slug: string }[] | null },
|
||||
]);
|
||||
|
||||
const adminUser = await getAdminUser();
|
||||
@@ -61,15 +93,24 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
|
||||
.from("products")
|
||||
.select("id, name, type, price")
|
||||
.eq("brand_id", stop.brand_id)
|
||||
.eq("active", true),
|
||||
.eq("active", true) as unknown as { data: Product[] | null },
|
||||
supabase
|
||||
.from("product_stops")
|
||||
.select("id, product_id, products(id, name, type, price)")
|
||||
.eq("stop_id", id),
|
||||
.eq("stop_id", id) as unknown as { data: ProductStop[] | null },
|
||||
]);
|
||||
|
||||
const assignedProducts = (productStops ?? [])
|
||||
.map((ps: any) => ps)
|
||||
.map((ps: ProductStop) => ({
|
||||
id: ps.id,
|
||||
product_id: ps.product_id,
|
||||
products: ps.products ? {
|
||||
name: ps.products.name,
|
||||
type: ps.products.type,
|
||||
price: ps.products.price,
|
||||
image_url: ps.products.image_url,
|
||||
} : null,
|
||||
}))
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
|
||||
@@ -36,7 +36,7 @@ export default async function NewStopPage({
|
||||
.from("stops")
|
||||
.select("city, state, location, date, time, brand_id, active, address, zip, cutoff_time")
|
||||
.eq("id", duplicate)
|
||||
.single();
|
||||
.single() as unknown as { data: Stop | null };
|
||||
duplicateFrom = data;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export default async function NewStopPage({
|
||||
.from("products")
|
||||
.select("id, name, type, price")
|
||||
.eq("brand_id", brandId)
|
||||
.eq("active", true),
|
||||
.eq("active", true) as unknown as { data: { id: string; name: string; type: string; price: number }[] | null },
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,6 +6,23 @@ import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { PageHeader } from "@/components/admin/design-system";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
interface Stop {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
active: boolean;
|
||||
deleted_at: string | null;
|
||||
brand_id: string;
|
||||
address: string | null;
|
||||
zip: string | null;
|
||||
cutoff_time: string | null;
|
||||
status: string;
|
||||
brands: { name: string } | { name: string }[];
|
||||
}
|
||||
|
||||
const StopIcon = () => (
|
||||
<svg className="h-5 w-5 sm:h-6 sm:w-6 text-current" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/>
|
||||
@@ -49,7 +66,7 @@ export default async function AdminStopsPage() {
|
||||
query = query.eq("brand_id", adminUser.brand_id);
|
||||
}
|
||||
|
||||
const { data: stops, error } = await query;
|
||||
const { data: stops, error } = await query as unknown as { data: Stop[] | null; error: { message: string } | null };
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
||||
@@ -29,9 +29,9 @@ export default async function TaxesPage({ params }: Props) {
|
||||
.from("brands")
|
||||
.select("id")
|
||||
.limit(1)
|
||||
.single();
|
||||
.single() as unknown as { data: { id: string } | null };
|
||||
if (firstBrand?.id) {
|
||||
resolvedBrandId = firstBrand.id;
|
||||
resolvedBrandId = String(firstBrand.id);
|
||||
} else {
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import TimeTrackingAdminPanel from "@/components/admin/TimeTrackingAdminPanel";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { PageHeader } from "@/components/admin/design-system";
|
||||
|
||||
@@ -11,16 +10,9 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminTimeTrackingPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
// Dev session bypass for platform admin
|
||||
|
||||
if (!adminUser) {
|
||||
const cookieStore = await cookies();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
if (devSession === "platform_admin" || devSession === "brand_admin") {
|
||||
// Allow access in dev mode
|
||||
} else {
|
||||
redirect("/login");
|
||||
}
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const isPlatformAdmin = adminUser?.role === "platform_admin";
|
||||
@@ -45,4 +37,4 @@ export default async function AdminTimeTrackingPage() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { getWaterAdminSettings, saveWaterAdminSettings, type WaterAdminSettings } from "@/actions/water-log/settings";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
@@ -24,11 +24,7 @@ export default function WaterLogSettingsPage() {
|
||||
const [alertPhone, setAlertPhone] = useState("");
|
||||
const [alertsEnabled, setAlertsEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
async function loadSettings() {
|
||||
const loadSettings = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const data = await getWaterAdminSettings(TUXEDO_BRAND_ID);
|
||||
if (data) {
|
||||
@@ -42,7 +38,14 @@ export default function WaterLogSettingsPage() {
|
||||
setAlertsEnabled(data.alerts_enabled ?? false);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
await loadSettings();
|
||||
};
|
||||
init();
|
||||
}, [loadSettings]);
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -41,6 +41,15 @@ import { PageHeader, AdminButton, AdminSearchInput, AdminFilterTabs, AdminBadge
|
||||
|
||||
type Tab = "dashboard" | "customers" | "products" | "orders" | "settings";
|
||||
|
||||
// SVG Icon for Wholesale - defined at module level to prevent recreation on each render
|
||||
function WholesaleIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WholesaleClient({ brandId }: { brandId: string }) {
|
||||
const [tab, setTab] = useState<Tab>("dashboard");
|
||||
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
|
||||
@@ -101,13 +110,6 @@ export default function WholesaleClient({ brandId }: { brandId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// SVG Icon for Wholesale
|
||||
const WholesaleIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const tabs = [
|
||||
{ value: "dashboard", label: "Dashboard", count: undefined },
|
||||
{ value: "customers", label: "Customers", count: customers.filter(c => c.account_status !== "pending_approval" && c.account_status !== "rejected").length },
|
||||
@@ -116,10 +118,13 @@ export default function WholesaleClient({ brandId }: { brandId: string }) {
|
||||
{ value: "settings", label: "Settings" },
|
||||
];
|
||||
|
||||
// Declare icon reference for PageHeader
|
||||
const pageIcon = <WholesaleIcon />;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||
<PageHeader
|
||||
icon={<WholesaleIcon />}
|
||||
icon={pageIcon}
|
||||
title="Wholesale Portal"
|
||||
subtitle="Manage wholesale orders, customers, and products"
|
||||
className="mb-0"
|
||||
@@ -1843,13 +1848,7 @@ function SettingsTab({ settings, brandId, onMsg, onRefresh, canManageSettings }:
|
||||
onRefresh: () => void;
|
||||
canManageSettings: boolean;
|
||||
}) {
|
||||
if (!canManageSettings) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-slate-400">You do not have permission to manage settings.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Hooks must be called unconditionally - always declare them first
|
||||
const [form, setForm] = useState({
|
||||
requireApproval: settings?.require_approval ?? true,
|
||||
minOrderAmount: settings?.min_order_amount ?? "",
|
||||
@@ -1868,6 +1867,15 @@ function SettingsTab({ settings, brandId, onMsg, onRefresh, canManageSettings }:
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Permission check after hooks
|
||||
if (!canManageSettings) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-slate-400">You do not have permission to manage settings.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
const result = await saveWholesaleSettings({
|
||||
|
||||
@@ -1,25 +1,12 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import WholesaleClient from "./WholesaleClient";
|
||||
|
||||
export default async function WholesalePage() {
|
||||
const cookieStore = await cookies();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
const isDevMode =
|
||||
devSession === "platform_admin" ||
|
||||
devSession === "brand_admin" ||
|
||||
devSession === "store_employee";
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
if (!isDevMode) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
const activeBrandId = await getActiveBrandId(adminUser);
|
||||
return <WholesaleClient brandId={activeBrandId ?? ""} />;
|
||||
}
|
||||
|
||||
// Dev mode: platform_admin sees all brands, use first brand as default
|
||||
const brandId = devSession === "platform_admin" ? "" : (cookieStore.get("dev_brand_id")?.value ?? "64294306-5f42-463d-a5e8-2ad6c81a96de");
|
||||
return <WholesaleClient brandId={brandId} />;
|
||||
}
|
||||
const activeBrandId = await getActiveBrandId(adminUser);
|
||||
return <WholesaleClient brandId={activeBrandId ?? ""} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user