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:
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user