feat(storage): MinIO object storage, Neon Auth, Supabase removal
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:
openclaw
2026-06-09 11:32:17 -06:00
parent bb464bda65
commit 916ad39176
349 changed files with 6706 additions and 3343 deletions
+7 -4
View File
@@ -143,9 +143,12 @@ export default function AdminOrdersPanel({
// Open the New Order modal when dashboard links here with ?new=true
useEffect(() => {
if (searchParams?.get("new") === "true") {
setShowNewOrderModal(true);
}
const init = async () => {
if (searchParams?.get("new") === "true") {
setShowNewOrderModal(true);
}
};
init();
}, [searchParams]);
// --- New Order (admin manual create) helpers ---
@@ -765,7 +768,7 @@ export default function AdminOrdersPanel({
<div className="col-span-2">
<select
value={item.fulfillment}
onChange={(e) => updateNewOrderItem(idx, { fulfillment: e.target.value as any })}
onChange={(e) => updateNewOrderItem(idx, { fulfillment: e.target.value as "pickup" | "ship" })}
className="w-full rounded border px-2 py-1 text-sm"
>
<option value="pickup">pickup</option>
@@ -316,7 +316,7 @@ export default function CommandCenterDashboard() {
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
@@ -4,6 +4,7 @@ import { useState, useCallback, useEffect } from "react";
import { type Campaign, type CampaignType } from "@/actions/harvest-reach/campaigns";
import { type Template } from "@/actions/communications/templates";
import type { Segment, SegmentRuleV2 } from "@/actions/harvest-reach/segments";
import type { AudienceRules } from "@/actions/communications/campaigns";
import { AdminButton } from "@/components/admin/design-system";
type Props = {
@@ -396,7 +397,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
template_id: selectedTemplateId || undefined,
campaign_type: campaignType,
status: sendNow ? "draft" : "scheduled",
audience_rules: rules as any,
audience_rules: rules as AudienceRules,
scheduled_at: sendNow ? undefined : scheduledAt || undefined,
});
@@ -76,7 +76,7 @@ export default function MatchingCustomersPanel({ brandId, rules, onCountChange }
const searched = search
? filtered.filter(
(c) =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
(c.fullName ?? "").toLowerCase().includes(search.toLowerCase()) ||
c.email.toLowerCase().includes(search.toLowerCase())
)
: filtered;
@@ -169,10 +169,10 @@ export default function MatchingCustomersPanel({ brandId, rules, onCountChange }
style={{ animationDelay: `${idx * 20}ms` }}
>
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-[var(--admin-accent-light)] to-[var(--admin-accent)]/20 flex items-center justify-center text-xs font-bold text-[var(--admin-accent)] flex-shrink-0">
{c.name ? c.name.slice(0, 2).toUpperCase() : "??"}
{c.fullName ? c.fullName.slice(0, 2).toUpperCase() : "??"}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-[var(--admin-text-primary)] truncate">{c.name || "(no name)"}</p>
<p className="text-sm font-semibold text-[var(--admin-text-primary)] truncate">{c.fullName || "(no name)"}</p>
<p className="text-xs text-[var(--admin-text-muted)] truncate">{c.email}</p>
</div>
{c.tags.length > 0 && (
+1 -1
View File
@@ -24,7 +24,7 @@ type Order = {
discount_amount: number;
tax_amount: number | null;
tax_rate: number | null;
tax_source: string | null;
tax_source?: string | null;
tax_location: string | null;
internal_notes: string | null;
discount_reason: string | null;
+24 -18
View File
@@ -118,28 +118,34 @@ export default function ProductFormModal({
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
const init = async () => {
setMounted(true);
};
init();
}, []);
// Reset state when opening with new initial values
useEffect(() => {
if (!open) return;
setName(initial?.name ?? "");
setDescription(initial?.description ?? "");
setPrice(initial?.price ?? "");
setType((initial?.type as ProductFormValues["type"]) ?? "pickup");
setBrandId(initial?.brand_id ?? lockedBrandId ?? brands[0]?.id ?? "");
setActive(initial?.active ?? true);
setIsTaxable(initial?.is_taxable ?? true);
setAvailableFrom(initial?.available_from ? initial.available_from.split('T')[0] : "");
setAvailableUntil(initial?.available_until ? initial.available_until.split('T')[0] : "");
setImagePreview(initialImageUrl);
setImageUrl(initialImageUrl);
setUploading(false);
setUploadError(null);
setError(null);
setSaving(false);
setIsDrag(false);
const init = async () => {
if (!open) return;
setName(initial?.name ?? "");
setDescription(initial?.description ?? "");
setPrice(initial?.price ?? "");
setType((initial?.type as ProductFormValues["type"]) ?? "pickup");
setBrandId(initial?.brand_id ?? lockedBrandId ?? brands[0]?.id ?? "");
setActive(initial?.active ?? true);
setIsTaxable(initial?.is_taxable ?? true);
setAvailableFrom(initial?.available_from ? initial.available_from.split('T')[0] : "");
setAvailableUntil(initial?.available_until ? initial.available_until.split('T')[0] : "");
setImagePreview(initialImageUrl);
setImageUrl(initialImageUrl);
setUploading(false);
setUploadError(null);
setError(null);
setSaving(false);
setIsDrag(false);
};
init();
}, [open, initial, initialImageUrl, lockedBrandId, brands]);
// Body scroll lock + escape key
+16 -10
View File
@@ -392,19 +392,25 @@ function TableView({
const buttonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
useEffect(() => {
setMounted(true);
const init = async () => {
setMounted(true);
};
init();
}, []);
useEffect(() => {
if (!deleteConfirm) {
setMenuPos(null);
return;
}
const btn = buttonRefs.current[deleteConfirm];
if (btn) {
const rect = btn.getBoundingClientRect();
setMenuPos({ top: rect.bottom + 4, left: rect.right, width: rect.width });
}
const init = async () => {
if (!deleteConfirm) {
setMenuPos(null);
return;
}
const btn = buttonRefs.current[deleteConfirm];
if (btn) {
const rect = btn.getBoundingClientRect();
setMenuPos({ top: rect.bottom + 4, left: rect.right, width: rect.width });
}
};
init();
}, [deleteConfirm]);
// Reposition on scroll/resize so the popup stays anchored to its button.
+59 -27
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { getSyncLog, syncSquareNow, type SyncLogEntry } from "@/actions/square-sync-ui";
import { getPaymentSettings } from "@/actions/payments";
@@ -23,28 +23,49 @@ export default function SquareSyncWidget({ brandId }: Props) {
const [syncMsg, setSyncMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
const [queueCount, setQueueCount] = useState(0);
useEffect(() => {
getPaymentSettings(brandId).then((r) => {
if (r.success && r.settings) setSettings(r.settings as any);
});
getSyncLog(brandId).then((r) => {
if (r.success) setLogs(r.logs.slice(0, 5));
});
// Poll pending queue count every 30s
const interval = setInterval(() => checkQueueCount(), 30000);
checkQueueCount();
return () => clearInterval(interval);
}, [brandId]);
async function checkQueueCount() {
const checkQueueCount = useCallback(async () => {
const { supabase } = await import("@/lib/supabase");
const { count } = await supabase
const result = await supabase
.from("square_sync_queue")
.select("*", { count: "exact", head: true })
.eq("brand_id", brandId)
.in("status", ["pending"]);
setQueueCount(count ?? 0);
}
.in("status", ["pending"]) as unknown as { count: number | null };
setQueueCount(result.count ?? 0);
}, [brandId]);
useEffect(() => {
const init = async () => {
const [settingsResult, logsResult] = await Promise.all([
getPaymentSettings(brandId),
getSyncLog(brandId),
]);
if (settingsResult.success && settingsResult.settings) {
setSettings({
provider: settingsResult.settings.provider ?? null,
square_access_token: settingsResult.settings.square_access_token ?? null,
square_sync_enabled: settingsResult.settings.square_sync_enabled ?? false,
square_inventory_mode: settingsResult.settings.square_inventory_mode ?? "none",
square_last_sync_at: settingsResult.settings.square_last_sync_at ?? null,
square_last_sync_error: settingsResult.settings.square_last_sync_error ?? null,
});
}
if (logsResult.success) {
setLogs(logsResult.logs.slice(0, 5));
}
};
init();
}, [brandId]);
useEffect(() => {
// Poll pending queue count every 30s
const interval = setInterval(async () => {
await checkQueueCount();
}, 30000);
(async () => {
await checkQueueCount();
})();
return () => clearInterval(interval);
}, [checkQueueCount]);
useEffect(() => {
if (!syncMsg) return;
@@ -56,19 +77,30 @@ export default function SquareSyncWidget({ brandId }: Props) {
setSyncing(true);
setSyncMsg(null);
const result = await syncSquareNow(brandId, type);
setSyncing(false);
setSyncMsg({
kind: result.success ? "success" : "error",
text: result.success
? `Sync complete — ${result.synced} item(s) synced.`
: `Sync failed: ${result.errors[0] ?? "Unknown error"}`,
});
setSyncing(false);
getSyncLog(brandId).then((r) => {
if (r.success) setLogs(r.logs.slice(0, 5));
});
getPaymentSettings(brandId).then((r) => {
if (r.success && r.settings) setSettings(r.settings as any);
});
const [logsResult, settingsResult] = await Promise.all([
getSyncLog(brandId),
getPaymentSettings(brandId),
]);
if (logsResult.success) {
setLogs(logsResult.logs.slice(0, 5));
}
if (settingsResult.success && settingsResult.settings) {
setSettings({
provider: settingsResult.settings.provider ?? null,
square_access_token: settingsResult.settings.square_access_token ?? null,
square_sync_enabled: settingsResult.settings.square_sync_enabled ?? false,
square_inventory_mode: settingsResult.settings.square_inventory_mode ?? "none",
square_last_sync_at: settingsResult.settings.square_last_sync_at ?? null,
square_last_sync_error: settingsResult.settings.square_last_sync_error ?? null,
});
}
}
const lastSyncAt = settings?.square_last_sync_at
@@ -227,4 +259,4 @@ function timeAgo(iso: string): string {
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
}
+2 -2
View File
@@ -47,7 +47,7 @@ export default function StopMessagingForm({
setLoading(false);
}
function useQuickMessage(msg: string) {
function applyQuickMessage(msg: string) {
setMessage(msg);
setCustomMessage(msg);
}
@@ -146,7 +146,7 @@ export default function StopMessagingForm({
{quickMessages.map((qm) => (
<button
key={qm.label}
onClick={() => useQuickMessage(qm.value)}
onClick={() => applyQuickMessage(qm.value)}
className={`rounded-full px-3 py-1 text-sm font-medium transition-colors ${
message === qm.value
? "bg-slate-900 text-white"
+19 -8
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import { formatDate } from "@/lib/date-utils";
import {
exportTaxReport,
@@ -190,13 +190,24 @@ export default function TaxDashboard({
else fetchTaxableOrders();
}
// Auto-fetch when tab/brand/range changes
if (summary === null && activeTab === "summary" && selectedBrandId && !loading) {
fetchTaxSummary();
}
if (orders.length === 0 && activeTab === "orders" && selectedBrandId && !loading) {
fetchTaxableOrders();
}
// Auto-fetch when tab/brand/range changes — use useEffect to avoid render-time side effects
useEffect(() => {
const init = async () => {
if (summary === null && activeTab === "summary" && selectedBrandId && !loading) {
await fetchTaxSummary();
}
};
init();
}, [summary, activeTab, selectedBrandId, loading, fetchTaxSummary]);
useEffect(() => {
const init = async () => {
if (orders.length === 0 && activeTab === "orders" && selectedBrandId && !loading) {
await fetchTaxableOrders();
}
};
init();
}, [orders.length, activeTab, selectedBrandId, loading, fetchTaxableOrders]);
function handleExportCSV() {
if (activeTab === "summary" && summary) {
+6 -6
View File
@@ -243,8 +243,8 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
} else {
setPasswordModal({ email, tempPassword: null, loading: false, error: result.error ?? "Failed" });
}
} catch (e: any) {
setPasswordModal({ email, tempPassword: null, loading: false, error: e?.message ?? "Error" });
} catch (e: unknown) {
setPasswordModal({ email, tempPassword: null, loading: false, error: e instanceof Error ? e.message : "Error" });
}
}
@@ -254,8 +254,8 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
const res = await setMustChangePassword(id);
if (res.error) throw new Error(res.error);
setUsers((prev) => prev.map((u) => u.id === id ? { ...u, must_change_password: true } : u));
} catch (e: any) {
setError(e?.message ?? "Failed to require password change");
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to require password change");
}
}
@@ -267,9 +267,9 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
setResetLinkLoading(null);
if (result.error) throw new Error(result.error);
setError(null);
} catch (e: any) {
} catch (e: unknown) {
setResetLinkLoading(null);
setError(e?.message ?? "Failed to send reset email");
setError(e instanceof Error ? e.message : "Failed to send reset email");
}
}
@@ -25,8 +25,8 @@ export function AdminInput({
// Clone child to inject id + aria props if it's a valid element (input/textarea/select)
const enhancedChildren = React.isValidElement(children)
? React.cloneElement(children as React.ReactElement<any>, {
id: (children as any).props?.id || generatedId,
? React.cloneElement(children as React.ReactElement<React.InputHTMLAttributes<HTMLInputElement>>, {
id: (children as React.ReactElement<{ id?: string }>).props?.id || generatedId,
"aria-required": required ? "true" : undefined,
required: required ? true : undefined,
"aria-describedby": error ? `${generatedId}-error` : helpText ? `${generatedId}-help` : undefined,