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:
Nora
2026-06-26 02:41:56 -06:00
parent 8e011da521
commit 29d9d23a26
88 changed files with 1399 additions and 1015 deletions
@@ -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);
+10 -6
View File
@@ -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>
+1 -4
View File
@@ -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"
-39
View File
@@ -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
View File
@@ -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"
>
+2 -2
View File
@@ -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>
+21 -15
View File
@@ -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,
+39
View File
@@ -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);
}
+5 -1
View File
@@ -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" />
+7 -2
View File
@@ -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} />
</>
);
}
+1 -1
View File
@@ -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"
>
+3 -5
View File
@@ -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
>
+5 -1
View File
@@ -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>
+4 -4
View File
@@ -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>
+2 -2
View File
@@ -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>
) : (
+1
View File
@@ -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"
>
+6 -3
View File
@@ -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,