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
+3 -9
View File
@@ -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 />
+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,
@@ -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 {
+1 -2
View File
@@ -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;
+12 -14
View File
@@ -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>
+1 -1
View File
@@ -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;
+6 -6
View File
@@ -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);