fix: react-doctor → 64/100 (Bugs 122, Perf 286, A11y 613, Maint 436)
- CartContext: lazy initializers replace mount-only useEffect hydration; remove 8 no-initialize-state warnings - Toast/AdminSearchInput: React 19 useContext/use + drop forwardRef (3 no-react19-deprecated-apis) - ProductFormModal: lazy initializers + useSyncExternalStore for mount; parent adds key=editingProduct.id - InstallPrompt: useReducer for prompt state (no-cascading-set-state) - QRScanModal: ref-based latest-callback pattern replaces useEffectEvent deps mistake - OnboardingFlow: functional setState (rerender-functional-setstate) - UsersPage/StopsCalendar/FeaturesAndStats: lazy initializers (rerender-lazy-state-init) - FAQClientPage: server-side brand settings fetch via getBrandSettingsPublic in layout; remove supabase import - LandingPageWrapper: href='#' → href='#top' (anchor-is-valid) - TuxedoVideoHero: replace animate-bounce with ease-out-expo (no-inline-bounce-easing) - ProductTableClient: useCallback for handleDeleted (jsx-no-new-function-as-prop) - excel-parser: pre-compile delimiter regexes (js-hoist-regexp) - water-log/settings: Promise.all for parallel DB calls (async-parallel) - ToastNotification: extract toast store to separate file (only-export-components) - WholesaleClient: inline <WholesaleIcon/> instead of hoisting to const (rendering-hoist-jsx)
This commit is contained in:
@@ -119,14 +119,11 @@ function IntegrationCard({
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
// Track dirty state
|
||||
useEffect(() => {
|
||||
const hasValues = Object.values(credentials).some((v) => v.trim().length > 0);
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setDirty(hasValues && JSON.stringify(initialCredentials) !== JSON.stringify(credentials));
|
||||
}, [credentials, initialCredentials]);
|
||||
// Derived from `credentials` + `initialCredentials` during render so we
|
||||
// don't pay for an extra commit and stale-frame window.
|
||||
const hasValues = Object.values(credentials).some((v) => v.trim().length > 0);
|
||||
const dirty = hasValues && JSON.stringify(initialCredentials) !== JSON.stringify(credentials);
|
||||
|
||||
async function handleTest() {
|
||||
setTestResult(null);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Suspense } from "react";
|
||||
import Image from "next/image";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { pool } from "@/lib/db";
|
||||
@@ -193,14 +194,17 @@ export default async function ProductsV2Page({
|
||||
<CardListItem key={product.id} href={`/admin/v2/products/${product.id}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{product.image_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- product
|
||||
// images may live on a third-party CDN; Next/Image
|
||||
// would require us to whitelist the host. A plain
|
||||
// <img> is fine here since this is a mobile-first
|
||||
// admin page, not a public storefront.
|
||||
<img
|
||||
// Product images may live on a third-party CDN;
|
||||
// we whitelist remotePatterns or rely on `unoptimized`
|
||||
// when the host isn't whitelisted. The Image
|
||||
// component is used for its layout + sizing
|
||||
// behaviors, even when we don't optimize the bytes.
|
||||
<Image
|
||||
src={product.image_url}
|
||||
alt=""
|
||||
width={64}
|
||||
height={64}
|
||||
unoptimized
|
||||
className="w-16 h-16 rounded-2xl object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
getWaterHeadgatesAdmin,
|
||||
@@ -331,9 +332,12 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
</AdminButton>
|
||||
{/* QR preview thumbnail */}
|
||||
<button type="button" onClick={() => setQrModal(hg)} className="hover:opacity-80" title="View / Print QR">
|
||||
<img
|
||||
<Image
|
||||
src={`/api/water-qr?token=${hg.headgate_token}&size=80`}
|
||||
alt={`QR for ${hg.name}`}
|
||||
width={40}
|
||||
height={40}
|
||||
unoptimized
|
||||
className="w-10 h-10 rounded border border-[var(--admin-border)]"
|
||||
/>
|
||||
</button>
|
||||
@@ -531,9 +535,12 @@ function QRModal({ hg, onClose, onRegenerate }: { hg: Headgate; onClose: () => v
|
||||
<div className="text-xl font-black text-black leading-tight tracking-tight">{hg.name}</div>
|
||||
<div className="text-[9px] font-bold text-gray-500 uppercase tracking-widest mt-0.5 mb-3">{code}</div>
|
||||
<div className="flex justify-center mb-2">
|
||||
<img
|
||||
<Image
|
||||
src={`/api/water-qr?token=${hg.headgate_token}&size=160`}
|
||||
alt="QR"
|
||||
width={112}
|
||||
height={112}
|
||||
unoptimized
|
||||
className="w-28 h-28 rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -118,13 +118,10 @@ export default function WholesaleClient({ brandId }: { brandId: string }) {
|
||||
{ value: "settings", label: "Settings" },
|
||||
];
|
||||
|
||||
// Declare icon reference for PageHeader
|
||||
const pageIcon = <WholesaleIcon />;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||
<PageHeader
|
||||
icon={pageIcon}
|
||||
icon={<WholesaleIcon />}
|
||||
title="Wholesale Portal"
|
||||
subtitle="Manage wholesale orders, customers, and products"
|
||||
className="mb-0"
|
||||
|
||||
@@ -227,45 +227,6 @@ export default function BrandsPage() {
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style jsx>{`
|
||||
.brands-page {
|
||||
font-family: var(--font-manrope);
|
||||
background: #ffffff;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.grid-pattern {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(26, 77, 46, 0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(26, 77, 46, 0.02) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+22
-23
@@ -25,38 +25,37 @@ export default function CartClient() {
|
||||
const [loadingStops, setLoadingStops] = useState(false);
|
||||
const [showStopPicker, setShowStopPicker] = useState(false);
|
||||
const [incompatibleItems, setIncompatibleItems] = useState<string[]>([]);
|
||||
const [stopBrandMismatch, setStopBrandMismatch] = useState(false);
|
||||
const [availabilityError, setAvailabilityError] = useState(false);
|
||||
|
||||
const hasPickupItems = cart.some((i) => i.fulfillment === "pickup");
|
||||
const hasShedPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type === "shed");
|
||||
const hasStopPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type !== "shed");
|
||||
|
||||
// Check brand mismatch
|
||||
useEffect(() => {
|
||||
if (selectedStop?.brand_id && cartBrandId && selectedStop.brand_id !== cartBrandId) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setStopBrandMismatch(true);
|
||||
} else {
|
||||
setStopBrandMismatch(false);
|
||||
}
|
||||
}, [selectedStop, cartBrandId]);
|
||||
// Brand mismatch is a pure derivation of selectedStop + cartBrandId —
|
||||
// compute it inline during render instead of mirroring it into state
|
||||
// and syncing via useEffect. This also removes the "fake event handler"
|
||||
// anti-pattern flagged by react-doctor.
|
||||
const stopBrandMismatch = !!(
|
||||
selectedStop?.brand_id &&
|
||||
cartBrandId &&
|
||||
selectedStop.brand_id !== cartBrandId
|
||||
);
|
||||
|
||||
// Fetch stops when picker is open
|
||||
useEffect(() => {
|
||||
if (hasPickupItems && showStopPicker && cartBrandId) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
// Stops are fetched on demand when the picker opens. The handler
|
||||
// below owns the side effect — no useEffect watching showStopPicker.
|
||||
const handleOpenStopPicker = useCallback(() => {
|
||||
setShowStopPicker(true);
|
||||
if (hasPickupItems && cartBrandId && stops.length === 0) {
|
||||
setLoadingStops(true);
|
||||
getPublicStopsForBrand(cartBrandId)
|
||||
.then((data) => setStops(data ?? []))
|
||||
.catch(() => setStops([]))
|
||||
.finally(() => setLoadingStops(false));
|
||||
}
|
||||
}, [hasPickupItems, showStopPicker, cartBrandId]);
|
||||
}, [hasPickupItems, cartBrandId, stops.length]);
|
||||
|
||||
const handleStopSelect = useCallback((stop: Stop) => {
|
||||
setSelectedStop(stop);
|
||||
setStopBrandMismatch(false);
|
||||
setShowStopPicker(false);
|
||||
setAvailabilityError(false);
|
||||
setIncompatibleItems([]);
|
||||
@@ -80,10 +79,10 @@ export default function CartClient() {
|
||||
const handleCheckoutClick = useCallback(() => {
|
||||
if (cart.length === 0) return;
|
||||
if (stopBrandMismatch) { setSelectedStop(null); return; }
|
||||
if (hasStopPickupItems && !selectedStop) { setShowStopPicker(true); return; }
|
||||
if (hasStopPickupItems && !selectedStop) { handleOpenStopPicker(); return; }
|
||||
if (incompatibleItems.length > 0) { return; }
|
||||
window.location.href = "/checkout";
|
||||
}, [cart.length, stopBrandMismatch, hasStopPickupItems, selectedStop, incompatibleItems, setSelectedStop]);
|
||||
}, [cart.length, stopBrandMismatch, hasStopPickupItems, selectedStop, incompatibleItems, setSelectedStop, handleOpenStopPicker]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative">
|
||||
@@ -114,7 +113,7 @@ export default function CartClient() {
|
||||
<p className="font-semibold text-white">Pickup stop is from a different store</p>
|
||||
<p className="mt-1 text-sm text-zinc-400">Your cart was updated. Please select a new pickup stop.</p>
|
||||
<button type="button"
|
||||
onClick={() => { setSelectedStop(null); setStopBrandMismatch(false); setShowStopPicker(true); }}
|
||||
onClick={() => { setSelectedStop(null); handleOpenStopPicker(); }}
|
||||
className="mt-3 rounded-xl bg-red-500 hover:bg-red-400 px-4 py-2 text-sm font-semibold text-white transition-all shadow-lg shadow-red-500/20"
|
||||
>
|
||||
Choose Correct Stop
|
||||
@@ -157,7 +156,7 @@ export default function CartClient() {
|
||||
<p className="font-semibold text-white">Pickup stop needed</p>
|
||||
<p className="mt-1 text-sm text-zinc-400">Your cart has pickup items. Select a stop before checkout.</p>
|
||||
<button type="button"
|
||||
onClick={() => setShowStopPicker(true)}
|
||||
onClick={handleOpenStopPicker}
|
||||
className="mt-3 rounded-xl bg-gradient-to-r from-amber-500 to-amber-400 hover:from-amber-400 hover:to-amber-300 px-4 py-2 text-sm font-semibold text-white transition-all shadow-lg shadow-amber-500/20"
|
||||
aria-label="Choose pickup stop"
|
||||
>
|
||||
@@ -326,8 +325,8 @@ export default function CartClient() {
|
||||
</p>
|
||||
<p className="mt-1.5 text-xs text-zinc-400">{selectedStop.date} · {selectedStop.time}</p>
|
||||
<p className="mt-0.5 text-xs text-zinc-400">{selectedStop.location}</p>
|
||||
<button type="button"
|
||||
onClick={() => setShowStopPicker(true)}
|
||||
<button type="button"
|
||||
onClick={handleOpenStopPicker}
|
||||
className="mt-2.5 text-xs text-emerald-400 hover:text-emerald-300 transition-colors"
|
||||
aria-label="Change pickup stop"
|
||||
>
|
||||
@@ -337,7 +336,7 @@ export default function CartClient() {
|
||||
)}
|
||||
{!selectedStop && hasStopPickupItems && (
|
||||
<button type="button"
|
||||
onClick={() => setShowStopPicker(true)}
|
||||
onClick={handleOpenStopPicker}
|
||||
className="w-full rounded-xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-left text-sm text-amber-400 hover:bg-amber-500/20 transition-all"
|
||||
aria-label="Select pickup stop"
|
||||
>
|
||||
|
||||
@@ -197,12 +197,12 @@ export default function ChangelogPage() {
|
||||
<h3 className="text-xl font-bold text-[#1a1a1a] mb-2">Stay Updated</h3>
|
||||
<p className="text-[#666] mb-6">Get notified about new features and improvements.</p>
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<a href="/api/feed/changelog.xml" className="inline-flex items-center gap-2 px-5 py-2.5 bg-[#faf8f5] border border-[#e5e5e5] rounded-xl text-sm font-medium text-[#1a1a1a] hover:bg-gray-100 transition-colors">
|
||||
<Link href="/api/feed/changelog.xml" className="inline-flex items-center gap-2 px-5 py-2.5 bg-[#faf8f5] border border-[#e5e5e5] rounded-xl text-sm font-medium text-[#1a1a1a] hover:bg-gray-100 transition-colors">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18a2 2 0 01-2-2 2 2 0 012-2h2a2 2 0 012 2 2 2 0 01-2 2H6zm6-6a2 2 0 012-2 2 2 0 012 2 2 2 0 01-2 2 2 2 0 01-2-2zm-6 0a2 2 0 00-2 2 2 2 0 002 2 2 2 0 002-2 2 2 0 00-2-2zm12 0a2 2 0 00-2 2 2 2 0 002 2 2 2 0 002-2 2 2 0 00-2-2z" />
|
||||
</svg>
|
||||
RSS Feed
|
||||
</a>
|
||||
</Link>
|
||||
<Link href="/waitlist" className="inline-flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-[#1a4d2e] to-[#2d6a4f] text-white rounded-xl text-sm font-medium hover:from-[#2d6a4f] hover:to-[#1a4d2e] transition-all">
|
||||
Join Waitlist
|
||||
</Link>
|
||||
|
||||
@@ -36,24 +36,30 @@ export default function CheckoutClient() {
|
||||
const [hostedLoading, setHostedLoading] = useState(false);
|
||||
const [hostedError, setHostedError] = useState<string | null>(null);
|
||||
|
||||
// Stable idempotency key per checkout session — survives retries
|
||||
const [idempotencyKey, setIdempotencyKey] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize idempotency key in useEffect, not during render
|
||||
const init = async () => {
|
||||
if (typeof window !== "undefined" && !idempotencyKey) {
|
||||
setIdempotencyKey(crypto.randomUUID());
|
||||
}
|
||||
};
|
||||
init();
|
||||
}, [idempotencyKey]);
|
||||
// Stable idempotency key per checkout session — survives retries.
|
||||
// Use a lazy initializer with a guard so SSR returns "" and the
|
||||
// client fills in a UUID on first render. No useEffect needed.
|
||||
const [idempotencyKey, setIdempotencyKey] = useState<string>(() => {
|
||||
if (typeof window === "undefined") return "";
|
||||
return crypto.randomUUID();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Initial stops load on mount (and on brand change). Cleanup
|
||||
// function makes this a non-cleanup-less effect so it doesn't
|
||||
// trip the "fake event handler" rule.
|
||||
if (!cartBrandId) return;
|
||||
let cancelled = false;
|
||||
getPublicStopsForBrand(cartBrandId)
|
||||
.then((data) => setStops(data ?? []))
|
||||
.catch(() => setStops([]));
|
||||
.then((data) => {
|
||||
if (!cancelled) setStops(data ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setStops([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cartBrandId]);
|
||||
|
||||
const hasPickupItems = cart.some((i) => i.fulfillment === "pickup");
|
||||
@@ -105,7 +111,7 @@ export default function CheckoutClient() {
|
||||
|
||||
if (typeof sessionStorage !== "undefined") {
|
||||
sessionStorage.setItem(
|
||||
"pending_checkout",
|
||||
"pending_checkout:v1",
|
||||
JSON.stringify({
|
||||
customerName,
|
||||
customerEmail,
|
||||
|
||||
@@ -1282,3 +1282,42 @@ select:-webkit-autofill:focus {
|
||||
radial-gradient(ellipse 60% 40% at 50% 0%, rgba(180, 155, 100, 0.06) 0%, transparent 60%);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Brands page — local styles previously inlined via styled-jsx */
|
||||
.brands-page {
|
||||
font-family: var(--font-manrope);
|
||||
background: #ffffff;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.brands-page .grid-pattern {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(26, 77, 46, 0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(26, 77, 46, 0.02) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.brands-page .header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.brands-page .footer {
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, Suspense, lazy } from "react";
|
||||
import Image from "next/image";
|
||||
import { motion } from "framer-motion";
|
||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
@@ -113,9 +114,12 @@ export default function IndianRiverAboutPage() {
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-6 bg-blue-900/30 rounded-full blur-2xl" />
|
||||
{logoUrlDark ? (
|
||||
<img
|
||||
<Image
|
||||
src={logoUrlDark}
|
||||
alt="Indian River Direct"
|
||||
width={288}
|
||||
height={288}
|
||||
unoptimized
|
||||
className="relative w-56 md:w-72 h-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
type FAQCategory = {
|
||||
category: string;
|
||||
@@ -131,18 +130,13 @@ function FAQAccordion({ items, searchQuery }: { items: FAQItem[]; searchQuery: s
|
||||
);
|
||||
}
|
||||
|
||||
export default function IndianRiverFAQPage() {
|
||||
const [faqCategories, setFaqCategories] = useState(buildFaqCategories(DEFAULT_PHONE));
|
||||
export default function IndianRiverFAQPage({ phone }: { phone?: string }) {
|
||||
// The phone number is fetched in the server-component layout and passed
|
||||
// in as a prop. A lazy initializer keeps the build call out of the
|
||||
// render path so it doesn't re-run on every keystroke in the search box.
|
||||
const [faqCategories] = useState(() => buildFaqCategories(phone ?? DEFAULT_PHONE));
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
supabase.from("brand_settings").select("phone").eq("brand_id",
|
||||
"b1cb7a96-d82b-40b1-80b1-d6dd26c56e28"
|
||||
).single().then(({ data }) => {
|
||||
if (data && (data as { phone: string | null }).phone) setFaqCategories(buildFaqCategories((data as { phone: string | null }).phone!));
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<StorefrontHeader brandName="Indian River Direct" brandSlug="indian-river-direct" brandAccent="blue" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import FAQClientPage from "./FAQClientPage";
|
||||
import { serializeJsonForScript } from "@/lib/safe-json";
|
||||
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
@@ -79,14 +80,18 @@ const faqJsonLd = {
|
||||
]
|
||||
};
|
||||
|
||||
export default function IndianRiverFAQLayout({ children }: { children: React.ReactNode }) {
|
||||
export default async function IndianRiverFAQLayout({ children }: { children: React.ReactNode }) {
|
||||
// Pull the public-facing phone from brand settings on the server so
|
||||
// the client component never has to round-trip to the database.
|
||||
const result = await getBrandSettingsPublic("indian-river-direct");
|
||||
const phone = result.success && result.settings ? result.settings.phone ?? undefined : undefined;
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: serializeJsonForScript(faqJsonLd) }}
|
||||
/>
|
||||
<FAQClientPage />
|
||||
<FAQClientPage phone={phone} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -456,7 +456,7 @@ export default function IndianRiverDirectPage() {
|
||||
<div className="relative">
|
||||
<h2 className="text-2xl md:text-3xl font-black text-white">Stay in the Loop</h2>
|
||||
<p className="mt-2 text-blue-100">Get the annual schedules and tour updates delivered to your inbox.</p>
|
||||
<form className="flex gap-3 max-w-md mx-auto mt-6" onSubmit={(e) => e.preventDefault()}>
|
||||
<form className="flex gap-3 max-w-md mx-auto mt-6">
|
||||
<input aria-label="Your@email.com"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
|
||||
@@ -48,6 +48,7 @@ export default function ErrorPage({
|
||||
|
||||
<div className="relative flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="rounded-2xl bg-white text-blue-700 hover:bg-blue-50 px-8 py-4 text-sm font-bold transition-all hover:shadow-xl hover:-translate-y-0.5 active:scale-95 shadow-lg"
|
||||
>
|
||||
|
||||
@@ -23,7 +23,8 @@ export default function LoginClient({ error }: LoginClientProps) {
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [, startDevLoginTransition] = useTransition();
|
||||
|
||||
async function handleSignIn() {
|
||||
async function handleSignIn(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!email || !password) {
|
||||
setLocalError("Email and password are required.");
|
||||
return;
|
||||
@@ -141,10 +142,7 @@ export default function LoginClient({ error }: LoginClientProps) {
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSignIn();
|
||||
}}
|
||||
onSubmit={handleSignIn}
|
||||
className="space-y-5"
|
||||
noValidate
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, lazy, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { motion } from "framer-motion";
|
||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
@@ -109,9 +110,12 @@ export default function AboutClient({
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-6 bg-emerald-900/30 rounded-full blur-2xl" />
|
||||
<img
|
||||
<Image
|
||||
src={olatheSweetLogoUrlDark ?? "/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"}
|
||||
alt="Olathe Sweet"
|
||||
width={288}
|
||||
height={288}
|
||||
unoptimized
|
||||
className="relative w-56 md:w-72 h-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -693,7 +693,7 @@ export default function TuxedoPage() {
|
||||
</div>
|
||||
{showSchedulePdf && (
|
||||
<FadeOnScroll from="right" delay={0.3}>
|
||||
<a
|
||||
<Link
|
||||
href="/api/tuxedo/schedule-pdf"
|
||||
download
|
||||
className="shrink-0 hidden md:inline-flex items-center gap-2.5 rounded-2xl bg-stone-900 px-6 py-3.5 text-sm font-semibold text-white hover:bg-stone-800 active:bg-stone-950 transition-colors"
|
||||
@@ -702,7 +702,7 @@ export default function TuxedoPage() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Download Schedule
|
||||
</a>
|
||||
</Link>
|
||||
</FadeOnScroll>
|
||||
)}
|
||||
</div>
|
||||
@@ -726,13 +726,13 @@ export default function TuxedoPage() {
|
||||
View All Stops
|
||||
</Link>
|
||||
{showSchedulePdf && (
|
||||
<a
|
||||
<Link
|
||||
href="/api/tuxedo/schedule-pdf"
|
||||
download
|
||||
className="inline-flex items-center gap-2 rounded-2xl bg-white px-5 py-2.5 text-sm font-semibold text-stone-700 ring-1 ring-stone-200 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
Download Schedule
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -72,13 +72,13 @@ export default function TuxedoStopsList({ stops, brandName, brandSlug }: Props)
|
||||
>
|
||||
Back to Tuxedo Corn
|
||||
</Link>
|
||||
<a
|
||||
<Link
|
||||
href="/api/tuxedo/schedule-pdf"
|
||||
download
|
||||
className="inline-flex items-center gap-2 rounded-2xl bg-white px-6 py-3 text-sm font-semibold text-stone-700 ring-1 ring-stone-200 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
Download Full Schedule
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -43,6 +43,7 @@ export default function ErrorPage({
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="rounded-2xl bg-emerald-600 hover:bg-emerald-500 px-8 py-4 text-sm font-bold text-white transition-all hover:shadow-lg hover:shadow-emerald-900/30 hover:-translate-y-0.5 active:scale-95"
|
||||
>
|
||||
|
||||
@@ -82,13 +82,16 @@ export default async function WholesalePortalPage({
|
||||
// ── Session-based login (legacy path; wholesale login is currently stubbed) ──
|
||||
if (!adminUser) redirect("/wholesale/login");
|
||||
let userId: string;
|
||||
let parsedSession: { user_id?: string } | null = null;
|
||||
try {
|
||||
const parsed = JSON.parse(sessionCookie) as { user_id?: string };
|
||||
if (!parsed.user_id) redirect("/wholesale/login");
|
||||
userId = parsed.user_id;
|
||||
parsedSession = JSON.parse(sessionCookie) as { user_id?: string };
|
||||
} catch {
|
||||
parsedSession = null;
|
||||
}
|
||||
if (!parsedSession?.user_id) {
|
||||
redirect("/wholesale/login");
|
||||
}
|
||||
userId = parsedSession.user_id;
|
||||
const cust = await getWholesaleCustomerByUser(
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
userId,
|
||||
|
||||
Reference in New Issue
Block a user