From 801c78dadee3c9e7474f02ed9afeb0625a7a766e Mon Sep 17 00:00:00 2001 From: Bret Little Date: Tue, 1 Nov 2022 15:55:08 -0400 Subject: [PATCH] Migrate pages to `context.storefront.query` (#130) --- app/data/index.ts | 875 +------------------ app/routes/[sitemap.xml].tsx | 103 ++- app/routes/account.tsx | 5 +- app/routes/collections/$collectionHandle.tsx | 71 +- app/routes/collections/index.tsx | 54 +- app/routes/featured-products.tsx | 59 +- app/routes/journal/$journalHandle.tsx | 53 +- app/routes/journal/index.tsx | 58 +- app/routes/pages/$pageHandle.tsx | 34 +- app/routes/policies/$policyHandle.tsx | 67 +- app/routes/policies/index.tsx | 60 +- app/routes/products/$productHandle.tsx | 196 ++++- app/routes/products/index.tsx | 54 +- app/routes/search.tsx | 131 ++- 14 files changed, 824 insertions(+), 996 deletions(-) diff --git a/app/data/index.ts b/app/data/index.ts index c8b496985c..dbcce098bd 100644 --- a/app/data/index.ts +++ b/app/data/index.ts @@ -1,20 +1,10 @@ -import { - type StorefrontApiResponseOk, - flattenConnection, -} from '@shopify/hydrogen-react'; +import {type StorefrontApiResponseOk} from '@shopify/hydrogen-react'; import type { Cart, CartInput, CartLineInput, CartLineUpdateInput, - Collection, - CollectionConnection, - Product, ProductConnection, - ProductVariant, - SelectedOptionInput, - Blog, - PageConnection, Shop, Order, Localization, @@ -23,8 +13,6 @@ import type { CustomerUpdateInput, CustomerUpdatePayload, UserError, - Page, - ShopPolicy, CustomerAddressUpdatePayload, MailingAddressInput, CustomerAddressDeletePayload, @@ -46,18 +34,11 @@ import invariant from 'tiny-invariant'; import {logout} from '~/routes/account/__private/logout'; import type {AppLoadContext} from '@hydrogen/remix'; import {type Params} from '@remix-run/react'; -import type {FeaturedData} from '~/components/FeaturedSection'; -import {PAGINATION_SIZE} from '~/lib/const'; type StorefrontApiResponse = StorefrontApiResponseOk; export interface CountriesData { localization: Localization; } -interface SitemapQueryData { - products: ProductConnection; - collections: CollectionConnection; - pages: PageConnection; -} export interface LayoutData { headerMenu: EnhancedMenu; @@ -226,43 +207,7 @@ const COUNTRIES_QUERY = `#graphql } `; -export async function getProductData( - handle: string, - searchParams: URLSearchParams, - params: Params, -) { - const {language, country} = getLocalizationFromLang(params.lang); - - const selectedOptions: SelectedOptionInput[] = []; - searchParams.forEach((value, name) => { - selectedOptions.push({name, value}); - }); - - const {data} = await getStorefrontData<{ - product: Product & {selectedVariant?: ProductVariant}; - shop: Shop; - }>({ - query: PRODUCT_QUERY, - variables: { - country, - language, - selectedOptions, - handle, - }, - }); - - invariant(data, 'No data returned from Shopify API'); - - const {product, shop} = data; - - if (!product) { - throw new Response('Not found', {status: 404}); - } - - return {product, shop}; -} - -const MEDIA_FRAGMENT = `#graphql +export const MEDIA_FRAGMENT = `#graphql fragment Media on Media { mediaContentType alt @@ -327,7 +272,7 @@ export const PRODUCT_CARD_FRAGMENT = `#graphql } `; -const PRODUCT_VARIANT_FRAGMENT = `#graphql +export const PRODUCT_VARIANT_FRAGMENT = `#graphql fragment ProductVariantFragment on ProductVariant { id availableForSale @@ -359,285 +304,6 @@ const PRODUCT_VARIANT_FRAGMENT = `#graphql } `; -const PRODUCT_QUERY = `#graphql - ${MEDIA_FRAGMENT} - ${PRODUCT_VARIANT_FRAGMENT} - query Product( - $country: CountryCode - $language: LanguageCode - $handle: String! - $selectedOptions: [SelectedOptionInput!]! - ) @inContext(country: $country, language: $language) { - product(handle: $handle) { - id - title - vendor - handle - descriptionHtml - options { - name - values - } - selectedVariant: variantBySelectedOptions(selectedOptions: $selectedOptions) { - ...ProductVariantFragment - } - media(first: 7) { - nodes { - ...Media - } - } - variants(first: 1) { - nodes { - ...ProductVariantFragment - } - } - seo { - description - title - } - } - shop { - name - shippingPolicy { - body - handle - } - refundPolicy { - body - handle - } - } - } -`; - -const RECOMMENDED_PRODUCTS_QUERY = `#graphql - ${PRODUCT_CARD_FRAGMENT} - query productRecommendations( - $productId: ID! - $count: Int - $country: CountryCode - $language: LanguageCode - ) @inContext(country: $country, language: $language) { - recommended: productRecommendations(productId: $productId) { - ...ProductCard - } - additional: products(first: $count, sortKey: BEST_SELLING) { - nodes { - ...ProductCard - } - } - } -`; - -export async function getRecommendedProducts( - productId: string, - params: Params, - count = 12, -) { - const {language, country} = getLocalizationFromLang(params.lang); - const {data: products} = await getStorefrontData<{ - recommended: Product[]; - additional: ProductConnection; - }>({ - query: RECOMMENDED_PRODUCTS_QUERY, - variables: { - productId, - count, - language, - country, - }, - }); - - invariant(products, 'No data returned from Shopify API'); - - const mergedProducts = products.recommended - .concat(products.additional.nodes) - .filter( - (value, index, array) => - array.findIndex((value2) => value2.id === value.id) === index, - ); - - const originalProduct = mergedProducts - .map((item) => item.id) - .indexOf(productId); - - mergedProducts.splice(originalProduct, 1); - - return mergedProducts; -} - -const COLLECTIONS_QUERY = `#graphql - query Collections( - $country: CountryCode - $language: LanguageCode - $pageBy: Int! - ) @inContext(country: $country, language: $language) { - collections(first: $pageBy) { - nodes { - id - title - description - handle - seo { - description - title - } - image { - id - url - width - height - altText - } - } - } - } -`; - -export async function getCollections( - params: Params, - {paginationSize} = {paginationSize: 8}, -) { - const {language, country} = getLocalizationFromLang(params.lang); - - const {data} = await getStorefrontData<{ - collections: CollectionConnection; - }>({ - query: COLLECTIONS_QUERY, - variables: { - pageBy: paginationSize, - country, - language, - }, - }); - - invariant(data, 'No data returned from Shopify API'); - - return data.collections.nodes; -} - -const COLLECTION_QUERY = `#graphql - ${PRODUCT_CARD_FRAGMENT} - query CollectionDetails( - $handle: String! - $country: CountryCode - $language: LanguageCode - $pageBy: Int! - $cursor: String - ) @inContext(country: $country, language: $language) { - collection(handle: $handle) { - id - handle - title - description - seo { - description - title - } - image { - id - url - width - height - altText - } - products(first: $pageBy, after: $cursor) { - nodes { - ...ProductCard - } - pageInfo { - hasNextPage - endCursor - } - } - } - } -`; - -export async function getCollection({ - handle, - paginationSize = 48, - cursor, - params, -}: { - handle: string; - paginationSize?: number; - cursor?: string; - params: Params; -}) { - const {language, country} = getLocalizationFromLang(params.lang); - - const {data} = await getStorefrontData<{ - collection: Collection; - }>({ - query: COLLECTION_QUERY, - variables: { - handle, - cursor, - language, - country, - pageBy: paginationSize, - }, - }); - - invariant(data, 'No data returned from Shopify API'); - - if (!data.collection) { - throw new Response('Not found', {status: 404}); - } - - return data.collection; -} - -const ALL_PRODUCTS_QUERY = `#graphql - ${PRODUCT_CARD_FRAGMENT} - query AllProducts( - $country: CountryCode - $language: LanguageCode - $pageBy: Int! - $cursor: String - ) @inContext(country: $country, language: $language) { - products(first: $pageBy, after: $cursor) { - nodes { - ...ProductCard - } - pageInfo { - hasNextPage - startCursor - endCursor - } - } - } -`; - -export async function getAllProducts({ - paginationSize = 48, - cursor, - params, -}: { - paginationSize?: number; - cursor?: string; - params: Params; -}) { - const {language, country} = getLocalizationFromLang(params.lang); - - const {data} = await getStorefrontData<{ - products: ProductConnection; - }>({ - query: ALL_PRODUCTS_QUERY, - variables: { - cursor, - language, - country, - pageBy: paginationSize, - }, - }); - - invariant(data, 'No data returned from Shopify API'); - - return data.products; -} - const CART_FRAGMENT = `#graphql fragment CartFragment on Cart { id @@ -920,403 +586,6 @@ export async function getTopProducts({ return data.products; } -const SITEMAP_QUERY = `#graphql - query sitemaps($urlLimits: Int, $language: LanguageCode) - @inContext(language: $language) { - products( - first: $urlLimits - query: "published_status:'online_store:visible'" - ) { - edges { - node { - updatedAt - handle - onlineStoreUrl - title - featuredImage { - url - altText - } - } - } - } - collections( - first: $urlLimits - query: "published_status:'online_store:visible'" - ) { - edges { - node { - updatedAt - handle - onlineStoreUrl - } - } - } - pages(first: $urlLimits, query: "published_status:'published'") { - edges { - node { - updatedAt - handle - onlineStoreUrl - } - } - } - } -`; - -export async function getSitemap({ - params, - urlLimits, -}: { - params: Params; - urlLimits: number; -}) { - const {language} = getLocalizationFromLang(params.lang); - const {data} = await getStorefrontData({ - query: SITEMAP_QUERY, - variables: { - urlLimits, - language, - }, - }); - - invariant(data, 'Sitemap data is missing'); - - return data; -} - -const BLOG_QUERY = `#graphql -query Blog( - $language: LanguageCode - $blogHandle: String! - $pageBy: Int! - $cursor: String -) @inContext(language: $language) { - blog(handle: $blogHandle) { - articles(first: $pageBy, after: $cursor) { - edges { - node { - author: authorV2 { - name - } - contentHtml - handle - id - image { - id - altText - url - width - height - } - publishedAt - title - } - } - } - } -} -`; - -export async function getBlog({ - params, - paginationSize, - blogHandle, -}: { - params: Params; - blogHandle: string; - paginationSize: number; -}) { - const {language} = getLocalizationFromLang(params.lang); - const {data} = await getStorefrontData<{ - blog: Blog; - }>({ - query: BLOG_QUERY, - variables: { - language, - blogHandle, - pageBy: paginationSize, - }, - }); - - invariant(data, 'No data returned from Shopify API'); - - if (!data.blog?.articles) { - throw new Response('Not found', {status: 404}); - } - - return data.blog.articles; -} - -const ARTICLE_QUERY = `#graphql - query ArticleDetails( - $language: LanguageCode - $blogHandle: String! - $articleHandle: String! - ) @inContext(language: $language) { - blog(handle: $blogHandle) { - articleByHandle(handle: $articleHandle) { - title - contentHtml - publishedAt - author: authorV2 { - name - } - image { - id - altText - url - width - height - } - seo { - description - title - } - } - } - } -`; - -export async function getArticle({ - params, - blogHandle, - articleHandle, -}: { - params: Params; - blogHandle: string; - articleHandle: string; -}) { - const {language} = getLocalizationFromLang(params.lang); - const {data} = await getStorefrontData<{ - blog: Blog; - }>({ - query: ARTICLE_QUERY, - variables: { - blogHandle, - articleHandle, - language, - }, - }); - - invariant(data, 'No data returned from Shopify API'); - - if (!data.blog?.articleByHandle) { - throw new Response('Not found', {status: 404}); - } - - return data.blog.articleByHandle; -} - -const PAGE_QUERY = `#graphql - query PageDetails($language: LanguageCode, $handle: String!) - @inContext(language: $language) { - page(handle: $handle) { - id - title - body - seo { - description - title - } - } - } -`; - -export async function getPageData({ - params, - handle, -}: { - params: Params; - handle: string; -}) { - const {language} = getLocalizationFromLang(params.lang); - const {data} = await getStorefrontData<{page: Page}>({ - query: PAGE_QUERY, - variables: { - language, - handle, - }, - }); - - invariant(data, 'No data returned from Shopify API'); - if (!data.page) { - throw new Response('Not found', {status: 404}); - } - - return data.page; -} - -const POLICIES_QUERY = `#graphql - fragment Policy on ShopPolicy { - id - title - handle - } - - query PoliciesQuery { - shop { - privacyPolicy { - ...Policy - } - shippingPolicy { - ...Policy - } - termsOfService { - ...Policy - } - refundPolicy { - ...Policy - } - subscriptionPolicy { - id - title - handle - } - } - } -`; - -export async function getPolicies() { - const {data} = await getStorefrontData<{ - shop: Record; - }>({ - query: POLICIES_QUERY, - variables: {}, - }); - - invariant(data, 'No data returned from Shopify API'); - const policies = Object.values(data.shop || {}); - - if (policies.length === 0) { - throw new Response('Not found', {status: 404}); - } - - return policies; -} - -const POLICY_CONTENT_QUERY = `#graphql - fragment Policy on ShopPolicy { - body - handle - id - title - url - } - - query PoliciesQuery( - $languageCode: LanguageCode - $privacyPolicy: Boolean! - $shippingPolicy: Boolean! - $termsOfService: Boolean! - $refundPolicy: Boolean! - ) @inContext(language: $languageCode) { - shop { - privacyPolicy @include(if: $privacyPolicy) { - ...Policy - } - shippingPolicy @include(if: $shippingPolicy) { - ...Policy - } - termsOfService @include(if: $termsOfService) { - ...Policy - } - refundPolicy @include(if: $refundPolicy) { - ...Policy - } - } - } -`; - -export async function getPolicyContent({ - params, - handle, -}: { - params: Params; - handle: string; -}) { - const {language} = getLocalizationFromLang(params.lang); - - const policyName = handle.replace(/-([a-z])/g, (_, m1) => m1.toUpperCase()); - - const {data} = await getStorefrontData<{ - shop: Record; - }>({ - query: POLICY_CONTENT_QUERY, - variables: { - language, - privacyPolicy: false, - shippingPolicy: false, - termsOfService: false, - refundPolicy: false, - [policyName]: true, - }, - }); - - invariant(data, 'No data returned from Shopify API'); - const policy = data.shop?.[policyName]; - - if (!policy) { - throw new Response('Not found', {status: 404}); - } - - return policy; -} - -const NOT_FOUND_QUERY = `#graphql - ${PRODUCT_CARD_FRAGMENT} - query homepage($country: CountryCode, $language: LanguageCode) - @inContext(country: $country, language: $language) { - featuredCollections: collections(first: 3, sortKey: UPDATED_AT) { - nodes { - id - title - handle - image { - altText - width - height - url - } - } - } - featuredProducts: products(first: 12) { - nodes { - ...ProductCard - } - } - } -`; - -export async function getFeaturedData({ - params, -}: { - params: Record; -}): Promise { - const {language, country} = getLocalizationFromLang(params.lang); - const {data} = await getStorefrontData<{ - featuredCollections: CollectionConnection; - featuredProducts: ProductConnection; - }>({ - query: NOT_FOUND_QUERY, - variables: { - language, - country, - }, - }); - - invariant(data, 'No data returned from Shopify API'); - - return { - featuredCollections: flattenConnection( - data.featuredCollections, - ) as Collection[], - featuredProducts: flattenConnection( - data.featuredProducts, - ) as Product[], - }; -} - // shop primary domain url for /admin export async function getPrimaryShopDomain() { const {data, errors} = await getStorefrontData<{shop: Shop}>({ @@ -2123,141 +1392,3 @@ export async function createCustomerAddress({ return data.customerAddressCreate.customerAddress.id; } - -export async function searchProducts( - params: Params, - variables: { - searchTerm: string; - cursor: string; - pageBy: number; - }, -) { - const {language, country} = getLocalizationFromLang(params.lang); - - const {data, errors} = await getStorefrontData<{ - products: Array; - }>({ - query: SEARCH_QUERY, - variables: { - ...variables, - language, - country, - }, - }); - - if (errors) { - console.error('Search error: '); - console.error(errors); - } - - invariant(data, 'No data returned from Shopify API'); - - return data.products; -} - -const SEARCH_QUERY = `#graphql - ${PRODUCT_CARD_FRAGMENT} - query search( - $searchTerm: String - $country: CountryCode - $language: LanguageCode - $pageBy: Int! - $after: String - ) @inContext(country: $country, language: $language) { - products( - first: $pageBy - sortKey: RELEVANCE - query: $searchTerm - after: $after - ) { - nodes { - ...ProductCard - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } -`; - -const PAGINATE_SEARCH_QUERY = `#graphql - ${PRODUCT_CARD_FRAGMENT} - query ProductsPage( - $searchTerm: String - $pageBy: Int! - $cursor: String - $country: CountryCode - $language: LanguageCode - ) @inContext(country: $country, language: $language) { - products( - sortKey: RELEVANCE - query: $searchTerm - first: $pageBy - after: $cursor - ) { - nodes { - ...ProductCard - } - pageInfo { - hasNextPage - endCursor - } - } - } -`; - -export async function getNoResultRecommendations(params: Params) { - const {language, country} = getLocalizationFromLang(params.lang); - - const {data, errors} = await getStorefrontData<{ - featuredCollections: Array; - featuredProducts: Array; - }>({ - query: SEARCH_NO_RESULTS_QUERY, - variables: { - language, - country, - pageBy: PAGINATION_SIZE, - }, - }); - - if (errors) { - console.error('No result recommendations error: '); - console.error(errors); - } - - invariant(data, 'No data returned from Shopify API'); - - return data; -} - -const SEARCH_NO_RESULTS_QUERY = `#graphql - ${PRODUCT_CARD_FRAGMENT} - query searchNoResult( - $country: CountryCode - $language: LanguageCode - $pageBy: Int! - ) @inContext(country: $country, language: $language) { - featuredCollections: collections(first: 3, sortKey: UPDATED_AT) { - nodes { - id - title - handle - image { - altText - width - height - url - } - } - } - featuredProducts: products(first: $pageBy) { - nodes { - ...ProductCard - } - } - } -`; diff --git a/app/routes/[sitemap.xml].tsx b/app/routes/[sitemap.xml].tsx index 351a85a087..85523e30e3 100644 --- a/app/routes/[sitemap.xml].tsx +++ b/app/routes/[sitemap.xml].tsx @@ -1,15 +1,49 @@ -import type {LoaderArgs} from '@hydrogen/remix'; import {flattenConnection} from '@shopify/hydrogen-react'; -import {getSitemap} from '~/data'; +import type {LoaderArgs} from '@hydrogen/remix'; +import { + CollectionConnection, + PageConnection, + ProductConnection, +} from '@shopify/hydrogen-react/storefront-api-types'; +import invariant from 'tiny-invariant'; +import {getLocalizationFromLang} from '~/lib/utils'; const MAX_URLS = 250; // the google limit is 50K, however, SF API only allow querying for 250 resources each time -export async function loader({request, params}: LoaderArgs) { - const data = await getSitemap({ - params, - urlLimits: MAX_URLS, +interface SitemapQueryData { + products: ProductConnection; + collections: CollectionConnection; + pages: PageConnection; +} + +interface ProductEntry { + url: string; + lastMod: string; + changeFreq: string; + image?: { + url: string; + title?: string; + caption?: string; + }; +} + +export async function loader({ + request, + params, + context: {storefront}, +}: LoaderArgs) { + const {language} = getLocalizationFromLang(params.lang); + + const data = await storefront.query({ + query: SITEMAP_QUERY, + variables: { + language, + urlLimits: MAX_URLS, + }, }); + invariant(data, 'Sitemap data is missing'); + return new Response( shopSitemap({data, baseUrl: new URL(request.url).origin}), { @@ -22,22 +56,11 @@ export async function loader({request, params}: LoaderArgs) { ); } -interface ProductEntry { - url: string; - lastMod: string; - changeFreq: string; - image?: { - url: string; - title?: string; - caption?: string; - }; -} - function shopSitemap({ data, baseUrl, }: { - data: Awaited>; + data: SitemapQueryData; baseUrl: string; }) { const productsData = flattenConnection(data.products) @@ -137,3 +160,47 @@ function renderUrlTag({ `; } + +const SITEMAP_QUERY = `#graphql + query sitemaps($urlLimits: Int, $language: LanguageCode) + @inContext(language: $language) { + products( + first: $urlLimits + query: "published_status:'online_store:visible'" + ) { + edges { + node { + updatedAt + handle + onlineStoreUrl + title + featuredImage { + url + altText + } + } + } + } + collections( + first: $urlLimits + query: "published_status:'online_store:visible'" + ) { + edges { + node { + updatedAt + handle + onlineStoreUrl + } + } + } + pages(first: $urlLimits, query: "published_status:'published'") { + edges { + node { + updatedAt + handle + onlineStoreUrl + } + } + } + } +`; diff --git a/app/routes/account.tsx b/app/routes/account.tsx index 5d03215635..ba34f93807 100644 --- a/app/routes/account.tsx +++ b/app/routes/account.tsx @@ -26,8 +26,9 @@ import { import {FeaturedCollections} from '~/components/FeaturedCollections'; import {type LoaderArgs, redirect, json, defer} from '@hydrogen/remix'; import {flattenConnection} from '@shopify/hydrogen-react'; -import {getCustomer, getFeaturedData} from '~/data'; +import {getCustomer} from '~/data'; import {getSession} from '~/lib/session.server'; +import {getFeaturedData} from './featured-products'; export async function loader({request, context, params}: LoaderArgs) { const {pathname} = new URL(request.url); @@ -67,7 +68,7 @@ export async function loader({request, context, params}: LoaderArgs) { heading, orders, addresses: flattenConnection(customer.addresses) as MailingAddress[], - featuredData: getFeaturedData({params}), + featuredData: getFeaturedData(context.storefront, params), }); } diff --git a/app/routes/collections/$collectionHandle.tsx b/app/routes/collections/$collectionHandle.tsx index b305ca8b23..b8fc71eee8 100644 --- a/app/routes/collections/$collectionHandle.tsx +++ b/app/routes/collections/$collectionHandle.tsx @@ -9,20 +9,41 @@ import type {Collection as CollectionType} from '@shopify/hydrogen-react/storefr import invariant from 'tiny-invariant'; import {PageHeader, Section, Text} from '~/components'; import {ProductGrid} from '~/components/ProductGrid'; -import {getCollection} from '~/data'; +import {getLocalizationFromLang} from '~/lib/utils'; +import type {Collection} from '@shopify/hydrogen-react/storefront-api-types'; +import {PRODUCT_CARD_FRAGMENT} from '~/data'; -export async function loader({params, request}: LoaderArgs) { +const PAGINATION_SIZE = 48; + +export async function loader({ + params, + request, + context: {storefront}, +}: LoaderArgs) { const {collectionHandle} = params; invariant(collectionHandle, 'Missing collectionHandle param'); const cursor = new URL(request.url).searchParams.get('cursor') ?? undefined; - const collection = await getCollection({ - handle: collectionHandle, - cursor, - params, + const {language, country} = getLocalizationFromLang(params.lang); + + const {collection} = await storefront.query<{ + collection: Collection; + }>({ + query: COLLECTION_QUERY, + variables: { + handle: collectionHandle, + pageBy: PAGINATION_SIZE, + cursor, + language, + country, + }, }); + if (!collection) { + throw new Response('Not found', {status: 404}); + } + return json({collection}); } @@ -63,3 +84,41 @@ export default function Collection() { ); } + +const COLLECTION_QUERY = `#graphql + ${PRODUCT_CARD_FRAGMENT} + query CollectionDetails( + $handle: String! + $country: CountryCode + $language: LanguageCode + $pageBy: Int! + $cursor: String + ) @inContext(country: $country, language: $language) { + collection(handle: $handle) { + id + handle + title + description + seo { + description + title + } + image { + id + url + width + height + altText + } + products(first: $pageBy, after: $cursor) { + nodes { + ...ProductCard + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +`; diff --git a/app/routes/collections/index.tsx b/app/routes/collections/index.tsx index 3c5c42d944..379e3c744a 100644 --- a/app/routes/collections/index.tsx +++ b/app/routes/collections/index.tsx @@ -1,14 +1,30 @@ import {json, type LoaderArgs, type MetaFunction} from '@hydrogen/remix'; import {useLoaderData} from '@remix-run/react'; -import type {Collection} from '@shopify/hydrogen-react/storefront-api-types'; +import type { + Collection, + CollectionConnection, +} from '@shopify/hydrogen-react/storefront-api-types'; import {Grid, Heading, PageHeader, Section, Link} from '~/components'; -import {getCollections} from '~/data'; import {getImageLoadingPriority} from '~/lib/const'; +import {getLocalizationFromLang} from '~/lib/utils'; -export const loader = async ({params}: LoaderArgs) => { - const collections = await getCollections(params); +const PAGINATION_SIZE = 8; - return json({collections}); +export const loader = async ({params, context: {storefront}}: LoaderArgs) => { + const {language, country} = getLocalizationFromLang(params.lang); + + const {collections} = await storefront.query<{ + collections: CollectionConnection; + }>({ + query: COLLECTIONS_QUERY, + variables: { + pageBy: PAGINATION_SIZE, + country, + language, + }, + }); + + return json({collections: collections.nodes}); }; export const meta: MetaFunction = () => { @@ -65,3 +81,31 @@ function CollectionCard({ ); } + +const COLLECTIONS_QUERY = `#graphql + query Collections( + $country: CountryCode + $language: LanguageCode + $pageBy: Int! + ) @inContext(country: $country, language: $language) { + collections(first: $pageBy) { + nodes { + id + title + description + handle + seo { + description + title + } + image { + id + url + width + height + altText + } + } + } + } +`; diff --git a/app/routes/featured-products.tsx b/app/routes/featured-products.tsx index e9bf8725bf..615589d53a 100644 --- a/app/routes/featured-products.tsx +++ b/app/routes/featured-products.tsx @@ -1,6 +1,59 @@ import {json, type LoaderArgs} from '@hydrogen/remix'; -import {getFeaturedData} from '~/data'; +import {flattenConnection} from '@shopify/hydrogen-react'; +import type { + CollectionConnection, + ProductConnection, +} from '@shopify/hydrogen-react/storefront-api-types'; +import invariant from 'tiny-invariant'; +import {PRODUCT_CARD_FRAGMENT} from '~/data'; +import {getLocalizationFromLang} from '~/lib/utils'; -export async function loader({params}: LoaderArgs) { - return json(await getFeaturedData({params})); +export async function loader({params, context: {storefront}}: LoaderArgs) { + return json(await getFeaturedData(storefront, params)); } + +export async function getFeaturedData( + storefront: LoaderArgs['context']['storefront'], + params: LoaderArgs['params'], +) { + const {language, country} = getLocalizationFromLang(params.lang); + const data = await storefront.query<{ + featuredCollections: CollectionConnection; + featuredProducts: ProductConnection; + }>({ + query: FEATURED_QUERY, + variables: {language, country}, + }); + + invariant(data, 'No data returned from Shopify API'); + + return { + featuredCollections: flattenConnection(data.featuredCollections), + featuredProducts: flattenConnection(data.featuredProducts), + }; +} + +const FEATURED_QUERY = `#graphql + ${PRODUCT_CARD_FRAGMENT} + query homepage($country: CountryCode, $language: LanguageCode) + @inContext(country: $country, language: $language) { + featuredCollections: collections(first: 3, sortKey: UPDATED_AT) { + nodes { + id + title + handle + image { + altText + width + height + url + } + } + } + featuredProducts: products(first: 12) { + nodes { + ...ProductCard + } + } + } +`; diff --git a/app/routes/journal/$journalHandle.tsx b/app/routes/journal/$journalHandle.tsx index 9dcf62a55c..23d5162231 100644 --- a/app/routes/journal/$journalHandle.tsx +++ b/app/routes/journal/$journalHandle.tsx @@ -7,9 +7,9 @@ import { } from '@hydrogen/remix'; import {useLoaderData} from '@remix-run/react'; import {Image} from '@shopify/hydrogen-react'; +import {Blog} from '@shopify/hydrogen-react/storefront-api-types'; import invariant from 'tiny-invariant'; import {PageHeader, Section} from '~/components'; -import {getArticle} from '~/data'; import {ATTR_LOADING_EAGER} from '~/lib/const'; import {getLocalizationFromLang} from '~/lib/utils'; import styles from '../../styles/custom-font.css'; @@ -23,17 +23,28 @@ export const handle = { }, }; -export async function loader({params}: LoaderArgs) { +export async function loader({params, context: {storefront}}: LoaderArgs) { const {language, country} = getLocalizationFromLang(params.lang); invariant(params.journalHandle, 'Missing journal handle'); - const article = await getArticle({ - blogHandle: BLOG_HANDLE, - articleHandle: params.journalHandle, - params, + const {blog} = await storefront.query<{ + blog: Blog; + }>({ + query: ARTICLE_QUERY, + variables: { + blogHandle: BLOG_HANDLE, + articleHandle: params.journalHandle, + language, + }, }); + if (!blog?.articleByHandle) { + throw new Response('Not found', {status: 404}); + } + + const article = blog.articleByHandle; + const formattedDate = new Intl.DateTimeFormat(`${language}-${country}`, { year: 'numeric', month: 'long', @@ -100,3 +111,33 @@ export default function Article() { ); } + +const ARTICLE_QUERY = `#graphql + query ArticleDetails( + $language: LanguageCode + $blogHandle: String! + $articleHandle: String! + ) @inContext(language: $language) { + blog(handle: $blogHandle) { + articleByHandle(handle: $articleHandle) { + title + contentHtml + publishedAt + author: authorV2 { + name + } + image { + id + altText + url + width + height + } + seo { + description + title + } + } + } + } +`; diff --git a/app/routes/journal/index.tsx b/app/routes/journal/index.tsx index aaf5ad41c7..b0ad8d861e 100644 --- a/app/routes/journal/index.tsx +++ b/app/routes/journal/index.tsx @@ -1,9 +1,8 @@ import {json, type LoaderArgs, type MetaFunction} from '@hydrogen/remix'; import {useLoaderData} from '@remix-run/react'; import {flattenConnection, Image} from '@shopify/hydrogen-react'; -import type {Article} from '@shopify/hydrogen-react/storefront-api-types'; +import type {Article, Blog} from '@shopify/hydrogen-react/storefront-api-types'; import {Grid, PageHeader, Section, Link} from '~/components'; -import {getBlog} from '~/data'; import {getImageLoadingPriority, PAGINATION_SIZE} from '~/lib/const'; import {getLocalizationFromLang} from '~/lib/utils'; @@ -15,16 +14,24 @@ export const handle = { }, }; -export const loader = async ({params}: LoaderArgs) => { - const journals = await getBlog({ - params, - blogHandle: BLOG_HANDLE, - paginationSize: PAGINATION_SIZE, +export const loader = async ({params, context: {storefront}}: LoaderArgs) => { + const {language, country} = getLocalizationFromLang(params.lang); + const {blog} = await storefront.query<{ + blog: Blog; + }>({ + query: BLOGS_QUERY, + variables: { + language, + blogHandle: BLOG_HANDLE, + pageBy: PAGINATION_SIZE, + }, }); - const {language, country} = getLocalizationFromLang(params.lang); + if (!blog?.articles) { + throw new Response('Not found', {status: 404}); + } - const articles = flattenConnection(journals).map((article) => { + const articles = flattenConnection(blog.articles).map((article) => { const {publishedAt} = article; return { ...article, @@ -109,3 +116,36 @@ function ArticleCard({ ); } + +const BLOGS_QUERY = `#graphql +query Blog( + $language: LanguageCode + $blogHandle: String! + $pageBy: Int! + $cursor: String +) @inContext(language: $language) { + blog(handle: $blogHandle) { + articles(first: $pageBy, after: $cursor) { + edges { + node { + author: authorV2 { + name + } + contentHtml + handle + id + image { + id + altText + url + width + height + } + publishedAt + title + } + } + } + } +} +`; diff --git a/app/routes/pages/$pageHandle.tsx b/app/routes/pages/$pageHandle.tsx index d62dab3ba0..15b6c4cfa8 100644 --- a/app/routes/pages/$pageHandle.tsx +++ b/app/routes/pages/$pageHandle.tsx @@ -1,17 +1,26 @@ import {json, LoaderArgs, MetaFunction, SerializeFrom} from '@hydrogen/remix'; +import type {Page} from '@shopify/hydrogen-react/storefront-api-types'; import {useLoaderData} from '@remix-run/react'; import invariant from 'tiny-invariant'; import {PageHeader} from '~/components'; -import {getPageData} from '~/data'; +import {getLocalizationFromLang} from '~/lib/utils'; -export async function loader({params}: LoaderArgs) { +export async function loader({params, context: {storefront}}: LoaderArgs) { invariant(params.pageHandle, 'Missing page handle'); - const page = await getPageData({ - handle: params.pageHandle, - params, + const {language} = getLocalizationFromLang(params.lang); + const {page} = await storefront.query<{page: Page}>({ + query: PAGE_QUERY, + variables: { + language, + handle: params.pageHandle, + }, }); + if (!page) { + throw new Response('Not found', {status: 404}); + } + return json( {page}, { @@ -47,3 +56,18 @@ export default function Page() { ); } + +const PAGE_QUERY = `#graphql + query PageDetails($language: LanguageCode, $handle: String!) + @inContext(language: $language) { + page(handle: $handle) { + id + title + body + seo { + description + title + } + } + } +`; diff --git a/app/routes/policies/$policyHandle.tsx b/app/routes/policies/$policyHandle.tsx index 96983039fc..c055e72d0b 100644 --- a/app/routes/policies/$policyHandle.tsx +++ b/app/routes/policies/$policyHandle.tsx @@ -1,18 +1,42 @@ import {json, type LoaderArgs, type MetaFunction} from '@hydrogen/remix'; import {useLoaderData} from '@remix-run/react'; -import {getPolicyContent} from '~/data'; import {PageHeader, Section, Button} from '~/components'; import invariant from 'tiny-invariant'; +import {getLocalizationFromLang} from '~/lib/utils'; +import {ShopPolicy} from '@shopify/hydrogen-react/storefront-api-types'; -export async function loader({params}: LoaderArgs) { +export async function loader({params, context: {storefront}}: LoaderArgs) { invariant(params.policyHandle, 'Missing policy handle'); + const handle = params.policyHandle; - const policy = await getPolicyContent({ - handle: params.policyHandle, - params, + const {language} = getLocalizationFromLang(params.lang); + + const policyName = handle.replace(/-([a-z])/g, (_: unknown, m1: string) => + m1.toUpperCase(), + ); + + const data = await storefront.query<{ + shop: Record; + }>({ + query: POLICY_CONTENT_QUERY, + variables: { + language, + privacyPolicy: false, + shippingPolicy: false, + termsOfService: false, + refundPolicy: false, + [policyName]: true, + }, }); + invariant(data, 'No data returned from Shopify API'); + const policy = data.shop?.[policyName]; + + if (!policy) { + throw new Response('Not found', {status: 404}); + } + return json( {policy}, { @@ -61,3 +85,36 @@ export default function Policies() { ); } + +const POLICY_CONTENT_QUERY = `#graphql + fragment Policy on ShopPolicy { + body + handle + id + title + url + } + + query PoliciesQuery( + $languageCode: LanguageCode + $privacyPolicy: Boolean! + $shippingPolicy: Boolean! + $termsOfService: Boolean! + $refundPolicy: Boolean! + ) @inContext(language: $languageCode) { + shop { + privacyPolicy @include(if: $privacyPolicy) { + ...Policy + } + shippingPolicy @include(if: $shippingPolicy) { + ...Policy + } + termsOfService @include(if: $termsOfService) { + ...Policy + } + refundPolicy @include(if: $refundPolicy) { + ...Policy + } + } + } +`; diff --git a/app/routes/policies/index.tsx b/app/routes/policies/index.tsx index 757ba9f3e9..52155a8b3c 100644 --- a/app/routes/policies/index.tsx +++ b/app/routes/policies/index.tsx @@ -1,14 +1,34 @@ -import {json, type MetaFunction, type SerializeFrom} from '@hydrogen/remix'; +import { + json, + LoaderArgs, + type MetaFunction, + type SerializeFrom, +} from '@hydrogen/remix'; import {Link, useLoaderData} from '@remix-run/react'; -import {getPolicies} from '~/data'; +import {ShopPolicy} from '@shopify/hydrogen-react/storefront-api-types'; +import invariant from 'tiny-invariant'; import {PageHeader, Section, Heading} from '~/components'; -export async function loader() { - const policies = await getPolicies(); +export async function loader({context: {storefront}}: LoaderArgs) { + const data = await storefront.query<{ + shop: Record; + }>({ + query: POLICIES_QUERY, + variables: {}, + }); + + invariant(data, 'No data returned from Shopify API'); + const policies = Object.values(data.shop || {}); + + if (policies.length === 0) { + throw new Response('Not found', {status: 404}); + } return json( - {policies}, + { + policies, + }, { headers: { // TODO cacheLong() @@ -48,3 +68,33 @@ export default function Policies() { ); } + +const POLICIES_QUERY = `#graphql + fragment Policy on ShopPolicy { + id + title + handle + } + + query PoliciesQuery { + shop { + privacyPolicy { + ...Policy + } + shippingPolicy { + ...Policy + } + termsOfService { + ...Policy + } + refundPolicy { + ...Policy + } + subscriptionPolicy { + id + title + handle + } + } + } +`; diff --git a/app/routes/products/$productHandle.tsx b/app/routes/products/$productHandle.tsx index 8eb6500deb..60f3c14b00 100644 --- a/app/routes/products/$productHandle.tsx +++ b/app/routes/products/$productHandle.tsx @@ -4,17 +4,11 @@ import { useLoaderData, Await, useSearchParams, - Form, useLocation, useTransition, useFetcher, } from '@remix-run/react'; import {Money, ShopPayButton} from '@shopify/hydrogen-react'; -import { - Product, - MediaImage, -} from '@shopify/hydrogen-react/storefront-api-types'; - import {type ReactNode, useRef, Suspense, useMemo} from 'react'; import { Button, @@ -29,57 +23,103 @@ import { Text, Link, } from '~/components'; -import {getProductData, getRecommendedProducts} from '~/data'; -import {getExcerpt} from '~/lib/utils'; +import {getExcerpt, getLocalizationFromLang} from '~/lib/utils'; import {useIsHydrated} from '~/hooks/useIsHydrated'; import invariant from 'tiny-invariant'; import clsx from 'clsx'; -import {SeoDescriptor} from '~/lib/seo'; - -export const handle = { - seo: (data: {product: Product}): Partial => { - const {media} = data.product; - const images = media.nodes - .filter((med) => med.mediaContentType === 'IMAGE') - .slice(0, 2) - .map((med) => ({ - ...(med as MediaImage).image, - alt: med.alt || 'Product image', - })); - - return { - description: 'A description of the product', - title: data?.product?.title, - titleTemplate: `%s | Product from ${data?.product?.vendor}`, - defaultTitle: 'Fallback title', - tags: ['snowboard', 'hydrogen', 'shopify'], - twitter: { - card: 'summary_large_image', - handle: 'shopify', - }, - images, - alternates: [{url: `/de/products/${data.product.handle}`, lang: 'DE-BE'}], - }; - }, -}; +import { + ProductVariant, + SelectedOptionInput, + Product, + Shop, + ProductConnection, + LanguageCode, + CountryCode, +} from '@shopify/hydrogen-react/storefront-api-types'; +import { + MEDIA_FRAGMENT, + PRODUCT_CARD_FRAGMENT, + PRODUCT_VARIANT_FRAGMENT, +} from '~/data'; -export const loader = async ({params, request}: LoaderArgs) => { +export const loader = async ({ + params, + request, + context: {storefront}, +}: LoaderArgs) => { const {productHandle} = params; invariant(productHandle, 'Missing productHandle param, check route filename'); - const {shop, product} = await getProductData( - productHandle, - new URL(request.url).searchParams, - params, - ); + const {language, country} = getLocalizationFromLang(params.lang); + const searchParams = new URL(request.url).searchParams; + + const selectedOptions: SelectedOptionInput[] = []; + searchParams.forEach((value, name) => { + selectedOptions.push({name, value}); + }); + + const {shop, product} = await storefront.query<{ + product: Product & {selectedVariant?: ProductVariant}; + shop: Shop; + }>({ + query: PRODUCT_QUERY, + variables: { + handle: productHandle, + country, + language, + selectedOptions, + }, + }); return defer({ product, shop, - recommended: getRecommendedProducts(product.id, params), + recommended: getRecommendedProducts( + storefront, + language, + country, + product.id, + ), }); }; +async function getRecommendedProducts( + storefront: LoaderArgs['context']['storefront'], + language: LanguageCode, + country: CountryCode, + productId: string, +) { + const products = await storefront.query<{ + recommended: Product[]; + additional: ProductConnection; + }>({ + query: RECOMMENDED_PRODUCTS_QUERY, + variables: { + productId, + count: 12, + language, + country, + }, + }); + + invariant(products, 'No data returned from Shopify API'); + + const mergedProducts = products.recommended + .concat(products.additional.nodes) + .filter( + (value, index, array) => + array.findIndex((value2) => value2.id === value.id) === index, + ); + + const originalProduct = mergedProducts + .map((item: Product) => item.id) + .indexOf(productId); + + mergedProducts.splice(originalProduct, 1); + + return mergedProducts; +} + export default function Product() { const {product, shop, recommended} = useLoaderData(); const {media, title, vendor, descriptionHtml} = product; @@ -454,3 +494,73 @@ function ProductDetail({ ); } + +const PRODUCT_QUERY = `#graphql + ${MEDIA_FRAGMENT} + ${PRODUCT_VARIANT_FRAGMENT} + query Product( + $country: CountryCode + $language: LanguageCode + $handle: String! + $selectedOptions: [SelectedOptionInput!]! + ) @inContext(country: $country, language: $language) { + product(handle: $handle) { + id + title + vendor + handle + descriptionHtml + options { + name + values + } + selectedVariant: variantBySelectedOptions(selectedOptions: $selectedOptions) { + ...ProductVariantFragment + } + media(first: 7) { + nodes { + ...Media + } + } + variants(first: 1) { + nodes { + ...ProductVariantFragment + } + } + seo { + description + title + } + } + shop { + name + shippingPolicy { + body + handle + } + refundPolicy { + body + handle + } + } + } +`; + +const RECOMMENDED_PRODUCTS_QUERY = `#graphql + ${PRODUCT_CARD_FRAGMENT} + query productRecommendations( + $productId: ID! + $count: Int + $country: CountryCode + $language: LanguageCode + ) @inContext(country: $country, language: $language) { + recommended: productRecommendations(productId: $productId) { + ...ProductCard + } + additional: products(first: $count, sortKey: BEST_SELLING) { + nodes { + ...ProductCard + } + } + } +`; diff --git a/app/routes/products/index.tsx b/app/routes/products/index.tsx index d11a62b60d..a82446002c 100644 --- a/app/routes/products/index.tsx +++ b/app/routes/products/index.tsx @@ -1,14 +1,37 @@ import type {LoaderArgs, MetaFunction} from '@hydrogen/remix'; import {useLoaderData} from '@remix-run/react'; -import type {Collection} from '@shopify/hydrogen-react/storefront-api-types'; +import type { + Collection, + ProductConnection, +} from '@shopify/hydrogen-react/storefront-api-types'; +import invariant from 'tiny-invariant'; import {PageHeader, Section, ProductGrid} from '~/components'; -import {getAllProducts} from '~/data'; +import {PRODUCT_CARD_FRAGMENT} from '~/data'; +import {getLocalizationFromLang} from '~/lib/utils'; -export async function loader({request, params}: LoaderArgs) { +export async function loader({ + request, + params, + context: {storefront}, +}: LoaderArgs) { const cursor = new URL(request.url).searchParams.get('cursor') ?? undefined; - const products = await getAllProducts({cursor, params}); - return products; + const {language, country} = getLocalizationFromLang(params.lang); + const data = await storefront.query<{ + products: ProductConnection; + }>({ + query: ALL_PRODUCTS_QUERY, + variables: { + pageBy: 48, + cursor, + language, + country, + }, + }); + + invariant(data, 'No data returned from Shopify API'); + + return data.products; } export const meta: MetaFunction = () => { @@ -34,3 +57,24 @@ export default function AllProducts() { ); } + +const ALL_PRODUCTS_QUERY = `#graphql + ${PRODUCT_CARD_FRAGMENT} + query AllProducts( + $country: CountryCode + $language: LanguageCode + $pageBy: Int! + $cursor: String + ) @inContext(country: $country, language: $language) { + products(first: $pageBy, after: $cursor) { + nodes { + ...ProductCard + } + pageInfo { + hasNextPage + startCursor + endCursor + } + } + } +`; diff --git a/app/routes/search.tsx b/app/routes/search.tsx index ba4edd0952..ac3265576a 100644 --- a/app/routes/search.tsx +++ b/app/routes/search.tsx @@ -1,7 +1,16 @@ import {defer, type LoaderArgs} from '@hydrogen/remix'; +import {flattenConnection} from '@shopify/hydrogen-react'; import {Await, Form, useLoaderData} from '@remix-run/react'; -import type {Collection} from '@shopify/hydrogen-react/storefront-api-types'; +import type { + Collection, + CollectionConnection, + CountryCode, + LanguageCode, + Product, + ProductConnection, +} from '@shopify/hydrogen-react/storefront-api-types'; import {Suspense} from 'react'; +import invariant from 'tiny-invariant'; import { Heading, Input, @@ -12,11 +21,13 @@ import { Section, Text, } from '~/components'; -import {getNoResultRecommendations, searchProducts} from '~/data'; +import {PRODUCT_CARD_FRAGMENT} from '~/data'; import {PAGINATION_SIZE} from '~/lib/const'; +import {getLocalizationFromLang} from '~/lib/utils'; export default function () { - const {searchTerm, products, noResultRecommendations} = useLoaderData(); + const {searchTerm, products, noResultRecommendations} = + useLoaderData(); const noResults = products?.nodes?.length === 0; return ( @@ -56,11 +67,11 @@ export default function () { <> } /> } /> )} @@ -80,24 +91,120 @@ export default function () { ); } -export async function loader({request, context, params}: LoaderArgs) { +export async function loader({ + request, + params, + context: {storefront}, +}: LoaderArgs) { const searchParams = new URL(request.url).searchParams; const cursor = searchParams.get('cursor')!; const searchTerm = searchParams.get('q')!; + const {language, country} = getLocalizationFromLang(params.lang); - const products = await searchProducts(params, { - cursor, - searchTerm, - pageBy: PAGINATION_SIZE, + const data = await storefront.query<{ + products: ProductConnection; + }>({ + query: SEARCH_QUERY, + variables: { + pageBy: PAGINATION_SIZE, + searchTerm, + cursor, + language, + country, + }, }); + invariant(data, 'No data returned from Shopify API'); + const {products} = data; + const getRecommendations = !searchTerm || products?.nodes?.length === 0; return defer({ searchTerm, products, noResultRecommendations: getRecommendations - ? getNoResultRecommendations(params) - : null, + ? getNoResultRecommendations(storefront, language, country) + : Promise.resolve(null), }); } + +const SEARCH_QUERY = `#graphql + ${PRODUCT_CARD_FRAGMENT} + query search( + $searchTerm: String + $country: CountryCode + $language: LanguageCode + $pageBy: Int! + $after: String + ) @inContext(country: $country, language: $language) { + products( + first: $pageBy + sortKey: RELEVANCE + query: $searchTerm + after: $after + ) { + nodes { + ...ProductCard + } + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + } + } +`; + +export async function getNoResultRecommendations( + storefront: LoaderArgs['context']['storefront'], + language: LanguageCode, + country: CountryCode, +) { + const data = await storefront.query<{ + featuredCollections: CollectionConnection; + featuredProducts: ProductConnection; + }>({ + query: SEARCH_NO_RESULTS_QUERY, + variables: { + language, + country, + pageBy: PAGINATION_SIZE, + }, + }); + + invariant(data, 'No data returned from Shopify API'); + + return { + featuredCollections: flattenConnection(data.featuredCollections), + featuredProducts: flattenConnection(data.featuredProducts), + }; +} + +const SEARCH_NO_RESULTS_QUERY = `#graphql + ${PRODUCT_CARD_FRAGMENT} + query searchNoResult( + $country: CountryCode + $language: LanguageCode + $pageBy: Int! + ) @inContext(country: $country, language: $language) { + featuredCollections: collections(first: 3, sortKey: UPDATED_AT) { + nodes { + id + title + handle + image { + altText + width + height + url + } + } + } + featuredProducts: products(first: $pageBy) { + nodes { + ...ProductCard + } + } + } +`;