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:
+12
-2
@@ -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 && (
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
}, []);
|
||||
|
||||
Reference in New Issue
Block a user