Gate search behind CompanySettings.searchEnabled; fix two reported bugs

Search icon now gated behind the new backend toggle, same pattern as
wishlistEnabled — off by default.

Fixed: blog category filter bar was invisible — it was wrapped in
<Reveal>, which sits right at/just past the hero's bottom edge, the
exact "already near the initial viewport" position Reveal.tsx's own
comment documents as a whileInView(margin:"-80px") trap (an element
already visible without scrolling can permanently never register as
"entered view", since `once: true` never gets a second chance). Now a
plain div, no scroll-reveal animation needed for a filter bar anyway.

Fixed: wishlist count showing one behind on the Navbar badge —
useWishlist.ts's toggle() broadcast the "refetch me" event immediately
after the optimistic local update, before the POST request was even
sent. Another instance's resulting refetch could race the actual
server-side write, get the pre-toggle list, and then never get told to
refetch again. Broadcast now only fires after the request settles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-30 22:51:37 +00:00
parent 2eda211a29
commit bc40e22910
5 changed files with 59 additions and 13 deletions
+12 -2
View File
@@ -74,8 +74,18 @@ export default async function BlogOverviewPage({
</div>
</Reveal>
{/* Plain div, not <Reveal> — this bar sits right at/just past the
hero's bottom edge, exactly the "already near the initial
viewport top" position Reveal.tsx's own comment documents as a
whileInView(margin:"-80px") trap: the shrunk viewport can
permanently miss triggering "entered view" for an element
that's already visible without any further scroll, since
`once: true` never gets a second chance. The Hero brand dot
hit the identical bug and switched to a plain animate — this
filter bar doesn't need a scroll-reveal animation at all, so
it's simplest to just not wrap it in Reveal in the first place. */}
{allCategories.length > 1 && (
<Reveal className="flex flex-wrap gap-2 w-full max-w-[80rem] mx-auto px-[var(--layout-padding-x)] pt-6">
<div className="flex flex-wrap gap-2 w-full max-w-[80rem] mx-auto px-[var(--layout-padding-x)] pt-6">
{allCategories.map((category) => {
const active = activeCategories.includes(category);
return (
@@ -100,7 +110,7 @@ export default async function BlogOverviewPage({
Zurücksetzen
</Link>
)}
</Reveal>
</div>
)}
{posts.length === 0 && (
+10 -2
View File
@@ -246,7 +246,15 @@ function CartLink() {
);
}
export function Navbar({ singleActiveProduct, wishlistEnabled }: { singleActiveProduct: boolean; wishlistEnabled: boolean }) {
export function Navbar({
singleActiveProduct,
wishlistEnabled,
searchEnabled,
}: {
singleActiveProduct: boolean;
wishlistEnabled: boolean;
searchEnabled: boolean;
}) {
const pathname = usePathname();
const [scrolled, setScrolled] = useState(false);
const [activeSection, setActiveSection] = useState("");
@@ -545,7 +553,7 @@ export function Navbar({ singleActiveProduct, wishlistEnabled }: { singleActiveP
built-in padding read as too much space on mobile, where
these two icons are the only always-visible controls. */}
<div className="flex items-center">
<SearchButton />
{searchEnabled && <SearchButton />}
<AccountLink />
{wishlistEnabled && <WishlistLink />}
<CartLink />
+8 -3
View File
@@ -4,7 +4,7 @@ import "./globals.css";
import { Navbar } from "./components/Navbar";
import { CartFlyProvider } from "./components/CartFly";
import { CartSync } from "./components/CartSync";
import { getProducts, getSeoSettings, getCompanySettings, getWishlistEnabled } from "./lib/payload";
import { getProducts, getSeoSettings, getCompanySettings, getWishlistEnabled, getSearchEnabled } from "./lib/payload";
import { buildOrganizationSchema } from "./lib/structuredData";
const inter = Inter({
@@ -67,7 +67,12 @@ export default async function RootLayout({
// just to know whether Navbar's "Shop" link should behave as an anchor
// to the homepage spotlight instead of a real /shop navigation (see
// Navbar.tsx/ProductSpotlight.tsx).
const [products, seller, wishlistEnabled] = await Promise.all([getProducts(), getCompanySettings(), getWishlistEnabled()]);
const [products, seller, wishlistEnabled, searchEnabled] = await Promise.all([
getProducts(),
getCompanySettings(),
getWishlistEnabled(),
getSearchEnabled(),
]);
const singleActiveProduct = products.filter((p) => p.active).length === 1;
// Organization JSON-LD on every page — one canonical node (@id) that
// Product/Article schemas elsewhere link back to via `{ "@id": ... }`
@@ -87,7 +92,7 @@ export default async function RootLayout({
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema) }} />
<CartFlyProvider>
<CartSync />
<Navbar singleActiveProduct={singleActiveProduct} wishlistEnabled={wishlistEnabled} />
<Navbar singleActiveProduct={singleActiveProduct} wishlistEnabled={wishlistEnabled} searchEnabled={searchEnabled} />
{children}
</CartFlyProvider>
</body>
+17
View File
@@ -1022,6 +1022,23 @@ export async function getWishlistEnabled(): Promise<boolean> {
return data.docs?.[0]?.wishlistEnabled ?? false;
}
// Same pattern as getWishlistEnabled() — gates the Navbar's search icon
// (SearchOverlay.tsx). Off by default so the feature stays invisible
// until a tenant explicitly wants it.
export async function getSearchEnabled(): Promise<boolean> {
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getSearchEnabled: Payload returned ${res.status} ${res.statusText}`);
return false;
}
const data: { docs?: { searchEnabled?: boolean }[] } = await res.json();
return data.docs?.[0]?.searchEnabled ?? false;
}
export type SeoSettings = {
defaultTitle: string | null;
titleTemplate: string | null;
+12 -6
View File
@@ -48,9 +48,17 @@ export function useWishlist() {
[items],
);
// Optimistic: flips the local list immediately, reconciles with the
// server response (or reverts on failure) rather than waiting for the
// round trip — same "feels instant" reasoning as AddToCartButton.
// Optimistic: flips the LOCAL list immediately (this component's own
// `items`/`cachedItems`), reconciles with the server response (or
// reverts on failure) rather than waiting for the round trip — same
// "feels instant" reasoning as AddToCartButton. Crucially, `broadcast()`
// (which tells every OTHER useWishlist() instance — e.g. the Navbar
// badge — to refetch) only fires AFTER the request settles, never
// before: broadcasting immediately after the optimistic update used to
// race the POST itself — another instance's resulting `load()` could
// hit the server before the toggle had actually been persisted there,
// fetch the pre-toggle list, and then never get told to refetch again,
// leaving e.g. the Navbar count permanently one behind the real value.
const toggle = useCallback(async (productId: number, variant = "") => {
const wasWishlisted = cachedItems?.some((i) => i.productId === productId && i.variant === variant) ?? false;
const optimistic = wasWishlisted
@@ -58,7 +66,6 @@ export function useWishlist() {
: [...(cachedItems ?? []), { id: -1, productId, variant }];
cachedItems = optimistic;
setItems(optimistic);
broadcast();
try {
const res = await fetch("/api/account/wishlist", {
@@ -69,7 +76,6 @@ export function useWishlist() {
if (res.status === 401) {
cachedItems = wasWishlisted ? [...optimistic, { id: -1, productId, variant }] : optimistic.filter((i) => i.productId !== productId);
setItems(cachedItems);
broadcast();
return { ok: false as const, unauthorized: true as const };
}
if (!res.ok) throw new Error("request failed");
@@ -79,11 +85,11 @@ export function useWishlist() {
// that hasn't refetched the list yet) rather than trusting the
// optimistic placeholder id (-1) forever.
await load();
broadcast();
return { ok: true as const, wishlisted: data.wishlisted ?? !wasWishlisted };
} catch {
cachedItems = wasWishlisted ? [...optimistic, { id: -1, productId, variant }] : optimistic.filter((i) => i.productId !== productId);
setItems(cachedItems);
broadcast();
return { ok: false as const };
}
}, []);