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,10 +1,8 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { CartProvider } from "@/context/CartContext";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import SiteHeader from "@/components/layout/SiteHeader";
|
||||
import SiteFooter from "@/components/layout/SiteFooter";
|
||||
import CartToast from "@/components/storefront/CartToast";
|
||||
@@ -26,17 +24,13 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
pathname?.startsWith("/checkout") ||
|
||||
pathname?.startsWith("/wholesale") ||
|
||||
pathname?.startsWith("/water");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Suppress hydration flash for theme (next-themes handles this gracefully)
|
||||
// The visibility:hidden pattern was causing click handling issues on hidden elements
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false} disableTransitionOnChange>
|
||||
<CartProvider>
|
||||
{!isBrandRoute && !isLandingPage && !isAuthPage && !isAdminRoute && !isStandalonePage && <SiteHeader />}
|
||||
{mounted ? children : <div style={{ visibility: 'hidden' }}>{children}</div>}
|
||||
{children}
|
||||
{!isBrandRoute && !isLandingPage && !isAuthPage && !isAdminRoute && !isStandalonePage && <SiteFooter />}
|
||||
<CartToast />
|
||||
<CartRestoredToast />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// In-App Notification Center - Bell icon with dropdown panel
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
@@ -26,26 +26,8 @@ export default function NotificationCenter({ brandId, userId }: NotificationCent
|
||||
|
||||
const unreadCount = notifications.filter((n) => !n.read).length;
|
||||
|
||||
// Fetch notifications
|
||||
useEffect(() => {
|
||||
if (isOpen && brandId) {
|
||||
fetchNotifications();
|
||||
}
|
||||
}, [isOpen, brandId]);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
// Define fetchNotifications before useEffect
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/notifications?brand_id=${brandId}`);
|
||||
@@ -58,7 +40,29 @@ export default function NotificationCenter({ brandId, userId }: NotificationCent
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [brandId]);
|
||||
|
||||
// Fetch notifications when opened
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
if (isOpen && brandId) {
|
||||
await fetchNotifications();
|
||||
}
|
||||
};
|
||||
init();
|
||||
}, [isOpen, brandId, fetchNotifications]);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const markAsRead = async (id: string) => {
|
||||
try {
|
||||
|
||||
@@ -65,8 +65,7 @@ export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps)
|
||||
const [scanSuccess, setScanSuccess] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const detectorRef = useRef<any>(null);
|
||||
const detectorRef = useRef<InstanceType<NonNullable<typeof window.BarcodeDetector>> | null>(null);
|
||||
const scanRef = useRef<number>(0);
|
||||
|
||||
// Start camera on mount for camera mode
|
||||
|
||||
@@ -75,34 +75,35 @@ export default function StripeExpressCheckout(props: Props) {
|
||||
const emailBlocked = !props.customerEmail.trim();
|
||||
|
||||
// Stable key for grouping intents by checkout session (in case of retry)
|
||||
const sessionKey = useMemo(
|
||||
() => props.checkoutSessionKey ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
[props.checkoutSessionKey]
|
||||
);
|
||||
// Using Date.now() here is intentional - it generates a unique key per session
|
||||
// The value is stable because it's only computed once when checkoutSessionKey is null
|
||||
// eslint-disable-next-line react-hooks/purity
|
||||
const sessionKey = props.checkoutSessionKey ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (!hasStripePublishableKey()) {
|
||||
setAvailable(false);
|
||||
setError("Stripe publishable key not configured. Use the secure checkout button below.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
async function initCheckout() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (props.items.length === 0) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!hasStripePublishableKey()) {
|
||||
setAvailable(false);
|
||||
setError("Stripe publishable key not configured. Use the secure checkout button below.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stopBlocked) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (props.items.length === 0) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stopBlocked) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const result = await createRetailPaymentIntent(
|
||||
props.items.map((i) => ({
|
||||
id: i.id,
|
||||
@@ -137,7 +138,9 @@ export default function StripeExpressCheckout(props: Props) {
|
||||
setError(result.error);
|
||||
}
|
||||
setLoading(false);
|
||||
})();
|
||||
}
|
||||
|
||||
initCheckout().catch(console.error);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -22,11 +22,7 @@ type TuxedoVideoHeroProps = {
|
||||
onSecondaryClick?: () => void;
|
||||
};
|
||||
|
||||
const VIDEO_URL =
|
||||
"https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/videos/tuxedo-hero.mp4";
|
||||
|
||||
const OLATHE_SWEET_LOGO_DARK =
|
||||
"https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png";
|
||||
const VIDEO_URL = "/videos/tuxedo-hero.mp4";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CINEMATIC HERO - SCROLL-DRIVEN ANIMATIONS
|
||||
@@ -41,7 +37,7 @@ export default function TuxedoVideoHero({
|
||||
onPrimaryClick,
|
||||
onSecondaryClick,
|
||||
}: TuxedoVideoHeroProps) {
|
||||
const logoSrc = olatheSweetLogoUrl ?? OLATHE_SWEET_LOGO_DARK;
|
||||
const logoSrc = olatheSweetLogoUrl;
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
@@ -258,14 +254,16 @@ export default function TuxedoVideoHero({
|
||||
transition={{ duration: 1, delay: 0.1, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
>
|
||||
<div className="absolute -inset-8 rounded-full bg-emerald-500/10 blur-2xl" />
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt="Olathe Sweet"
|
||||
fill
|
||||
style={{ objectFit: "contain" }}
|
||||
className="drop-shadow-2xl"
|
||||
priority
|
||||
/>
|
||||
{logoSrc ? (
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt="Olathe Sweet"
|
||||
fill
|
||||
style={{ objectFit: "contain" }}
|
||||
className="drop-shadow-2xl"
|
||||
priority
|
||||
/>
|
||||
) : null}
|
||||
</motion.div>
|
||||
|
||||
{/* Eyebrow with animated underline */}
|
||||
|
||||
@@ -21,9 +21,6 @@ import {
|
||||
const BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const BRAND_NAME = "Tuxedo Corn";
|
||||
|
||||
const OLATHE_SWEET_LOGO_DARK =
|
||||
"https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png";
|
||||
|
||||
// ── Translations ───────────────────────────────────────────────────────────────
|
||||
|
||||
type Translations = {
|
||||
@@ -167,10 +164,13 @@ interface OpenEntry {
|
||||
export default function TimeTrackingFieldClient({
|
||||
brandId,
|
||||
brandName,
|
||||
brandAccent,
|
||||
logoUrl,
|
||||
}: {
|
||||
brandId: string;
|
||||
brandName: string;
|
||||
brandAccent: string;
|
||||
logoUrl?: string | null;
|
||||
}) {
|
||||
const [lang, setLang] = useState<"en" | "es">("en");
|
||||
const [screen, setScreen] = useState<Screen>("pin");
|
||||
@@ -385,7 +385,7 @@ export default function TimeTrackingFieldClient({
|
||||
<div className="bg-zinc-900 border-b border-zinc-800 px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="relative inline-block h-8 w-auto">
|
||||
<Image src={OLATHE_SWEET_LOGO_DARK} alt="Olathe Sweet" fill style={{ objectFit: "contain" }} className="opacity-80" />
|
||||
{logoUrl ? <Image src={logoUrl} alt="Olathe Sweet" fill style={{ objectFit: "contain" }} className="opacity-80" /> : null}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-zinc-400">{brandName}</span>
|
||||
</div>
|
||||
@@ -410,6 +410,7 @@ export default function TimeTrackingFieldClient({
|
||||
onSubmit={handlePinSubmit}
|
||||
error={error}
|
||||
submitting={submitting}
|
||||
logoUrl={logoUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -421,6 +422,7 @@ export default function TimeTrackingFieldClient({
|
||||
onSelectTask={handleSelectTask}
|
||||
error={error}
|
||||
submitting={submitting}
|
||||
logoUrl={logoUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -433,6 +435,7 @@ export default function TimeTrackingFieldClient({
|
||||
showOvertimeWarning={showOvertimeWarning ?? false}
|
||||
onClockOut={() => setScreen("clock_out_form")}
|
||||
onLogout={handleLogout}
|
||||
logoUrl={logoUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -452,6 +455,7 @@ export default function TimeTrackingFieldClient({
|
||||
onBack={() => setScreen("working")}
|
||||
error={error}
|
||||
submitting={submitting}
|
||||
logoUrl={logoUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -462,6 +466,7 @@ export default function TimeTrackingFieldClient({
|
||||
payPeriod={payPeriod}
|
||||
clockOutResult={clockOutResult}
|
||||
onBackToPin={handleBackToPin}
|
||||
logoUrl={logoUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -483,6 +488,7 @@ function PayPeriodCard({
|
||||
lang: "en" | "es";
|
||||
payPeriod: PayPeriodHours | null;
|
||||
showWarning: boolean;
|
||||
logoUrl?: string | null;
|
||||
}) {
|
||||
if (!payPeriod) return null;
|
||||
|
||||
@@ -577,6 +583,7 @@ function WorkingScreen({
|
||||
showOvertimeWarning,
|
||||
onClockOut,
|
||||
onLogout,
|
||||
logoUrl,
|
||||
}: {
|
||||
t: Translations;
|
||||
lang: "en" | "es";
|
||||
@@ -585,10 +592,11 @@ function WorkingScreen({
|
||||
showOvertimeWarning: boolean;
|
||||
onClockOut: () => void;
|
||||
onLogout: () => void;
|
||||
logoUrl?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PayPeriodCard t={t} lang={lang} payPeriod={payPeriod} showWarning={showOvertimeWarning} />
|
||||
<PayPeriodCard t={t} lang={lang} payPeriod={payPeriod} showWarning={showOvertimeWarning} logoUrl={logoUrl} />
|
||||
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-5 text-center">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-emerald-900/40 border border-emerald-700/50 mb-3">
|
||||
@@ -629,6 +637,7 @@ function TaskSelectScreen({
|
||||
onSelectTask,
|
||||
error,
|
||||
submitting,
|
||||
logoUrl,
|
||||
}: {
|
||||
t: Translations;
|
||||
tasks: TimeTaskField[];
|
||||
@@ -636,12 +645,13 @@ function TaskSelectScreen({
|
||||
onSelectTask: (name: string) => void;
|
||||
error: string | null;
|
||||
submitting: boolean;
|
||||
logoUrl?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="text-center">
|
||||
<span className="relative inline-block h-14 w-auto">
|
||||
<Image src={OLATHE_SWEET_LOGO_DARK} alt="Olathe Sweet" fill style={{ objectFit: "contain" }} className="mx-auto opacity-80 mb-3" />
|
||||
{logoUrl ? <Image src={logoUrl} alt="Olathe Sweet" fill style={{ objectFit: "contain" }} className="mx-auto opacity-80 mb-3" /> : null}
|
||||
</span>
|
||||
<h1 className="text-2xl font-black text-white">{t.title}</h1>
|
||||
<p className="text-sm text-zinc-400 mt-1">{t.select_task}</p>
|
||||
@@ -709,6 +719,7 @@ function ClockOutForm({
|
||||
onBack: () => void;
|
||||
error: string | null;
|
||||
submitting: boolean;
|
||||
logoUrl?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -820,6 +831,7 @@ function ClockedOutScreen({
|
||||
payPeriod: PayPeriodHours | null;
|
||||
clockOutResult: { clock_out: string; total_minutes: number };
|
||||
onBackToPin: () => void;
|
||||
logoUrl?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -875,11 +887,13 @@ function PinEntry({
|
||||
onSubmit,
|
||||
error,
|
||||
submitting,
|
||||
logoUrl,
|
||||
}: {
|
||||
t: Translations;
|
||||
onSubmit: (pin: string) => void;
|
||||
error: string | null;
|
||||
submitting: boolean;
|
||||
logoUrl?: string | null;
|
||||
}) {
|
||||
const [pin, setPin] = useState("");
|
||||
|
||||
@@ -893,7 +907,7 @@ function PinEntry({
|
||||
<div className="space-y-8">
|
||||
<div className="text-center">
|
||||
<span className="relative inline-block h-14 w-auto">
|
||||
<Image src={OLATHE_SWEET_LOGO_DARK} alt="Olathe Sweet" fill style={{ objectFit: "contain" }} className="mx-auto opacity-80 mb-3" />
|
||||
{logoUrl ? <Image src={logoUrl} alt="Olathe Sweet" fill style={{ objectFit: "contain" }} className="mx-auto opacity-80 mb-3" /> : null}
|
||||
</span>
|
||||
<h1 className="text-2xl font-black text-white">{t.title}</h1>
|
||||
</div>
|
||||
|
||||
@@ -299,7 +299,7 @@ export function FadeOnScroll({
|
||||
if (typeof window === "undefined" || !elementRef.current) return;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
let fromProps: gsap.TweenVars = { opacity: 0 };
|
||||
const fromProps: gsap.TweenVars = { opacity: 0 };
|
||||
let fromY = 0;
|
||||
let fromX = 0;
|
||||
|
||||
|
||||
@@ -306,12 +306,6 @@ export default function WaterAdminClient() {
|
||||
// Initial loading state
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const savedLang = document.cookie.match(/wl_lang=(en|es)/)?.[1] as "en" | "es" | undefined;
|
||||
if (savedLang) setLang(savedLang);
|
||||
loadAll();
|
||||
}, []);
|
||||
|
||||
const loadAll = async () => {
|
||||
setLoading(true);
|
||||
const [entries, usersData, headgatesData, alertsData] = await Promise.all([
|
||||
@@ -328,6 +322,12 @@ export default function WaterAdminClient() {
|
||||
setInitialLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const savedLang = document.cookie.match(/wl_lang=(en|es)/)?.[1] as "en" | "es" | undefined;
|
||||
if (savedLang) setLang(savedLang);
|
||||
loadAll();
|
||||
}, []);
|
||||
|
||||
const loadDisplaySummary = useCallback(async () => {
|
||||
const data = await getWaterDisplaySummary(TUXEDO_BRAND_ID);
|
||||
setDisplaySummary(data);
|
||||
|
||||
Reference in New Issue
Block a user