diff --git a/templates/hydrogen-theme/app/components/CmsSection.tsx b/templates/hydrogen-theme/app/components/CmsSection.tsx index 63aeeb9..05c2eb8 100644 --- a/templates/hydrogen-theme/app/components/CmsSection.tsx +++ b/templates/hydrogen-theme/app/components/CmsSection.tsx @@ -5,6 +5,7 @@ import {Suspense, useMemo} from 'react'; import type {FOOTERS_FRAGMENT} from '~/qroq/footers'; import type { + COLLECTION_SECTIONS_FRAGMENT, PRODUCT_SECTIONS_FRAGMENT, SECTIONS_FRAGMENT, } from '~/qroq/sections'; @@ -14,6 +15,7 @@ import {useSettingsCssVars} from '~/hooks/useSettingsCssVars'; import {sections} from '~/lib/sectionRelsolver'; type CmsSectionsProps = + | NonNullable>[0] | NonNullable> | NonNullable>[0] | NonNullable>[0]; diff --git a/templates/hydrogen-theme/app/components/collection/SortFilter.tsx b/templates/hydrogen-theme/app/components/collection/SortFilter.tsx index 87913ff..bd00e4e 100644 --- a/templates/hydrogen-theme/app/components/collection/SortFilter.tsx +++ b/templates/hydrogen-theme/app/components/collection/SortFilter.tsx @@ -46,17 +46,11 @@ export type SortParam = type Props = { appliedFilters?: AppliedFilter[]; children: React.ReactNode; - collections?: Array<{handle: string; title: string}>; filters: Filter[]; }; export const FILTER_URL_PREFIX = 'filter.'; -export function SortFilter({ - appliedFilters = [], - children, - collections = [], - filters, -}: Props) { +export function SortFilter({appliedFilters = [], children, filters}: Props) { const [isOpen, setIsOpen] = useState(false); return ( <> @@ -71,7 +65,7 @@ export function SortFilter({ -
+
0 ? products.map((product) => (
  • - +
  • )) : skeleton ? [...Array(skeleton.cardsNumber ?? 3)].map((_, i) => (
  • ; + +export function CollectionBannerSection( + props: SectionDefaultProps & {data: CollectionBannerSectionProps}, +) { + const loaderData = useLoaderData(); + const collection = loaderData.collection; + + return collection ? ( +
    + {/* Todo => add settings for banner height */} + {/* Todo => add setting to add overlay */} + {/* Todo => add settings for text and content alignment */} +
    + {props.data.showImage && collection.image && ( + + )} +
    +
    +
    +

    {collection.title}

    + {props.data.showDescription &&

    {collection.description}

    } +
    +
    +
    +
    +
    + ) : null; +} diff --git a/templates/hydrogen-theme/app/components/sections/CollectionProductGridSection.tsx b/templates/hydrogen-theme/app/components/sections/CollectionProductGridSection.tsx new file mode 100644 index 0000000..e8968ae --- /dev/null +++ b/templates/hydrogen-theme/app/components/sections/CollectionProductGridSection.tsx @@ -0,0 +1,152 @@ +import type {Filter} from '@shopify/hydrogen/storefront-api-types'; +import type {TypeFromSelection} from 'groqd'; +import type { + CollectionProductGridQuery, + ProductCardFragment, +} from 'storefrontapi.generated'; + +import { + Await, + useLoaderData, + useNavigate, + useSearchParams, +} from '@remix-run/react'; +import {Pagination, flattenConnection} from '@shopify/hydrogen'; +import {Suspense, useEffect} from 'react'; + +import type {SectionDefaultProps} from '~/lib/type'; +import type {COLLECTION_PRODUCT_GRID_SECTION_FRAGMENT} from '~/qroq/sections'; +import type {loader} from '~/routes/($locale).collections.$collectionHandle'; + +import {useLocale} from '~/hooks/useLocale'; +import {getAppliedFilters} from '~/lib/shopifyCollection'; + +import {SortFilter} from '../collection/SortFilter'; +import {ProductCardGrid} from '../product/ProductCardGrid'; + +type CollectionProductGridSectionProps = TypeFromSelection< + typeof COLLECTION_PRODUCT_GRID_SECTION_FRAGMENT +>; + +export type ShopifyCollection = CollectionProductGridQuery['collection']; + +export function CollectionProductGridSection( + props: SectionDefaultProps & {data: CollectionProductGridSectionProps}, +) { + const locale = useLocale(); + const [searchParams] = useSearchParams(); + const loaderData = useLoaderData(); + const collectionProductGridPromise = loaderData?.collectionProductGridPromise; + const columns = props.data.desktopColumns; + const mobileColumns = props.data.mobileColumns; + + // Todo => Add skeleton and errorElement + return ( + + Error
  • } + resolve={collectionProductGridPromise} + > + {(result) => { + const collection = result?.collection as ShopifyCollection; + + if (!collection) { + return null; + } + + const appliedFilters = getAppliedFilters({ + collection, + locale, + searchParams, + }); + + return ( +
    + + + {({ + NextLink, + PreviousLink, + hasNextPage, + isLoading, + nextPageUrl, + nodes, + state, + }) => ( + <> +
    + + {isLoading ? 'Loading...' : 'Load previous'} + +
    + +
    + + {isLoading ? 'Loading...' : 'Load more products'} + +
    + + )} +
    +
    +
    + ); + }} + + + ); +} + +function ProductsLoadedOnScroll({ + columns, + hasNextPage, + inView, + nextPageUrl, + nodes, + state, +}: { + columns?: { + desktop?: null | number; + mobile?: null | number; + }; + hasNextPage: boolean; + inView: boolean; + nextPageUrl: string; + nodes: ProductCardFragment[]; + state: unknown; +}) { + const navigate = useNavigate(); + + useEffect(() => { + if (inView && hasNextPage) { + navigate(nextPageUrl, { + preventScrollReset: true, + replace: true, + state, + }); + } + }, [inView, navigate, state, nextPageUrl, hasNextPage]); + + return ( + + ); +} diff --git a/templates/hydrogen-theme/app/components/sections/FeaturedCollectionSection.tsx b/templates/hydrogen-theme/app/components/sections/FeaturedCollectionSection.tsx index 88ec8fa..33fc28c 100644 --- a/templates/hydrogen-theme/app/components/sections/FeaturedCollectionSection.tsx +++ b/templates/hydrogen-theme/app/components/sections/FeaturedCollectionSection.tsx @@ -39,7 +39,9 @@ export function FeaturedCollectionSection( > {(products) => ( )} @@ -52,7 +54,9 @@ function Skeleton(props: {cardsNumber: number; columns: number}) { return (
    ; type SanityProductData = InferType; +type SanityCollectionData = InferType; type PromiseResolverArgs = { - document: {data: SanityPageData | SanityProductData}; + document: {data: SanityCollectionData | SanityPageData | SanityProductData}; + request: Request; storefront: Storefront; }; @@ -31,43 +36,81 @@ type PromiseResolverArgs = { */ export function resolveShopifyPromises({ document, + request, storefront, }: PromiseResolverArgs) { const featuredCollectionPromise = resolveFeaturedCollectionPromise({ document, + request, storefront, }); const collectionListPromise = resolveCollectionListPromise({ document, + request, storefront, }); const featuredProductPromise = resolveFeaturedProductPromise({ document, + request, storefront, }); const relatedProductsPromise = resolveRelatedProductsPromise({ document, + request, + storefront, + }); + + const collectionProductGridPromise = resolveCollectionProductGridPromise({ + document, + request, storefront, }); return { collectionListPromise, + collectionProductGridPromise, featuredCollectionPromise, featuredProductPromise, relatedProductsPromise, }; } +function getSections(document: { + data: SanityCollectionData | SanityPageData | SanityProductData; +}) { + if (document?.data?._type === 'page' || document?.data?._type === 'home') { + return document.data.sections; + } + + if (document?.data?._type === 'product') { + return ( + document.data?.product?.template?.sections || + document.data?.defaultProductTemplate?.sections + ); + } + + if (document?.data?._type === 'collection') { + return ( + document.data?.collection?.template?.sections || + document.data?.defaultCollectionTemplate?.sections + ); + } + + return []; +} + function resolveFeaturedCollectionPromise({ document, storefront, }: PromiseResolverArgs) { const promises: Promise[] = []; - for (const section of document.data?.sections || []) { + const sections = getSections(document); + + for (const section of sections || []) { if (section._type === 'featuredCollectionSection') { const gid = section.collection?.store.gid; const first = section.maxProducts || 3; @@ -106,7 +149,9 @@ function resolveCollectionListPromise({ }: PromiseResolverArgs) { const promises: Promise[] = []; - for (const section of document.data?.sections || []) { + const sections = getSections(document); + + for (const section of sections || []) { if (section._type === 'collectionListSection') { const first = section.collections?.length; const ids = section.collections?.map( @@ -148,7 +193,9 @@ function resolveFeaturedProductPromise({ }: PromiseResolverArgs) { const promises: Promise[] = []; - for (const section of document.data?.sections || []) { + const sections = getSections(document); + + for (const section of sections || []) { if (section._type === 'featuredProductSection') { const gid = section.product?.store.gid; @@ -189,9 +236,14 @@ async function resolveRelatedProductsPromise({ return null; } - const productId = document.data?.store.gid; + const productId = document.data?.product?.store.gid; + const sections = getSections(document); - for (const section of document.data?.sections || []) { + if (!productId) { + return null; + } + + for (const section of sections || []) { if (section._type === 'relatedProductsSection') { promise = storefront.query(RECOMMENDED_PRODUCTS_QUERY, { variables: { @@ -206,3 +258,47 @@ async function resolveRelatedProductsPromise({ return promise || null; } + +async function resolveCollectionProductGridPromise({ + document, + request, + storefront, +}: PromiseResolverArgs) { + let promise; + + if (document.data?._type !== 'collection') { + return null; + } + + const collectionId = document.data?.collection?.store.gid; + + if (!collectionId) { + return null; + } + + const sections = getSections(document); + const searchParams = new URL(request.url).searchParams; + const {filters, reverse, sortKey} = getFiltersFromParam(searchParams); + + for (const section of sections || []) { + if (section._type === 'collectionProductGridSection') { + const paginationVariables = getPaginationVariables(request, { + pageBy: section.productsPerPage || 8, + }); + + promise = storefront.query(COLLECTION_PRODUCT_GRID_QUERY, { + variables: { + ...paginationVariables, + country: storefront.i18n.country, + filters, + id: collectionId, + language: storefront.i18n.language, + reverse, + sortKey, + }, + }); + } + } + + return promise || null; +} diff --git a/templates/hydrogen-theme/app/lib/sectionRelsolver.ts b/templates/hydrogen-theme/app/lib/sectionRelsolver.ts index 0fb5ccf..3d33faa 100644 --- a/templates/hydrogen-theme/app/lib/sectionRelsolver.ts +++ b/templates/hydrogen-theme/app/lib/sectionRelsolver.ts @@ -8,11 +8,23 @@ export const sections: { default: module.CarouselSection, })), ), + collectionBannerSection: lazy(() => + import('../components/sections/CollectionBannerSection').then((module) => ({ + default: module.CollectionBannerSection, + })), + ), collectionListSection: lazy(() => import('../components/sections/CollectionListSection').then((module) => ({ default: module.CollectionListSection, })), ), + collectionProductGridSection: lazy(() => + import('../components/sections/CollectionProductGridSection').then( + (module) => ({ + default: module.CollectionProductGridSection, + }), + ), + ), ctaSection: lazy(() => import('../components/sections/CtaSection').then((module) => ({ default: module.CtaSection, diff --git a/templates/hydrogen-theme/app/lib/shopifyCollection.ts b/templates/hydrogen-theme/app/lib/shopifyCollection.ts new file mode 100644 index 0000000..66f5345 --- /dev/null +++ b/templates/hydrogen-theme/app/lib/shopifyCollection.ts @@ -0,0 +1,141 @@ +import type { + ProductCollectionSortKeys, + ProductFilter, +} from '@shopify/hydrogen/storefront-api-types'; + +import type {ShopifyCollection} from '~/components/sections/CollectionProductGridSection'; + +import { + FILTER_URL_PREFIX, + type SortParam, +} from '~/components/collection/SortFilter'; + +import type {I18nLocale} from './type'; + +import {parseAsCurrency} from './utils'; + +export function getFiltersFromParam(searchParams: URLSearchParams) { + const {reverse, sortKey} = getSortValuesFromParam( + searchParams.get('sort') as SortParam, + ); + + const filters = [...searchParams.entries()].reduce( + (filters, [key, value]) => { + if (key.startsWith(FILTER_URL_PREFIX)) { + const filterKey = key.substring(FILTER_URL_PREFIX.length); + filters.push({ + [filterKey]: JSON.parse(value), + }); + } + return filters; + }, + [] as ProductFilter[], + ); + + return { + filters, + reverse, + sortKey, + }; +} + +export function getAppliedFilters({ + collection, + locale, + searchParams, +}: { + collection?: ShopifyCollection; + locale?: I18nLocale; + searchParams: URLSearchParams; +}) { + if (!locale || !collection) { + return []; + } + + const {filters} = getFiltersFromParam(searchParams); + + const allFilterValues = collection?.products.filters.flatMap( + (filter) => filter.values, + ); + + return filters + .map((filter) => { + const foundValue = allFilterValues?.find((value) => { + const valueInput = JSON.parse(value.input as string) as ProductFilter; + // special case for price, the user can enter something freeform (still a number, though) + // that may not make sense for the locale/currency. + // Basically just check if the price filter is applied at all. + if (valueInput.price && filter.price) { + return true; + } + return ( + // This comparison should be okay as long as we're not manipulating the input we + // get from the API before using it as a URL param. + JSON.stringify(valueInput) === JSON.stringify(filter) + ); + }); + if (!foundValue) { + // eslint-disable-next-line no-console + console.error('Could not find filter value for filter', filter); + return null; + } + + if (foundValue.id === 'filter.v.price') { + // Special case for price, we want to show the min and max values as the label. + const input = JSON.parse(foundValue.input as string) as ProductFilter; + const min = parseAsCurrency(input.price?.min ?? 0, locale); + const max = input.price?.max + ? parseAsCurrency(input.price.max, locale) + : ''; + const label = min && max ? `${min} - ${max}` : 'Price'; + + return { + filter, + label, + }; + } + return { + filter, + label: foundValue.label, + }; + }) + .filter((filter): filter is NonNullable => filter !== null); +} + +export function getSortValuesFromParam(sortParam: SortParam | null): { + reverse: boolean; + sortKey: ProductCollectionSortKeys; +} { + switch (sortParam) { + case 'price-high-low': + return { + reverse: true, + sortKey: 'PRICE', + }; + case 'price-low-high': + return { + reverse: false, + sortKey: 'PRICE', + }; + case 'best-selling': + return { + reverse: false, + sortKey: 'BEST_SELLING', + }; + case 'newest': + return { + reverse: true, + sortKey: 'CREATED', + }; + case 'featured': + return { + reverse: false, + sortKey: 'MANUAL', + }; + default: + return { + reverse: false, + sortKey: 'RELEVANCE', + }; + } +} diff --git a/templates/hydrogen-theme/app/qroq/queries.ts b/templates/hydrogen-theme/app/qroq/queries.ts index 99afd32..d136acd 100644 --- a/templates/hydrogen-theme/app/qroq/queries.ts +++ b/templates/hydrogen-theme/app/qroq/queries.ts @@ -7,9 +7,38 @@ import { MENU_FRAGMENT, SETTINGS_FRAGMENT, } from './fragments'; -import {PRODUCT_SECTIONS_FRAGMENT, SECTIONS_FRAGMENT} from './sections'; +import { + COLLECTION_SECTIONS_FRAGMENT, + PRODUCT_SECTIONS_FRAGMENT, + SECTIONS_FRAGMENT, +} from './sections'; import {THEME_CONTENT_FRAGMENT} from './themeContent'; +/* +|-------------------------------------------------------------------------- +| Template Queries +|-------------------------------------------------------------------------- +*/ +export const DEFAULT_PRODUCT_TEMPLATE = q('*') + .filter("_type == 'productTemplate' && default == true") + .grab({ + _type: q.literal('productTemplate'), + name: q.string().nullable(), + sections: PRODUCT_SECTIONS_FRAGMENT, + }) + .slice(0) + .nullable(); + +export const DEFAULT_COLLECTION_TEMPLATE = q('*') + .filter("_type == 'collectionTemplate' && default == true") + .grab({ + _type: q.literal('collectionTemplate'), + name: q.string().nullable(), + sections: COLLECTION_SECTIONS_FRAGMENT, + }) + .slice(0) + .nullable(); + /* |-------------------------------------------------------------------------- | Page Query @@ -27,7 +56,7 @@ export const PAGE_QUERY = q('*') `, ) .grab({ - _type: q.literal('page'), + _type: q.literal('page').or(q.literal('home')), sections: SECTIONS_FRAGMENT, }) .slice(0) @@ -38,19 +67,44 @@ export const PAGE_QUERY = q('*') | Product Query |-------------------------------------------------------------------------- */ -export const PRODUCT_QUERY = q('*') - .filter(`_type == "product" && store.slug.current == $productHandle`) - .grab({ - _type: q.literal('product'), - store: q('store').grab({ - gid: q.string(), - }), - template: q('template').deref().grab({ - sections: PRODUCT_SECTIONS_FRAGMENT, - }), - }) - .slice(0) - .nullable(); +export const PRODUCT_QUERY = q('').grab({ + _type: ['"product"', q.literal('product')], + defaultProductTemplate: DEFAULT_PRODUCT_TEMPLATE, + product: q('*') + .filter(`_type == "product" && store.slug.current == $productHandle`) + .grab({ + store: q('store').grab({ + gid: q.string(), + }), + template: q('template').deref().grab({ + sections: PRODUCT_SECTIONS_FRAGMENT, + }), + }) + .slice(0) + .nullable(), +}); + +/* +|-------------------------------------------------------------------------- +| Collection Query +|-------------------------------------------------------------------------- +*/ +export const COLLECTION_QUERY = q('').grab({ + _type: ['"collection"', q.literal('collection')], + collection: q('*') + .filter(`_type == "collection" && store.slug.current == $collectionHandle`) + .grab({ + store: q('store').grab({ + gid: q.string(), + }), + template: q('template').deref().grab({ + sections: COLLECTION_SECTIONS_FRAGMENT, + }), + }) + .slice(0) + .nullable(), + defaultCollectionTemplate: DEFAULT_COLLECTION_TEMPLATE, +}); /* |-------------------------------------------------------------------------- @@ -74,21 +128,6 @@ export const DEFAULT_COLOR_SCHEME_QUERY = q('*') .slice(0) .nullable(); -export const DEFAULT_PRODUCT_TEMPLATE = q('*') - .filter("_type == 'productTemplate' && default == true") - .grab({ - _type: q.literal('productTemplate'), - name: q.string().nullable(), - sections: PRODUCT_SECTIONS_FRAGMENT, - }) - .slice(0) - .nullable(); - -export const DEFAULT_COLLECTION_TEMPLATE = q('*') - .filter("_type == 'collectionTemplate' && default == true") - .slice(0) - .nullable(); - export const SETTINGS_QUERY = q('*') .filter("_type == 'settings'") .grab(SETTINGS_FRAGMENT) @@ -129,9 +168,7 @@ export const THEME_CONTENT_QUERY = q('*') export const ROOT_QUERY = q('') .grab({ - defaultCollectionTemplate: DEFAULT_COLLECTION_TEMPLATE, defaultColorScheme: DEFAULT_COLOR_SCHEME_QUERY, - defaultProductTemplate: DEFAULT_PRODUCT_TEMPLATE, fonts: FONTS_QUERY, footer: FOOTER_QUERY, header: HEADER_QUERY, diff --git a/templates/hydrogen-theme/app/qroq/sections.ts b/templates/hydrogen-theme/app/qroq/sections.ts index 2dd01e8..c38721c 100644 --- a/templates/hydrogen-theme/app/qroq/sections.ts +++ b/templates/hydrogen-theme/app/qroq/sections.ts @@ -217,6 +217,35 @@ export const RICHTEXT_SECTION_FRAGMENT = { settings: SECTION_SETTINGS_FRAGMENT, } satisfies Selection; +/* +|-------------------------------------------------------------------------- +| Collection Banner Section +|-------------------------------------------------------------------------- +*/ +export const COLLECTION_BANNER_SECTION_FRAGMENT = { + _key: q.string().nullable(), + _type: q.literal('collectionBannerSection'), + settings: SECTION_SETTINGS_FRAGMENT, + showDescription: q.boolean().nullable(), + showImage: q.boolean().nullable(), +} satisfies Selection; + +/* +|-------------------------------------------------------------------------- +| Collection Banner Section +|-------------------------------------------------------------------------- +*/ +export const COLLECTION_PRODUCT_GRID_SECTION_FRAGMENT = { + _key: q.string().nullable(), + _type: q.literal('collectionProductGridSection'), + desktopColumns: q.number().nullable(), + enableFiltering: q.boolean().nullable(), + enableSorting: q.boolean().nullable(), + mobileColumns: q.number().nullable(), + productsPerPage: q.number().nullable(), + settings: SECTION_SETTINGS_FRAGMENT, +} satisfies Selection; + /* |-------------------------------------------------------------------------- | List of sections @@ -232,15 +261,15 @@ export const SECTIONS_LIST_SELECTION = { "_type == 'richtextSection'": RICHTEXT_SECTION_FRAGMENT, }; -/* -|-------------------------------------------------------------------------- -| Sections Fragment -|-------------------------------------------------------------------------- -*/ export const SECTIONS_FRAGMENT = q('sections[]', {isArray: true}) .select(SECTIONS_LIST_SELECTION) .nullable(); +/* +|-------------------------------------------------------------------------- +| Product Sections Fragment +|-------------------------------------------------------------------------- +*/ export const PRODUCT_SECTIONS_FRAGMENT = q('sections[]', {isArray: true}) .select({ "_type == 'productInformationSection'": @@ -249,3 +278,17 @@ export const PRODUCT_SECTIONS_FRAGMENT = q('sections[]', {isArray: true}) ...SECTIONS_LIST_SELECTION, }) .nullable(); + +/* +|-------------------------------------------------------------------------- +| Collection Sections Fragment +|-------------------------------------------------------------------------- +*/ +export const COLLECTION_SECTIONS_FRAGMENT = q('sections[]', {isArray: true}) + .select({ + "_type == 'collectionBannerSection'": COLLECTION_BANNER_SECTION_FRAGMENT, + "_type == 'collectionProductGridSection'": + COLLECTION_PRODUCT_GRID_SECTION_FRAGMENT, + ...SECTIONS_LIST_SELECTION, + }) + .nullable(); diff --git a/templates/hydrogen-theme/app/routes/($locale).$.tsx b/templates/hydrogen-theme/app/routes/($locale).$.tsx index f1ed8ef..e6a9193 100644 --- a/templates/hydrogen-theme/app/routes/($locale).$.tsx +++ b/templates/hydrogen-theme/app/routes/($locale).$.tsx @@ -35,6 +35,7 @@ export async function loader({context, params, request}: LoaderFunctionArgs) { featuredProductPromise, } = resolveShopifyPromises({ document: page, + request, storefront, }); diff --git a/templates/hydrogen-theme/app/routes/($locale).collections.$collectionHandle.tsx b/templates/hydrogen-theme/app/routes/($locale).collections.$collectionHandle.tsx index 8101fde..bab1fa3 100644 --- a/templates/hydrogen-theme/app/routes/($locale).collections.$collectionHandle.tsx +++ b/templates/hydrogen-theme/app/routes/($locale).collections.$collectionHandle.tsx @@ -1,260 +1,90 @@ -import type { - Filter, - ProductCollectionSortKeys, - ProductFilter, -} from '@shopify/hydrogen/storefront-api-types'; import type {LoaderFunctionArgs} from '@shopify/remix-oxygen'; -import type {ProductCardFragment} from 'storefrontapi.generated'; +import type {CollectionDetailsQuery} from 'storefrontapi.generated'; -import {useLoaderData, useNavigate} from '@remix-run/react'; -import { - Image, - Pagination, - flattenConnection, - getPaginationVariables, -} from '@shopify/hydrogen'; -import {json} from '@shopify/remix-oxygen'; -import {useEffect} from 'react'; +import {useLoaderData} from '@remix-run/react'; +import {defer} from '@shopify/remix-oxygen'; +import {DEFAULT_LOCALE} from 'countries'; import invariant from 'tiny-invariant'; -import type {SortParam} from '~/components/collection/SortFilter'; - -import { - FILTER_URL_PREFIX, - SortFilter, -} from '~/components/collection/SortFilter'; -import {ProductCardGrid} from '~/components/product/ProductCardGrid'; +import {CmsSection} from '~/components/CmsSection'; import {COLLECTION_QUERY} from '~/graphql/queries'; -import {parseAsCurrency} from '~/lib/utils'; +import {useSanityData} from '~/hooks/useSanityData'; +import {resolveShopifyPromises} from '~/lib/resolveShopifyPromises'; +import {sanityPreviewPayload} from '~/lib/sanity/sanity.payload.server'; +import {COLLECTION_QUERY as CMS_COLLECTION_QUERY} from '~/qroq/queries'; export async function loader({context, params, request}: LoaderFunctionArgs) { - const paginationVariables = getPaginationVariables(request, { - pageBy: 8, - }); const {collectionHandle} = params; - const {locale, storefront} = context; + const {locale, sanity, storefront} = context; + const language = locale?.language.toLowerCase(); invariant(collectionHandle, 'Missing collectionHandle param'); - const searchParams = new URL(request.url).searchParams; - - const {reverse, sortKey} = getSortValuesFromParam( - searchParams.get('sort') as SortParam, - ); - const filters = [...searchParams.entries()].reduce( - (filters, [key, value]) => { - if (key.startsWith(FILTER_URL_PREFIX)) { - const filterKey = key.substring(FILTER_URL_PREFIX.length); - filters.push({ - [filterKey]: JSON.parse(value), - }); - } - return filters; - }, - [] as ProductFilter[], - ); - - const {collection, collections} = await storefront.query(COLLECTION_QUERY, { - variables: { - ...paginationVariables, - country: storefront.i18n.country, - filters, - handle: collectionHandle, - language: storefront.i18n.language, - reverse, - sortKey, - }, - }); - - if (!collection) { + const queryParams = { + collectionHandle, + defaultLanguage: DEFAULT_LOCALE.language.toLowerCase(), + language, + }; + + const collectionData = Promise.all([ + sanity.query({ + groqdQuery: CMS_COLLECTION_QUERY, + params: queryParams, + }), + storefront.query(COLLECTION_QUERY, { + variables: { + country: storefront.i18n.country, + handle: collectionHandle, + language: storefront.i18n.language, + }, + }), + ]); + + const [cmsCollection, {collection}] = await collectionData; + + if (!collection?.id || !cmsCollection) { throw new Response('collection', {status: 404}); } - const allFilterValues = collection.products.filters.flatMap( - (filter) => filter.values, - ); - - const appliedFilters = filters - .map((filter) => { - const foundValue = allFilterValues.find((value) => { - const valueInput = JSON.parse(value.input as string) as ProductFilter; - // special case for price, the user can enter something freeform (still a number, though) - // that may not make sense for the locale/currency. - // Basically just check if the price filter is applied at all. - if (valueInput.price && filter.price) { - return true; - } - return ( - // This comparison should be okay as long as we're not manipulating the input we - // get from the API before using it as a URL param. - JSON.stringify(valueInput) === JSON.stringify(filter) - ); - }); - if (!foundValue) { - // eslint-disable-next-line no-console - console.error('Could not find filter value for filter', filter); - return null; - } - - if (foundValue.id === 'filter.v.price') { - // Special case for price, we want to show the min and max values as the label. - const input = JSON.parse(foundValue.input as string) as ProductFilter; - const min = parseAsCurrency(input.price?.min ?? 0, locale); - const max = input.price?.max - ? parseAsCurrency(input.price.max, locale) - : ''; - const label = min && max ? `${min} - ${max}` : 'Price'; - - return { - filter, - label, - }; - } - return { - filter, - label: foundValue.label, - }; - }) - .filter((filter): filter is NonNullable => filter !== null); + const { + collectionListPromise, + collectionProductGridPromise, + featuredCollectionPromise, + featuredProductPromise, + } = resolveShopifyPromises({ + document: cmsCollection, + request, + storefront, + }); - return json({ - appliedFilters, + return defer({ + cmsCollection, collection, - collections: flattenConnection(collections), + collectionListPromise, + collectionProductGridPromise, + featuredCollectionPromise, + featuredProductPromise, + ...sanityPreviewPayload({ + context, + params: queryParams, + query: CMS_COLLECTION_QUERY.query, + }), }); } export default function Collection() { - const {appliedFilters, collection, collections} = - useLoaderData(); - const products = collection.products.nodes.length - ? flattenConnection(collection.products) - : []; - - return ( - <> - {collection.image && ( -
    -
    - -
    -
    -

    {collection.title}

    -
    -
    -
    -
    - )} -
    - - - {({ - NextLink, - PreviousLink, - hasNextPage, - isLoading, - nextPageUrl, - nodes, - state, - }) => ( - <> -
    - - {isLoading ? 'Loading...' : 'Load previous'} - -
    - -
    - - {isLoading ? 'Loading...' : 'Load more products'} - -
    - - )} -
    -
    -
    - - ); -} - -function ProductsLoadedOnScroll({ - hasNextPage, - inView, - nextPageUrl, - nodes, - state, -}: { - hasNextPage: boolean; - inView: boolean; - nextPageUrl: string; - nodes: ProductCardFragment[]; - state: unknown; -}) { - const navigate = useNavigate(); - - useEffect(() => { - if (inView && hasNextPage) { - navigate(nextPageUrl, { - preventScrollReset: true, - replace: true, - state, - }); - } - }, [inView, navigate, state, nextPageUrl, hasNextPage]); - - return ; -} - -function getSortValuesFromParam(sortParam: SortParam | null): { - reverse: boolean; - sortKey: ProductCollectionSortKeys; -} { - switch (sortParam) { - case 'price-high-low': - return { - reverse: true, - sortKey: 'PRICE', - }; - case 'price-low-high': - return { - reverse: false, - sortKey: 'PRICE', - }; - case 'best-selling': - return { - reverse: false, - sortKey: 'BEST_SELLING', - }; - case 'newest': - return { - reverse: true, - sortKey: 'CREATED', - }; - case 'featured': - return { - reverse: false, - sortKey: 'MANUAL', - }; - default: - return { - reverse: false, - sortKey: 'RELEVANCE', - }; - } + const {cmsCollection} = useLoaderData(); + const {data, encodeDataAttribute} = useSanityData(cmsCollection); + const template = + data?.collection?.template || data?.defaultCollectionTemplate; + + return template?.sections && template.sections.length > 0 + ? template.sections.map((section) => ( + + )) + : null; } diff --git a/templates/hydrogen-theme/app/routes/($locale).products.$productHandle.tsx b/templates/hydrogen-theme/app/routes/($locale).products.$productHandle.tsx index 4c25696..4131137 100644 --- a/templates/hydrogen-theme/app/routes/($locale).products.$productHandle.tsx +++ b/templates/hydrogen-theme/app/routes/($locale).products.$productHandle.tsx @@ -71,6 +71,7 @@ export async function loader({context, params, request}: LoaderFunctionArgs) { relatedProductsPromise, } = resolveShopifyPromises({ document: cmsProduct, + request, storefront, }); @@ -92,10 +93,8 @@ export async function loader({context, params, request}: LoaderFunctionArgs) { export default function Product() { const {cmsProduct} = useLoaderData(); - const {data: rootData} = useSanityRoot(); const {data, encodeDataAttribute} = useSanityData(cmsProduct); - - const template = data?.template || rootData?.defaultProductTemplate; + const template = data?.product?.template || data?.defaultProductTemplate; return template?.sections && template.sections.length > 0 ? template.sections.map((section) => ( diff --git a/templates/hydrogen-theme/app/routes/_index.tsx b/templates/hydrogen-theme/app/routes/_index.tsx index 8a8223d..cbb252e 100644 --- a/templates/hydrogen-theme/app/routes/_index.tsx +++ b/templates/hydrogen-theme/app/routes/_index.tsx @@ -9,7 +9,7 @@ import {PAGE_QUERY} from '~/qroq/queries'; import PageRoute from './($locale).$'; -export async function loader({context}: LoaderFunctionArgs) { +export async function loader({context, request}: LoaderFunctionArgs) { const {locale, sanity, storefront} = context; const language = locale?.language.toLowerCase(); const queryParams = { @@ -29,6 +29,7 @@ export async function loader({context}: LoaderFunctionArgs) { featuredProductPromise, } = resolveShopifyPromises({ document: page, + request, storefront, }); diff --git a/templates/hydrogen-theme/storefrontapi.generated.d.ts b/templates/hydrogen-theme/storefrontapi.generated.d.ts index 748fee7..5a8896e 100644 --- a/templates/hydrogen-theme/storefrontapi.generated.d.ts +++ b/templates/hydrogen-theme/storefrontapi.generated.d.ts @@ -519,6 +519,25 @@ export type CollectionDetailsQueryVariables = StorefrontAPI.Exact<{ handle: StorefrontAPI.Scalars['String']['input']; country?: StorefrontAPI.InputMaybe; language?: StorefrontAPI.InputMaybe; +}>; + +export type CollectionDetailsQuery = { + collection?: StorefrontAPI.Maybe< + Pick< + StorefrontAPI.Collection, + 'id' | 'handle' | 'title' | 'description' + > & { + image?: StorefrontAPI.Maybe< + Pick + >; + } + >; +}; + +export type CollectionProductGridQueryVariables = StorefrontAPI.Exact<{ + id: StorefrontAPI.Scalars['ID']['input']; + country?: StorefrontAPI.InputMaybe; + language?: StorefrontAPI.InputMaybe; filters?: StorefrontAPI.InputMaybe< Array | StorefrontAPI.ProductFilter >; @@ -534,15 +553,9 @@ export type CollectionDetailsQueryVariables = StorefrontAPI.Exact<{ >; }>; -export type CollectionDetailsQuery = { +export type CollectionProductGridQuery = { collection?: StorefrontAPI.Maybe< - Pick< - StorefrontAPI.Collection, - 'id' | 'handle' | 'title' | 'description' - > & { - image?: StorefrontAPI.Maybe< - Pick - >; + Pick & { products: { filters: Array< Pick & { @@ -591,9 +604,6 @@ export type CollectionDetailsQuery = { }; } >; - collections: { - edges: Array<{node: Pick}>; - }; }; export type FeaturedCollectionQueryVariables = StorefrontAPI.Exact<{ @@ -673,10 +683,14 @@ interface GeneratedQueryTypes { return: CollectionsQuery; variables: CollectionsQueryVariables; }; - '#graphql\n query CollectionDetails(\n $handle: String!\n $country: CountryCode\n $language: LanguageCode\n $filters: [ProductFilter!]\n $sortKey: ProductCollectionSortKeys!\n $reverse: Boolean\n $first: Int\n $last: Int\n $startCursor: String\n $endCursor: String\n ) @inContext(country: $country, language: $language) {\n collection(handle: $handle) {\n id\n handle\n title\n description\n image {\n id\n url\n width\n height\n altText\n }\n products(\n first: $first,\n last: $last,\n before: $startCursor,\n after: $endCursor,\n filters: $filters,\n sortKey: $sortKey,\n reverse: $reverse\n ) {\n filters {\n id\n label\n type\n values {\n id\n label\n count\n input\n }\n }\n nodes {\n ...ProductCard\n }\n pageInfo {\n hasPreviousPage\n hasNextPage\n endCursor\n startCursor\n }\n }\n }\n collections(first: 100) {\n edges {\n node {\n title\n handle\n }\n }\n }\n }\n #graphql\n fragment ProductCard on Product {\n id\n title\n publishedAt\n handle\n vendor\n variants(first: 1) {\n nodes {\n id\n availableForSale\n image {\n url\n altText\n width\n height\n }\n price {\n amount\n currencyCode\n }\n compareAtPrice {\n amount\n currencyCode\n }\n selectedOptions {\n name\n value\n }\n product {\n handle\n title\n }\n }\n }\n }\n\n': { + '#graphql\n query CollectionDetails(\n $handle: String!\n $country: CountryCode\n $language: LanguageCode\n ) @inContext(country: $country, language: $language) {\n collection(handle: $handle) {\n id\n handle\n title\n description\n image {\n id\n url\n width\n height\n altText\n }\n }\n }\n': { return: CollectionDetailsQuery; variables: CollectionDetailsQueryVariables; }; + '#graphql\n query CollectionProductGrid(\n $id: ID!\n $country: CountryCode\n $language: LanguageCode\n $filters: [ProductFilter!]\n $sortKey: ProductCollectionSortKeys!\n $reverse: Boolean\n $first: Int\n $last: Int\n $startCursor: String\n $endCursor: String\n ) @inContext(country: $country, language: $language) {\n collection(id: $id) {\n id\n handle\n products(\n first: $first,\n last: $last,\n before: $startCursor,\n after: $endCursor,\n filters: $filters,\n sortKey: $sortKey,\n reverse: $reverse\n ) {\n filters {\n id\n label\n type\n values {\n id\n label\n count\n input\n }\n }\n nodes {\n ...ProductCard\n }\n pageInfo {\n hasPreviousPage\n hasNextPage\n endCursor\n startCursor\n }\n }\n }\n }\n #graphql\n fragment ProductCard on Product {\n id\n title\n publishedAt\n handle\n vendor\n variants(first: 1) {\n nodes {\n id\n availableForSale\n image {\n url\n altText\n width\n height\n }\n price {\n amount\n currencyCode\n }\n compareAtPrice {\n amount\n currencyCode\n }\n selectedOptions {\n name\n value\n }\n product {\n handle\n title\n }\n }\n }\n }\n\n': { + return: CollectionProductGridQuery; + variables: CollectionProductGridQueryVariables; + }; '#graphql\n query FeaturedCollection(\n $id: ID!\n $country: CountryCode\n $language: LanguageCode\n $first: Int\n ) @inContext(country: $country, language: $language) {\n collection(id: $id) {\n id\n handle\n title\n description\n image {\n id\n url\n width\n height\n altText\n }\n products(\n first: $first,\n ) {\n nodes {\n ...ProductCard\n }\n }\n }\n }\n #graphql\n fragment ProductCard on Product {\n id\n title\n publishedAt\n handle\n vendor\n variants(first: 1) {\n nodes {\n id\n availableForSale\n image {\n url\n altText\n width\n height\n }\n price {\n amount\n currencyCode\n }\n compareAtPrice {\n amount\n currencyCode\n }\n selectedOptions {\n name\n value\n }\n product {\n handle\n title\n }\n }\n }\n }\n\n': { return: FeaturedCollectionQuery; variables: FeaturedCollectionQueryVariables; diff --git a/templates/hydrogen-theme/studio/schemas/documents/collectionTemplate.tsx b/templates/hydrogen-theme/studio/schemas/documents/collectionTemplate.tsx index 62cf6ae..672b37a 100644 --- a/templates/hydrogen-theme/studio/schemas/documents/collectionTemplate.tsx +++ b/templates/hydrogen-theme/studio/schemas/documents/collectionTemplate.tsx @@ -23,6 +23,10 @@ export default defineType({ ), initialValue: false, }), + defineField({ + name: 'sections', + type: 'collectionSections', + }), ], preview: { select: { diff --git a/templates/hydrogen-theme/studio/schemas/index.ts b/templates/hydrogen-theme/studio/schemas/index.ts index 1851e99..cedacd5 100644 --- a/templates/hydrogen-theme/studio/schemas/index.ts +++ b/templates/hydrogen-theme/studio/schemas/index.ts @@ -10,7 +10,10 @@ import home from './singletons/home'; import collection from './documents/collection'; import product from './documents/product'; import blogPost from './documents/blogPost'; -import sectionsList, {productSections} from './objects/global/sectionsList'; +import sectionsList, { + productSections, + collectionSections, +} from './objects/global/sectionsList'; import seo from './objects/global/seo'; import sectionSettings from './objects/global/sectionSettings'; import headerNavigation from './objects/global/headerNavigation'; @@ -40,6 +43,8 @@ import richtextSection from './objects/sections/richtextSection'; import richtext from './objects/global/richtext'; import productTemplate from './documents/productTemplate'; import collectionTemplate from './documents/collectionTemplate'; +import collectionBanner from './objects/sections/collectionBanner'; +import collectionProductGrid from './objects/sections/collectionProductGrid'; const singletons = [home, header, footer, settings, themeContent]; const documents = [ @@ -62,12 +67,15 @@ const sections = [ relatedProductsSection, carouselSection, richtextSection, + collectionBanner, + collectionProductGrid, ]; const footers = [socialLinksOnly]; const objects = [ footersList, sectionsList, productSections, + collectionSections, productRichtext, seo, sectionSettings, diff --git a/templates/hydrogen-theme/studio/schemas/objects/global/sectionsList.ts b/templates/hydrogen-theme/studio/schemas/objects/global/sectionsList.ts index df5f49f..d0f6d0e 100644 --- a/templates/hydrogen-theme/studio/schemas/objects/global/sectionsList.ts +++ b/templates/hydrogen-theme/studio/schemas/objects/global/sectionsList.ts @@ -36,6 +36,16 @@ const pdpSections = [ ...globalSections, ]; +const collectionSectionsList = [ + { + type: 'collectionBannerSection', + }, + { + type: 'collectionProductGridSection', + }, + ...globalSections, +]; + export default defineField({ title: 'Sections', name: 'sections', @@ -59,3 +69,15 @@ export const productSections = defineField({ SectionsListInput({type: 'section', ...props}), }, }); + +export const collectionSections = defineField({ + title: 'Sections', + name: 'collectionSections', + type: 'array', + group: 'pagebuilder', + of: collectionSectionsList, + components: { + input: (props: ArrayOfObjectsInputProps) => + SectionsListInput({type: 'section', ...props}), + }, +}); diff --git a/templates/hydrogen-theme/studio/schemas/objects/sections/collectionBanner.tsx b/templates/hydrogen-theme/studio/schemas/objects/sections/collectionBanner.tsx new file mode 100644 index 0000000..50c34f8 --- /dev/null +++ b/templates/hydrogen-theme/studio/schemas/objects/sections/collectionBanner.tsx @@ -0,0 +1,33 @@ +import {defineField} from 'sanity'; +import {Image} from 'lucide-react'; + +export default defineField({ + name: 'collectionBannerSection', + title: 'Collection Banner', + type: 'object', + fields: [ + defineField({ + name: 'showImage', + title: 'Show collection image', + description: 'For best results, use an image with a 16:9 aspect ratio.', + type: 'boolean', + }), + defineField({ + name: 'showDescription', + title: 'Show collection description', + type: 'boolean', + }), + defineField({ + type: 'sectionSettings', + name: 'settings', + }), + ], + preview: { + prepare() { + return { + title: 'Collection Banner', + media: () => , + }; + }, + }, +}); diff --git a/templates/hydrogen-theme/studio/schemas/objects/sections/collectionProductGrid.tsx b/templates/hydrogen-theme/studio/schemas/objects/sections/collectionProductGrid.tsx new file mode 100644 index 0000000..57c6521 --- /dev/null +++ b/templates/hydrogen-theme/studio/schemas/objects/sections/collectionProductGrid.tsx @@ -0,0 +1,67 @@ +import {defineField} from 'sanity'; +import {LayoutGrid} from 'lucide-react'; + +export default defineField({ + name: 'collectionProductGridSection', + title: 'Product Grid', + type: 'object', + fields: [ + defineField({ + name: 'productsPerPage', + type: 'rangeSlider', + options: { + min: 8, + max: 14, + step: 4, + }, + }), + defineField({ + name: 'desktopColumns', + title: 'Number of columns on desktop', + type: 'rangeSlider', + options: { + min: 1, + max: 5, + }, + validation: (Rule: any) => Rule.required().min(1).max(5), + }), + defineField({ + name: 'mobileColumns', + title: 'Number of columns on mobile', + type: 'rangeSlider', + options: { + min: 1, + max: 2, + }, + validation: (Rule: any) => Rule.required().min(1).max(2), + }), + defineField({ + name: 'enableFiltering', + description: 'Customize filters with the Search & Discovery Shopify app.', + type: 'boolean', + }), + defineField({ + name: 'enableSorting', + type: 'boolean', + }), + defineField({ + type: 'sectionSettings', + name: 'settings', + }), + ], + initialValue: { + productsPerPage: 8, + desktopColumns: 4, + mobileColumns: 2, + enableFiltering: true, + enableSorting: true, + }, + preview: { + prepare() { + return { + title: 'Collection Product Grid', + media: () => , + }; + }, + }, +}); diff --git a/templates/hydrogen-theme/studio/static/assets/collectionBannerSection.png b/templates/hydrogen-theme/studio/static/assets/collectionBannerSection.png new file mode 100644 index 0000000..2752d2c Binary files /dev/null and b/templates/hydrogen-theme/studio/static/assets/collectionBannerSection.png differ diff --git a/templates/hydrogen-theme/studio/static/assets/collectionProductGridSection.png b/templates/hydrogen-theme/studio/static/assets/collectionProductGridSection.png new file mode 100644 index 0000000..dca9242 Binary files /dev/null and b/templates/hydrogen-theme/studio/static/assets/collectionProductGridSection.png differ