Closed mdunbavan closed 1 year ago
Hey there!
Yes. That is very tricky thing to do. I'm not 100% sure that this will work, but let's try :)
What I assume is that you this kind of case: You have two server components: nav and main. These two are server components that are rendering on server. And then after the client has been hydrated then client main should react to changes made in client nav.
To transfer the request from server to client I created these two functions: KlevuPackFetchResult
and KlevuHydratePackedFetchResult
. You should pack results in the server side and hydrate in the frontend side. In your case I would try hydrate in both: nav and in main. But you would need to create separate client only FilterManager that is imported to both of these (nav and main) client components.
Now you can use that one client FilterManager instance to change facets to different values in nav component and in main component it is only used as parameter to client side KlevuFetch()
(You need to do separate client only fetching after hydration that is separate from server side fetching).
And as last bit you need to listen Dom events in main component. Listen for FilterSelectionUpdate
and in callback run client side KlevuFetch()
again. Using same FilterManager between those two client components should do the trick of updating facets for query.
Here is my example code that does the same, but with just one client component: https://github.com/klevultd/frontend-sdk/blob/master/examples/hydrogen/src/routes/search.server.tsx And here is client component: https://github.com/klevultd/frontend-sdk/blob/master/examples/hydrogen/src/components/searchResultPage.client.tsx#L15 There in line 15 is the FilterManager. If it would be moved to separate file where it is created and instance is exported to both of your client components.
So this kind of hits the nail on the head in what I am trying to do @rallu.
The only difference is that there are lots of other deep nested components within that Header and Main that control lots of complex functionality so it isn't a a simple one and they are both client because of what they need to do with state management and hooks in react. This means that the filters are deeply nested inside all of this as a client component. eg below:
import {Suspense, useEffect} from 'react';
import {
useLocalization,
useShopQuery,
CacheLong,
gql,
useServerProps,
useServerState,
useRouteParams,
useUrl,
} from '@shopify/hydrogen';
import {useContentfulQuery as useContentfulQuery} from '~/services/contentful';
import {Header, MainContent} from '~/components';
import {parseMenu} from '~/lib/utils';
import {PAGINATION_SIZE} from '~/lib/const';
const HEADER_MENU_HANDLE = 'main-menu';
const FOOTER_MENU_HANDLE = 'footer';
const SHOP_NAME_FALLBACK = 'l';
/**
* A server component that defines a structure and organization of a page that can be used in different parts of the Hydrogen app
*/
export function Layout({
children,
pageTransition,
title,
description,
mainClassName,
product,
variant,
}) {
const {pathname} = useUrl();
// activeLayer
const {
language: {isoCode: languageCode},
country: {isoCode: countryCode},
} = useLocalization();
const {category, slug} = useRouteParams();
const {data} = useShopQuery({
query: TYPES_QUERY,
variables: {
country: countryCode,
language: languageCode,
pageBy: PAGINATION_SIZE,
},
preload: true,
});
const cats = data.collections.nodes.filter((v) => v.node != '');
// get last segment of a url
const lastSegment = pathname.split('/').pop();
console.log(lastSegment);
const catsTransform = cats.map((v) => {
return {
name: v.title,
slug: '/collections/' + v.handle.toLowerCase().replace(/ /g, '-'),
};
});
const {data: useConData} = useContentfulQuery({
query: CONTENTFUL_HIGHLIGHTS_AND_INFO_QUERY,
key: pathname,
});
const {items} = useConData.highlightCollection;
const highlightItems = items.filter(
(item) => item && item.type.includes('Men'),
);
const infoData = useConData.informationCollection?.items[0] || {};
return (
<>
<div className="flex flex-col md:flex-row justify-between items-center min-h-screen bg-lav_white">
<div className="">
<a href="#mainContent" className="sr-only">
Skip to content
</a>
</div>
<Suspense fallback={<Header title={SHOP_NAME_FALLBACK} />}>
<Header
title={title}
description={description}
information={infoData}
highlights={highlightItems}
categories={catsTransform}
product={product}
/>
</Suspense>
<MainContent children={children}></MainContent>
</div>
</>
);
}
function HeaderWithMenu({pageTitle}) {
const {shopName} = useLayoutQuery();
return <Header title={pageTitle} />;
}
function useLayoutQuery() {
const {
language: {isoCode: languageCode},
} = useLocalization();
const {data} = useShopQuery({
query: SHOP_QUERY,
variables: {
language: languageCode,
headerMenuHandle: HEADER_MENU_HANDLE,
footerMenuHandle: FOOTER_MENU_HANDLE,
},
cache: CacheLong(),
preload: '*',
});
const shopName = data ? data.shop.name : SHOP_NAME_FALLBACK;
/*
Modify specific links/routes (optional)
@see: https://shopify.dev/api/storefront/unstable/enums/MenuItemType
e.g here we map:
- /blogs/news -> /news
- /blog/news/blog-post -> /news/blog-post
- /collections/all -> /products
*/
const customPrefixes = {BLOG: '', CATALOG: 'products'};
const headerMenu = data?.headerMenu
? parseMenu(data.headerMenu, customPrefixes)
: undefined;
return {headerMenu, shopName};
}
const TYPES_QUERY = gql`
query ProductTypes($country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
collections(first: 10) {
nodes {
handle
title
}
}
}
`;
const SHOP_QUERY = gql`
fragment MenuItem on MenuItem {
id
resourceId
tags
title
type
url
}
query layoutMenus($language: LanguageCode, $headerMenuHandle: String!)
@inContext(language: $language) {
shop {
name
}
headerMenu: menu(handle: $headerMenuHandle) {
id
items {
...MenuItem
items {
...MenuItem
}
}
}
}
`;
const CONTENTFUL_HIGHLIGHTS_AND_INFO_QUERY = gql`
query {
highlightCollection {
items {
title
slug
collection
image {
url
width
height
title
}
type
}
}
informationCollection {
items {
instagramLink
facebookLink
emailContact
pageLinksCollection {
items {
title
slug
}
}
}
}
}
`;
@rallu how are you?
I have got the above working as you wrote above.
My only query is the hydrate function that I wrote gets the correct filters back but for some reason products do not seem to be updating. When I check the network the search from klevu servers suggests otherwise.
import {
useState,
useRef,
forwardRef,
useImperativeHandle,
useEffect,
useCallback,
} from 'react';
import {useDebounce} from 'react-use';
import {Link, flattenConnection, useServerProps} from '@shopify/hydrogen';
import {
listFilters,
applyFilterWithManager,
KlevuFetch,
FilterManager,
KlevuDomEvents,
KlevuListenDomEvent,
KlevuPackFetchResult,
KlevuHydratePackedFetchResult,
search,
sendSearchEvent,
} from '@klevu/core';
import {Button, Grid, ProductCard} from '~/components';
import {getImageLoadingPriority} from '~/lib/const';
import {searchQuery} from '~/services/klevu';
import {manager} from '~/components/global/FilterManagerClient.client';
let currentResult = {};
let clickEvent = null;
export function ProductGrid({url, collection, description, baseKlevuQuery}) {
const nextButtonRef = useRef(null);
const initialProducts = collection?.products?.nodes || [];
const {hasNextPage, endCursor} = collection?.products?.pageInfo ?? {};
const [products, setProducts] = useState(initialProducts);
const [klevuProducts, setKlevuProducts] = useState(null);
const [cursor, setCursor] = useState(endCursor ?? '');
const [nextPage, setNextPage] = useState(hasNextPage);
const [pending, setPending] = useState(false);
const haveProducts = initialProducts.length > 0;
const {serverProps, setServerProps} = useServerProps();
const [options, setOptions] = useState(manager.options);
const [sliders, setSliders] = useState(manager.sliders);
const [searchResultData, setSearchResultData] = useState([]);
const hydrate = async () => {
currentResult = await KlevuHydratePackedFetchResult(
baseKlevuQuery,
searchQuery('*', manager),
);
console.log(currentResult);
const search = currentResult.queriesById('search');
if (search) {
setKlevuProducts(search.records);
if (search.getSearchClickSendEvent) {
clickEvent = search.getSearchClickSendEvent();
}
}
};
const handleFilterUpdate = (e) => {
hydrate();
};
useEffect(() => {
const stop = KlevuListenDomEvent(
KlevuDomEvents.FilterSelectionUpdate,
handleFilterUpdate,
);
// cleanup this component
return () => {
stop();
};
}, []);
const fetchProducts = useCallback(async () => {
setPending(true);
const postUrl = new URL(window.location.origin + url);
postUrl.searchParams.set('cursor', cursor);
const response = await fetch(postUrl, {
method: 'POST',
});
const {data} = await response.json();
// ProductGrid can paginate collection, products and search routes
// @ts-ignore TODO: Fix types
const newProducts = flattenConnection(
data?.collection?.products || data?.products || [],
);
const {endCursor, hasNextPage} = data?.collection?.products?.pageInfo ||
data?.products?.pageInfo || {endCursor: '', hasNextPage: false};
// this was changed from {...newProducts} to {...products} because sorting was causing issues.
// If we get issues with pagination then we need to revisit this more
setProducts([...products, ...newProducts]);
setCursor(endCursor);
setNextPage(hasNextPage);
setPending(false);
}, [cursor, url, products]);
const handleIntersect = useCallback(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
fetchProducts();
}
});
},
[fetchProducts],
);
useEffect(() => {
const observer = new IntersectionObserver(handleIntersect, {
rootMargin: '100%',
});
setServerProps({description: description});
const nextButton = nextButtonRef.current;
if (nextButton) observer.observe(nextButton);
return () => {
if (nextButton) observer.unobserve(nextButton);
};
}, [nextButtonRef, cursor, handleIntersect]);
if (!haveProducts) {
return (
<>
<p>No products found on this collection</p>
<Link to="/products">
<p className="underline">Browse catalog</p>
</Link>
</>
);
}
return (
<>
<div className="product-fixed-container">
<div className="snap-container grid grid-cols-2 gap-x-2 md:gap-x-6 px-2 md:px-0">
{klevuProducts && klevuProducts.length > 0
? klevuProducts.map((product) => <p>{product.name}</p>)
: products.map((product) => (
<section key={product.id} className="scroll-snap-top_section">
<ProductCard
key={product.id}
product={product}
loadingPriority={getImageLoadingPriority(product)}
className="product-card_sm-width"
/>
</section>
))}
</div>
</div>
{nextPage && (
<div
className="flex items-center justify-center mt-6"
ref={nextButtonRef}
>
<Button
variant="secondary"
disabled={pending}
onClick={fetchProducts}
width="full"
>
{pending ? 'Loading...' : 'Load more products'}
</Button>
</div>
)}
</>
);
}
If you check my hydrate function it seems to not give the correct product records or it never updates.
I think this
const handleFilterUpdate = (e) => {
hydrate();
};
should be replaced with
const handleFilterUpdate = (e) => {
fetchProducts();
};
@rallu yeah I spotted that yesterday. Still getting my head around all this ;)
Closing as inactive. Please comment if more information is required.
Hi,
So I am building a hydrogen app with Klevu.
I have a main menu area that is built within a header and that lives separate like so:
MainContent is where the products live and Header is where the filter lives.
Because of the constraints of Hydrogen and SSR with Server Components it is very difficult to pass data back to the server.jsx file to be rendered within the MainContent. Obviously server files render once only and aren't reactive so I cannot set new data from filter updates because the .server.jsx file has no idea.
Below is an example visually of what I am building to give you context.
I think there may need to be a global event listener that wraps the app somehow but I am pretty sure that it will not work with SSR and how we'd need to use clientside functions to render the data on the f/e.