diff --git a/shopify/handlers/sitemap.ts b/shopify/handlers/sitemap.ts index 5cb37be8b..968238e3d 100644 --- a/shopify/handlers/sitemap.ts +++ b/shopify/handlers/sitemap.ts @@ -3,74 +3,107 @@ import { AppContext } from "../mod.ts"; import { withDigestCookie } from "../utils/password.ts"; type ConnInfo = Deno.ServeHandlerInfo; -const xmlHeader = + +const XML_HEADER = ''; -const includeSiteMaps = ( - currentXML: string, - origin: string, - includes?: string[], -) => { - const siteMapIncludeTags = []; - - for (const include of (includes ?? [])) { - siteMapIncludeTags.push(` - - ${include.startsWith("/") ? `${origin}${include}` : include} - ${new Date().toISOString().substring(0, 10)} - `); - } - return siteMapIncludeTags.length > 0 - ? currentXML.replace( - xmlHeader, - `${xmlHeader}\n${siteMapIncludeTags.join("\n")}`, - ) - : currentXML; -}; +// Helper function to get current date in YYYY-MM-DD format +const getToday = (): string => new Date().toISOString().substring(0, 10); + +function buildIncludeSitemaps(origin: string, includes?: string[]) { + if (!includes?.length) return ""; + + const today = getToday(); + const esc = (s: string) => + s + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + + return includes + .map((include) => { + const loc = include.startsWith("/") ? `${origin}${include}` : include; + const safeLoc = esc(loc); + return ` \n ${safeLoc}\n ${today}\n `; + }) + .join("\n"); +} + +function excludeSitemaps(xml: string, origin: string, excludes?: string[]) { + if (!excludes?.length) return xml; + + // Ensure all exclude prefixes start with a slash + const normalized = excludes.map((ex) => (ex.startsWith("/") ? ex : `/${ex}`)); + + return xml.replace( + /\s*(.*?)<\/loc>[\s\S]*?<\/sitemap>/g, + (match, loc) => { + let locPath: string; + try { + // Use origin as base to support both absolute and relative URLs + const u = new URL(loc, origin); + locPath = u.pathname; + } catch { + // If URL parsing fails, leave the sitemap entry untouched + return match; + } + + return normalized.some((ex) => locPath.startsWith(ex)) ? "" : match; + }, + ); +} export interface Props { include?: string[]; + exclude?: string[]; } + /** * @title Sitemap Proxy */ export default function Sitemap( - { include }: Props, + { include, exclude }: Props, appCtx: AppContext, ) { - const url = `https://${appCtx.storeName}.myshopify.com`; - return async ( - req: Request, - ctx: ConnInfo, - ) => { - if (!url) { - throw new Error("Missing publicUrl"); - } - - const publicUrl = - new URL(url?.startsWith("http") ? url : `https://${url}`).href; + const shopifyUrl = `https://${appCtx.storeName}.myshopify.com`; - const response = await Proxy({ - url: publicUrl, + return async (req: Request, conn: ConnInfo) => { + const reqOrigin = new URL(req.url).origin; + const proxyResponse = await Proxy({ + url: shopifyUrl, customHeaders: withDigestCookie(appCtx), - })(req, ctx); + })(req, conn); - if (!response.ok) { - return response; - } + if (!proxyResponse.ok) return proxyResponse; - const reqUrl = new URL(req.url); - const text = await response.text(); - return new Response( - includeSiteMaps( - text.replaceAll(publicUrl, `${reqUrl.origin}/`), - reqUrl.origin, - include, - ), - { - headers: response.headers, - status: response.status, - }, + const originalXml = await proxyResponse.text(); + const originWithSlash = reqOrigin.endsWith("/") + ? reqOrigin.slice(0, -1) + : reqOrigin; + const originReplacedXml = originalXml.replaceAll( + shopifyUrl, + originWithSlash, ); + const excludedXml = excludeSitemaps(originReplacedXml, reqOrigin, exclude); + + const includeBlock = buildIncludeSitemaps(reqOrigin, include); + const finalXml = includeBlock + ? excludedXml.replace(XML_HEADER, `${XML_HEADER}\n${includeBlock}`) + : excludedXml; + + const headers = new Headers(proxyResponse.headers); + headers.delete("content-length"); + headers.delete("content-encoding"); + headers.delete("etag"); + headers.delete("accept-ranges"); + if (!headers.get("content-type")?.includes("xml")) { + headers.set("content-type", "application/xml; charset=utf-8"); + } + return new Response(finalXml, { + status: proxyResponse.status, + headers, + }); }; } diff --git a/shopify/loaders/ProductDetailsPage.ts b/shopify/loaders/ProductDetailsPage.ts index 29c345c81..5117c5282 100644 --- a/shopify/loaders/ProductDetailsPage.ts +++ b/shopify/loaders/ProductDetailsPage.ts @@ -3,12 +3,14 @@ import { AppContext } from "../../shopify/mod.ts"; import { toProductPage } from "../../shopify/utils/transform.ts"; import type { RequestURLParam } from "../../website/functions/requestToParam.ts"; import { + CountryCode, GetProductQuery, GetProductQueryVariables, HasMetafieldsMetafieldsArgs, + LanguageCode, } from "../utils/storefront/storefront.graphql.gen.ts"; import { GetProduct } from "../utils/storefront/queries.ts"; -import { Metafield } from "../utils/types.ts"; +import { LanguageContextArgs, Metafield } from "../utils/types.ts"; export interface Props { slug: RequestURLParam; @@ -17,6 +19,18 @@ export interface Props { * @description search for metafields */ metafields?: Metafield[]; + /** + * @title Language Code + * @description Language code for the storefront API + * @example "EN" for English, "FR" for French, etc. + */ + languageCode?: LanguageCode; + /** + * @title Country Code + * @description Country code for the storefront API + * @example "US" for United States, "FR" for France, etc. + */ + countryCode?: CountryCode; } /** @@ -29,7 +43,7 @@ const loader = async ( ctx: AppContext, ): Promise => { const { storefront } = ctx; - const { slug } = props; + const { slug, languageCode = "PT", countryCode = "BR" } = props; const metafields = props.metafields || []; const splitted = slug?.split("-"); @@ -39,9 +53,9 @@ const loader = async ( const data = await storefront.query< GetProductQuery, - GetProductQueryVariables & HasMetafieldsMetafieldsArgs + GetProductQueryVariables & HasMetafieldsMetafieldsArgs & LanguageContextArgs >({ - variables: { handle, identifiers: metafields }, + variables: { handle, identifiers: metafields, languageCode, countryCode }, ...GetProduct, }); diff --git a/shopify/loaders/ProductList.ts b/shopify/loaders/ProductList.ts index e093fe0c6..aee5bf601 100644 --- a/shopify/loaders/ProductList.ts +++ b/shopify/loaders/ProductList.ts @@ -6,7 +6,9 @@ import { } from "../utils/storefront/queries.ts"; import { CollectionProductsArgs, + CountryCode, HasMetafieldsMetafieldsArgs, + LanguageCode, ProductConnection, ProductFragment, QueryRoot, @@ -21,7 +23,7 @@ import { searchSortShopify, sortShopify, } from "../utils/utils.ts"; -import { Metafield } from "../utils/types.ts"; +import { LanguageContextArgs, Metafield } from "../utils/types.ts"; export interface QueryProps { /** @description search term to use on search */ @@ -70,6 +72,18 @@ export type Props = { * @description search for metafields */ metafields?: Metafield[]; + /** + * @title Language Code + * @description Language code for the storefront API + * @example "EN" for English, "FR" for French, etc. + */ + languageCode?: LanguageCode; + /** + * @title Country Code + * @description Country code for the storefront API + * @example "US" for United States, "FR" for France, etc. + */ + countryCode?: CountryCode; }; // deno-lint-ignore no-explicit-any @@ -92,6 +106,8 @@ const loader = async ( const count = props.count ?? 12; const metafields = expandedProps.metafields || []; + const languageCode = expandedProps?.languageCode ?? "PT"; + const countryCode = expandedProps?.countryCode ?? "BR"; let shopifyProducts: | SearchResultItemConnection @@ -121,13 +137,15 @@ const loader = async ( if (isQueryList(props)) { const data = await storefront.query< QueryRoot, - QueryRootSearchArgs & HasMetafieldsMetafieldsArgs + QueryRootSearchArgs & HasMetafieldsMetafieldsArgs & LanguageContextArgs >({ variables: { first: count, query: props.query, productFilters: filters, identifiers: metafields, + languageCode, + countryCode, ...searchSortShopify[sort], }, ...SearchProducts, @@ -139,12 +157,15 @@ const loader = async ( & QueryRootCollectionArgs & CollectionProductsArgs & HasMetafieldsMetafieldsArgs + & LanguageContextArgs >({ variables: { first: count, handle: props.collection, filters, identifiers: metafields, + languageCode, + countryCode, ...sortShopify[sort], }, ...ProductsByCollection, diff --git a/shopify/loaders/ProductListingPage.ts b/shopify/loaders/ProductListingPage.ts index e0556ac38..3a31abdaf 100644 --- a/shopify/loaders/ProductListingPage.ts +++ b/shopify/loaders/ProductListingPage.ts @@ -6,7 +6,9 @@ import { } from "../utils/storefront/queries.ts"; import { CollectionProductsArgs, + CountryCode, HasMetafieldsMetafieldsArgs, + LanguageCode, ProductConnection, ProductFragment, QueryRoot, @@ -15,7 +17,7 @@ import { SearchResultItemConnection, } from "../utils/storefront/storefront.graphql.gen.ts"; import { toFilter, toProduct } from "../utils/transform.ts"; -import { Metafield } from "../utils/types.ts"; +import { LanguageContextArgs, Metafield } from "../utils/types.ts"; import { getFiltersByUrl, searchSortOptions, @@ -69,6 +71,18 @@ export interface Props { * @description The URL of the page, used to override URL from request */ pageHref?: string; + /** + * @title Language Code + * @description Language code for the storefront API + * @example "EN" for English, "FR" for French, etc. + */ + languageCode?: LanguageCode; + /** + * @title Country Code + * @description Country code for the storefront API + * @example "US" for United States, "FR" for France, etc. + */ + countryCode?: CountryCode; } /** @@ -94,6 +108,8 @@ const loader = async ( const startCursor = props.startCursor || url.searchParams.get("startCursor") || ""; const metafields = props.metafields || []; + const languageCode = props?.languageCode || "PT"; + const countryCode = props?.countryCode || "BR"; const isSearch = Boolean(query); let hasNextPage = false; @@ -105,6 +121,7 @@ const loader = async ( | undefined = undefined; let shopifyFilters = undefined; let records = undefined; + let collectionId = undefined; let collectionTitle = undefined; let collectionDescription = undefined; @@ -113,7 +130,7 @@ const loader = async ( if (isSearch) { const data = await storefront.query< QueryRoot, - QueryRootSearchArgs & HasMetafieldsMetafieldsArgs + QueryRootSearchArgs & HasMetafieldsMetafieldsArgs & LanguageContextArgs >({ variables: { ...(!endCursor && { first: count }), @@ -123,7 +140,9 @@ const loader = async ( query: query, productFilters: getFiltersByUrl(url), identifiers: metafields, - ...searchSortShopify[sort], + languageCode, + countryCode, + ...(searchSortShopify[sort] || {}), }, ...SearchProducts, }); @@ -136,15 +155,17 @@ const loader = async ( data?.search?.pageInfo.hasPreviousPage ?? false, ); } else { - // TODO: understand how accept more than one path - // example: /collections/first-collection/second-collection - const pathname = props.collectionName || url.pathname.split("/")[1]; + // Support for multiple paths, such as /{lang}/collections/first-collection/second-collection + // Always takes the last non-empty segment as pathname + const pathname = props.collectionName || + url.pathname.split("/").filter(Boolean).pop(); const data = await storefront.query< QueryRoot, & QueryRootCollectionArgs & CollectionProductsArgs & HasMetafieldsMetafieldsArgs + & LanguageContextArgs >({ variables: { ...(!endCursor && { first: count }), @@ -154,7 +175,9 @@ const loader = async ( identifiers: metafields, handle: pathname, filters: getFiltersByUrl(url), - ...sortShopify[sort], + languageCode, + countryCode, + ...(sortShopify[sort] || {}), }, ...ProductsByCollection, }); @@ -167,6 +190,7 @@ const loader = async ( hasPreviousPage = Boolean( data?.collection?.products.pageInfo.hasPreviousPage ?? false, ); + collectionId = data.collection?.id; collectionTitle = data.collection?.title; collectionDescription = data.collection?.description; } @@ -207,9 +231,10 @@ const loader = async ( // TODO: Update breadcrumb when accept more than one path breadcrumb: { "@type": "BreadcrumbList", + "@id": collectionId, itemListElement: [{ "@type": "ListItem" as const, - name: isSearch ? query : url.pathname.split("/")[1], + name: isSearch ? query : url.pathname.split("/").filter(Boolean).pop(), item: isSearch ? url.href : url.pathname, position: 2, }], diff --git a/shopify/loaders/RelatedProducts.ts b/shopify/loaders/RelatedProducts.ts index a1d8b0ece..51f44515a 100644 --- a/shopify/loaders/RelatedProducts.ts +++ b/shopify/loaders/RelatedProducts.ts @@ -6,14 +6,16 @@ import { ProductRecommendations, } from "../utils/storefront/queries.ts"; import { + CountryCode, GetProductQuery, GetProductQueryVariables, HasMetafieldsMetafieldsArgs, + LanguageCode, ProductRecommendationsQuery, ProductRecommendationsQueryVariables, } from "../utils/storefront/storefront.graphql.gen.ts"; import { toProduct } from "../utils/transform.ts"; -import { Metafield } from "../utils/types.ts"; +import { LanguageContextArgs, Metafield } from "../utils/types.ts"; export interface Props { slug: RequestURLParam; @@ -27,6 +29,18 @@ export interface Props { * @description search for metafields */ metafields?: Metafield[]; + /** + * @title Language Code + * @description Language code for the storefront API + * @example "EN" for English, "FR" for French, etc. + */ + languageCode?: LanguageCode; + /** + * @title Country Code + * @description Country code for the storefront API + * @example "US" for United States, "FR" for France, etc. + */ + countryCode?: CountryCode; } /** @@ -39,7 +53,7 @@ const loader = async ( ctx: AppContext, ): Promise => { const { storefront } = ctx; - const { slug, count } = props; + const { slug, count, languageCode = "PT", countryCode = "BR" } = props; const splitted = slug?.split("-"); const maybeSkuId = Number(splitted[splitted.length - 1]); @@ -48,9 +62,9 @@ const loader = async ( const query = await storefront.query< GetProductQuery, - GetProductQueryVariables & HasMetafieldsMetafieldsArgs + GetProductQueryVariables & HasMetafieldsMetafieldsArgs & LanguageContextArgs >({ - variables: { handle, identifiers: metafields }, + variables: { handle, identifiers: metafields, languageCode, countryCode }, ...GetProduct, }); @@ -60,11 +74,15 @@ const loader = async ( const data = await storefront.query< ProductRecommendationsQuery, - ProductRecommendationsQueryVariables & HasMetafieldsMetafieldsArgs + & ProductRecommendationsQueryVariables + & HasMetafieldsMetafieldsArgs + & LanguageContextArgs >({ variables: { productId: query.product.id, identifiers: metafields, + languageCode, + countryCode, }, ...ProductRecommendations, }); diff --git a/shopify/loaders/cart.ts b/shopify/loaders/cart.ts index 960d3f7b1..8f198cb6c 100644 --- a/shopify/loaders/cart.ts +++ b/shopify/loaders/cart.ts @@ -2,31 +2,53 @@ import { AppContext } from "../mod.ts"; import { getCartCookie, setCartCookie } from "../utils/cart.ts"; import { CreateCart, GetCart } from "../utils/storefront/queries.ts"; import { + CountryCode, CreateCartMutation, CreateCartMutationVariables, GetCartQuery, GetCartQueryVariables, + LanguageCode, } from "../utils/storefront/storefront.graphql.gen.ts"; +import { LanguageContextArgs } from "../utils/types.ts"; + +export interface Props { + /** + * @title Language Code + * @description Language code for the storefront API + * @example "EN" for English, "FR" for French, etc. + */ + languageCode?: LanguageCode; + /** + * @title Country Code + * @description Country code for the storefront API + * @example "US" for United States, "FR" for France, etc. + */ + countryCode?: CountryCode; +} const loader = async ( - _props: unknown, + props: Props, req: Request, ctx: AppContext, ): Promise => { + const { languageCode = "PT", countryCode = "BR" } = props; const { storefront } = ctx; const maybeCartId = getCartCookie(req.headers); const cartId = maybeCartId || - await storefront.query( - CreateCart, - ).then((data) => data.payload?.cart?.id); + await storefront.query({ + ...CreateCart, + }).then((data) => data.payload?.cart?.id); if (!cartId) { throw new Error("Missing cart id"); } - const cart = await storefront.query({ - variables: { id: decodeURIComponent(cartId) }, + const cart = await storefront.query< + GetCartQuery, + GetCartQueryVariables & LanguageContextArgs + >({ + variables: { id: decodeURIComponent(cartId), languageCode, countryCode }, ...GetCart, }).then((data) => data.cart); diff --git a/shopify/loaders/proxy.ts b/shopify/loaders/proxy.ts index aee923767..605412dc8 100644 --- a/shopify/loaders/proxy.ts +++ b/shopify/loaders/proxy.ts @@ -29,20 +29,27 @@ const buildProxyRoutes = ( { ctx, ctx: { storeName, publicUrl }, - extraPaths, + extraPathsToProxy = [], + extraPaths = [], includeSiteMap, generateDecoSiteMap, excludePathsFromDecoSiteMap, + excludePathsFromShopifySiteMap, replaces, - }: { - extraPaths: string[]; - includeSiteMap?: string[]; - generateDecoSiteMap?: boolean; - excludePathsFromDecoSiteMap: string[]; - replaces: TextReplace[]; + }: Props & { ctx: AppContext; + extraPaths?: string[]; }, ) => { + // Handle backward compatibility for extraPaths prop + const finalExtraPathsToProxy = extraPathsToProxy.length > 0 + ? extraPathsToProxy + : extraPaths; + if (extraPaths.length > 0 && extraPathsToProxy.length === 0) { + console.warn( + 'DEPRECATION WARNING: "extraPaths" prop is deprecated. Use "extraPathsToProxy" instead.', + ); + } const urlToUse = publicUrl ? new URL(publicUrl.startsWith("http") ? publicUrl : `https://${publicUrl}`) : new URL(`https://${storeName}.myshopify.com`); @@ -64,7 +71,9 @@ const buildProxyRoutes = ( pathTemplate, handler: { value: { - __resolveType: "website/handlers/proxy.ts", + __resolveType: pathTemplate.includes("sitemap") + ? "shopify/handlers/sitemap.ts" + : "website/handlers/proxy.ts", url: urlToProxy, host: hostToUse, customHeaders: withDigestCookie(ctx), @@ -72,7 +81,7 @@ const buildProxyRoutes = ( }, }, }); - const routesFromPaths = [...PATHS_TO_PROXY, ...extraPaths].map( + const routesFromPaths = [...PATHS_TO_PROXY, ...finalExtraPathsToProxy].map( routeFromPath, ); @@ -95,6 +104,7 @@ const buildProxyRoutes = ( handler: { value: { include, + exclude: excludePathsFromShopifySiteMap, __resolveType: "shopify/handlers/sitemap.ts", }, }, @@ -118,6 +128,10 @@ const buildProxyRoutes = ( export interface Props { extraPathsToProxy?: string[]; + /** + * @deprecated Use extraPathsToProxy instead + */ + extraPaths?: string[]; /** * @title Other site maps to include */ @@ -130,6 +144,10 @@ export interface Props { * @title Exclude paths from /deco-sitemap.xml */ excludePathsFromDecoSiteMap?: string[]; + /** + * @title Exclude paths from /shopify-sitemap.xml + */ + excludePathsFromShopifySiteMap?: string[]; replaces?: TextReplace[]; } @@ -139,9 +157,11 @@ export interface Props { function loader( { extraPathsToProxy = [], + extraPaths = [], includeSiteMap = [], generateDecoSiteMap = true, excludePathsFromDecoSiteMap = [], + excludePathsFromShopifySiteMap = [], replaces = [], }: Props, _req: Request, @@ -150,8 +170,10 @@ function loader( return buildProxyRoutes({ generateDecoSiteMap, excludePathsFromDecoSiteMap, + excludePathsFromShopifySiteMap, includeSiteMap, - extraPaths: extraPathsToProxy, + extraPathsToProxy, + extraPaths, replaces, ctx, }); diff --git a/shopify/loaders/shop.ts b/shopify/loaders/shop.ts index 1498e0c85..22db0b638 100644 --- a/shopify/loaders/shop.ts +++ b/shopify/loaders/shop.ts @@ -1,10 +1,12 @@ import { AppContext } from "../mod.ts"; import { GetShopInfo } from "../utils/storefront/queries.ts"; import { + CountryCode, + LanguageCode, Shop, ShopMetafieldsArgs, } from "../utils/storefront/storefront.graphql.gen.ts"; -import { Metafield } from "../utils/types.ts"; +import { LanguageContextArgs, Metafield } from "../utils/types.ts"; export interface Props { /** @@ -12,6 +14,18 @@ export interface Props { * @description search for metafields */ metafields?: Metafield[]; + /** + * @title Language Code + * @description Language code for the storefront API + * @example "EN" for English, "FR" for French, etc. + */ + languageCode?: LanguageCode; + /** + * @title Country Code + * @description Country code for the storefront API + * @example "US" for United States, "FR" for France, etc. + */ + countryCode?: CountryCode; } export const defaultVisibility = "private"; @@ -22,10 +36,13 @@ const loader = async ( ctx: AppContext, ): Promise => { const { storefront } = ctx; - const { metafields = [] } = props; + const { metafields = [], languageCode = "PT", countryCode = "BR" } = props; - const shop = await storefront.query<{ shop: Shop }, ShopMetafieldsArgs>({ - variables: { identifiers: metafields }, + const shop = await storefront.query< + { shop: Shop }, + ShopMetafieldsArgs & LanguageContextArgs + >({ + variables: { identifiers: metafields, languageCode, countryCode }, ...GetShopInfo, }).then((data) => data.shop); diff --git a/shopify/utils/storefront/queries.ts b/shopify/utils/storefront/queries.ts index bd5e2bc3e..68b68170d 100644 --- a/shopify/utils/storefront/queries.ts +++ b/shopify/utils/storefront/queries.ts @@ -174,6 +174,10 @@ fragment Cart on Cart { id checkoutUrl totalQuantity + buyerIdentity { + countryCode + email + } lines(first: 100) { nodes { id @@ -266,112 +270,103 @@ const Customer = gql` `; export const CreateCart = { - query: gql`mutation CreateCart { - payload: cartCreate { - cart { id } + query: gql` + mutation CreateCart($countryCode: CountryCode) { + payload: cartCreate(input: { buyerIdentity: { countryCode: $countryCode } }) { + cart { + id + } + } } - }`, + `, }; export const GetCart = { fragments: [Cart], - query: gql`query GetCart($id: ID!) { cart(id: $id) { ...Cart } }`, + query: gql` + query GetCart( + $id: ID!, + $languageCode: LanguageCode, + $countryCode: CountryCode + ) @inContext(language: $languageCode, country: $countryCode) { + cart(id: $id) { + ...Cart + } + } + `, }; export const GetProduct = { fragments: [Product, ProductVariant, Collection], - query: - gql`query GetProduct($handle: String, $identifiers: [HasMetafieldsIdentifier!]!) { - product(handle: $handle) { ...Product } - }`, + query: gql` + query GetProduct( + $handle: String, + $identifiers: [HasMetafieldsIdentifier!]!, + $languageCode: LanguageCode, + $countryCode: CountryCode + ) @inContext(language: $languageCode, country: $countryCode) { + product(handle: $handle) { + ...Product + } + } + `, }; export const ListProducts = { fragments: [Product, ProductVariant, Collection], - query: - gql`query ListProducts($first: Int, $after: String, $query: String, $identifiers: [HasMetafieldsIdentifier!]!) { - products(first: $first, after: $after, query: $query) { - nodes { - ...Product + query: gql` + query ListProducts( + $first: Int, + $after: String, + $query: String, + $identifiers: [HasMetafieldsIdentifier!]!, + $languageCode: LanguageCode, + $countryCode: CountryCode + ) @inContext(language: $languageCode, country: $countryCode) { + products(first: $first, after: $after, query: $query) { + nodes { + ...Product + } } } - }`, + `, }; export const SearchProducts = { fragments: [Product, ProductVariant, Filter, Collection], - query: gql`query searchWithFilters( - $first: Int, - $last: Int, - $after: String, - $before: String, - $query: String!, - $productFilters: [ProductFilter!] - $sortKey: SearchSortKeys, + query: gql` + query searchWithFilters( + $first: Int, + $last: Int, + $after: String, + $before: String, + $query: String!, + $productFilters: [ProductFilter!], + $sortKey: SearchSortKeys, $reverse: Boolean, - $identifiers: [HasMetafieldsIdentifier!]! - ){ - search( - first: $first, - last: $last, - after: $after, - before: $before, - query: $query, - productFilters: $productFilters, - types: PRODUCT, - sortKey: $sortKey, - reverse: $reverse, - ){ - totalCount - pageInfo { - hasNextPage - hasPreviousPage - endCursor - startCursor - } - productFilters { - ...Filter - } - nodes { - ...Product - } - } - }`, -}; - -export const ProductsByCollection = { - fragments: [Product, ProductVariant, Collection, Filter], - query: gql`query AllProducts( - $first: Int, - $last: Int, - $after: String, - $before: String, - $handle: String, - $sortKey: ProductCollectionSortKeys, - $reverse: Boolean, - $filters: [ProductFilter!], - $identifiers: [HasMetafieldsIdentifier!]! - ){ - collection(handle: $handle) { - handle - description - title - products( - first: $first, - last: $last, - after: $after, - before: $before, - sortKey: $sortKey, - reverse: $reverse, - filters: $filters - ){ + $identifiers: [HasMetafieldsIdentifier!]!, + $languageCode: LanguageCode, + $countryCode: CountryCode + ) @inContext(language: $languageCode, country: $countryCode) { + search( + first: $first, + last: $last, + after: $after, + before: $before, + query: $query, + productFilters: $productFilters, + types: PRODUCT, + sortKey: $sortKey, + reverse: $reverse + ) { + totalCount pageInfo { hasNextPage hasPreviousPage endCursor startCursor } - filters { + productFilters { ...Filter } nodes { @@ -379,63 +374,123 @@ export const ProductsByCollection = { } } } - }`, + `, +}; + +export const ProductsByCollection = { + fragments: [Product, ProductVariant, Collection, Filter], + query: gql` + query AllProducts( + $first: Int, + $last: Int, + $after: String, + $before: String, + $handle: String, + $sortKey: ProductCollectionSortKeys, + $reverse: Boolean, + $filters: [ProductFilter!], + $identifiers: [HasMetafieldsIdentifier!]!, + $languageCode: LanguageCode, + $countryCode: CountryCode + ) @inContext(language: $languageCode, country: $countryCode) { + collection(handle: $handle) { + id + handle + description + title + products( + first: $first, + last: $last, + after: $after, + before: $before, + sortKey: $sortKey, + reverse: $reverse, + filters: $filters + ) { + pageInfo { + hasNextPage + hasPreviousPage + endCursor + startCursor + } + filters { + ...Filter + } + nodes { + ...Product + } + } + } + } + `, }; export const ProductRecommendations = { fragments: [Product, ProductVariant, Collection], - query: - gql`query productRecommendations($productId: ID!, $identifiers: [HasMetafieldsIdentifier!]!) { - productRecommendations(productId: $productId) { - ...Product + query: gql` + query ProductRecommendations( + $productId: ID!, + $identifiers: [HasMetafieldsIdentifier!]!, + $languageCode: LanguageCode, + $countryCode: CountryCode + ) @inContext(language: $languageCode, country: $countryCode) { + productRecommendations(productId: $productId) { + ...Product + } } - }`, + `, }; export const GetShopInfo = { - query: gql`query GetShopInfo($identifiers: [HasMetafieldsIdentifier!]!) { - shop { - name - description - privacyPolicy { - title - body - } - refundPolicy { - title - body - } - shippingPolicy { - title - body - } - subscriptionPolicy { - title - body - } - termsOfService { - title - body - } - metafields(identifiers: $identifiers) { + query: gql` + query GetShopInfo( + $identifiers: [HasMetafieldsIdentifier!]!, + $languageCode: LanguageCode, + $countryCode: CountryCode + ) @inContext(language: $languageCode, country: $countryCode) { + shop { + name description - key - namespace - type - value - reference { - ... on MediaImage { - image { - url + privacyPolicy { + title + body + } + refundPolicy { + title + body + } + shippingPolicy { + title + body + } + subscriptionPolicy { + title + body + } + termsOfService { + title + body + } + metafields(identifiers: $identifiers) { + description + key + namespace + type + value + reference { + ... on MediaImage { + image { + url + } } } - } - references(first: 250) { - edges { - node { - ... on MediaImage { - image { - url + references(first: 250) { + edges { + node { + ... on MediaImage { + image { + url + } } } } @@ -443,88 +498,126 @@ export const GetShopInfo = { } } } - }`, + `, }; export const FetchCustomerInfo = { fragments: [Customer], - query: gql`query FetchCustomerInfo($customerAccessToken: String!) { - customer(customerAccessToken: $customerAccessToken) { - ...Customer + query: gql` + query FetchCustomerInfo($customerAccessToken: String!) { + customer(customerAccessToken: $customerAccessToken) { + ...Customer + } } - }`, + `, }; export const AddItemToCart = { fragments: [Cart], - query: gql`mutation AddItemToCart($cartId: ID!, $lines: [CartLineInput!]!) { - payload: cartLinesAdd(cartId: $cartId, lines: $lines) { - cart { ...Cart } + query: gql` + mutation AddItemToCart($cartId: ID!, $lines: [CartLineInput!]!) { + payload: cartLinesAdd(cartId: $cartId, lines: $lines) { + cart { + ...Cart + } + } } - }`, + `, }; export const RegisterAccount = { - query: gql`mutation RegisterAccount( + query: gql` + mutation RegisterAccount( $email: String!, $password: String!, $firstName: String, $lastName: String, $acceptsMarketing: Boolean = false ) { - customerCreate(input: { - email: $email, - password: $password, - firstName: $firstName, - lastName: $lastName, - acceptsMarketing: $acceptsMarketing, - }) { - customer { - id - } - customerUserErrors { - code - message + customerCreate( + input: { + email: $email, + password: $password, + firstName: $firstName, + lastName: $lastName, + acceptsMarketing: $acceptsMarketing + } + ) { + customer { + id + } + customerUserErrors { + code + message + } } } - }`, + `, }; export const AddCoupon = { fragments: [Cart], - query: gql`mutation AddCoupon($cartId: ID!, $discountCodes: [String!]!) { - payload: cartDiscountCodesUpdate(cartId: $cartId, discountCodes: $discountCodes) { - cart { ...Cart } - userErrors { - field - message + query: gql` + mutation AddCoupon($cartId: ID!, $discountCodes: [String!]!) { + payload: cartDiscountCodesUpdate(cartId: $cartId, discountCodes: $discountCodes) { + cart { + ...Cart + } + userErrors { + field + message + } } } - }`, + `, }; export const UpdateItems = { fragments: [Cart], - query: - gql`mutation UpdateItems($cartId: ID!, $lines: [CartLineUpdateInput!]!) { + query: gql` + mutation UpdateItems($cartId: ID!, $lines: [CartLineUpdateInput!]!) { payload: cartLinesUpdate(cartId: $cartId, lines: $lines) { - cart { ...Cart } + cart { + ...Cart + } + } + } + `, +}; + +export const CartBuyerIdentityUpdate = { + fragments: [Cart], + query: gql` + mutation CartBuyerIdentityUpdate( + $cartId: ID!, + $buyerIdentity: CartBuyerIdentityInput! + ) { + cartBuyerIdentityUpdate(cartId: $cartId, buyerIdentity: $buyerIdentity) { + cart { + ...Cart + } + userErrors { + field + message + } } - }`, + } + `, }; export const SignInWithEmailAndPassword = { - query: - gql`mutation SignInWithEmailAndPassword($email: String!, $password: String!) { - customerAccessTokenCreate(input: { email: $email, password: $password }) { - customerAccessToken { - accessToken - expiresAt - } - customerUserErrors { - code - message + query: gql` + mutation SignInWithEmailAndPassword($email: String!, $password: String!) { + customerAccessTokenCreate(input: { email: $email, password: $password }) { + customerAccessToken { + accessToken + expiresAt + } + customerUserErrors { + code + message + } } } - }`, + `, }; diff --git a/shopify/utils/storefront/storefront.graphql.gen.ts b/shopify/utils/storefront/storefront.graphql.gen.ts index 5e8bf5c02..03602f998 100644 --- a/shopify/utils/storefront/storefront.graphql.gen.ts +++ b/shopify/utils/storefront/storefront.graphql.gen.ts @@ -7733,7 +7733,7 @@ export type ProductFragment = { availableForSale: boolean, createdAt: any, descr export type FilterFragment = { id: string, label: string, type: FilterType, values: Array<{ count: number, id: string, input: any, label: string }> }; -export type CartFragment = { id: string, checkoutUrl: any, totalQuantity: number, lines: { nodes: Array< +export type CartFragment = { id: string, checkoutUrl: any, totalQuantity: number, buyerIdentity: { countryCode?: CountryCode | null, email?: string | null }, lines: { nodes: Array< | { id: string, quantity: number, merchandise: { id: string, title: string, image?: { url: any, altText?: string | null } | null, product: { title: string, onlineStoreUrl?: any | null, handle: string }, price: { amount: any, currencyCode: CurrencyCode } }, discountAllocations: Array< | { code: string, discountedAmount: { amount: any, currencyCode: CurrencyCode } } | Record @@ -7750,17 +7750,21 @@ export type CartFragment = { id: string, checkoutUrl: any, totalQuantity: number export type CustomerFragment = { id: string, email?: string | null, firstName?: string | null, lastName?: string | null }; -export type CreateCartMutationVariables = Exact<{ [key: string]: never; }>; +export type CreateCartMutationVariables = Exact<{ + countryCode?: InputMaybe; +}>; export type CreateCartMutation = { payload?: { cart?: { id: string } | null } | null }; export type GetCartQueryVariables = Exact<{ id: Scalars['ID']['input']; + languageCode?: InputMaybe; + countryCode?: InputMaybe; }>; -export type GetCartQuery = { cart?: { id: string, checkoutUrl: any, totalQuantity: number, lines: { nodes: Array< +export type GetCartQuery = { cart?: { id: string, checkoutUrl: any, totalQuantity: number, buyerIdentity: { countryCode?: CountryCode | null, email?: string | null }, lines: { nodes: Array< | { id: string, quantity: number, merchandise: { id: string, title: string, image?: { url: any, altText?: string | null } | null, product: { title: string, onlineStoreUrl?: any | null, handle: string }, price: { amount: any, currencyCode: CurrencyCode } }, discountAllocations: Array< | { code: string, discountedAmount: { amount: any, currencyCode: CurrencyCode } } | Record @@ -7778,6 +7782,8 @@ export type GetCartQuery = { cart?: { id: string, checkoutUrl: any, totalQuantit export type GetProductQueryVariables = Exact<{ handle?: InputMaybe; identifiers: Array | HasMetafieldsIdentifier; + languageCode?: InputMaybe; + countryCode?: InputMaybe; }>; @@ -7799,6 +7805,8 @@ export type ListProductsQueryVariables = Exact<{ after?: InputMaybe; query?: InputMaybe; identifiers: Array | HasMetafieldsIdentifier; + languageCode?: InputMaybe; + countryCode?: InputMaybe; }>; @@ -7825,6 +7833,8 @@ export type SearchWithFiltersQueryVariables = Exact<{ sortKey?: InputMaybe; reverse?: InputMaybe; identifiers: Array | HasMetafieldsIdentifier; + languageCode?: InputMaybe; + countryCode?: InputMaybe; }>; @@ -7854,10 +7864,12 @@ export type AllProductsQueryVariables = Exact<{ reverse?: InputMaybe; filters?: InputMaybe | ProductFilter>; identifiers: Array | HasMetafieldsIdentifier; + languageCode?: InputMaybe; + countryCode?: InputMaybe; }>; -export type AllProductsQuery = { collection?: { handle: string, description: string, title: string, products: { pageInfo: { hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null, startCursor?: string | null }, filters: Array<{ id: string, label: string, type: FilterType, values: Array<{ count: number, id: string, input: any, label: string }> }>, nodes: Array<{ availableForSale: boolean, createdAt: any, description: string, descriptionHtml: any, handle: string, id: string, isGiftCard: boolean, onlineStoreUrl?: any | null, productType: string, publishedAt: any, requiresSellingPlan: boolean, tags: Array, title: string, totalInventory?: number | null, updatedAt: any, vendor: string, featuredImage?: { altText?: string | null, url: any } | null, images: { nodes: Array<{ altText?: string | null, url: any }> }, media: { nodes: Array< +export type AllProductsQuery = { collection?: { id: string, handle: string, description: string, title: string, products: { pageInfo: { hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null, startCursor?: string | null }, filters: Array<{ id: string, label: string, type: FilterType, values: Array<{ count: number, id: string, input: any, label: string }> }>, nodes: Array<{ availableForSale: boolean, createdAt: any, description: string, descriptionHtml: any, handle: string, id: string, isGiftCard: boolean, onlineStoreUrl?: any | null, productType: string, publishedAt: any, requiresSellingPlan: boolean, tags: Array, title: string, totalInventory?: number | null, updatedAt: any, vendor: string, featuredImage?: { altText?: string | null, url: any } | null, images: { nodes: Array<{ altText?: string | null, url: any }> }, media: { nodes: Array< | { alt?: string | null, mediaContentType: MediaContentType, previewImage?: { altText?: string | null, url: any } | null } | { alt?: string | null, mediaContentType: MediaContentType, previewImage?: { altText?: string | null, url: any } | null } | { alt?: string | null, mediaContentType: MediaContentType, previewImage?: { altText?: string | null, url: any } | null } @@ -7873,6 +7885,8 @@ export type AllProductsQuery = { collection?: { handle: string, description: str export type ProductRecommendationsQueryVariables = Exact<{ productId: Scalars['ID']['input']; identifiers: Array | HasMetafieldsIdentifier; + languageCode?: InputMaybe; + countryCode?: InputMaybe; }>; @@ -7891,6 +7905,8 @@ export type ProductRecommendationsQuery = { productRecommendations?: Array<{ ava export type GetShopInfoQueryVariables = Exact<{ identifiers: Array | HasMetafieldsIdentifier; + languageCode?: InputMaybe; + countryCode?: InputMaybe; }>; @@ -7915,7 +7931,7 @@ export type AddItemToCartMutationVariables = Exact<{ }>; -export type AddItemToCartMutation = { payload?: { cart?: { id: string, checkoutUrl: any, totalQuantity: number, lines: { nodes: Array< +export type AddItemToCartMutation = { payload?: { cart?: { id: string, checkoutUrl: any, totalQuantity: number, buyerIdentity: { countryCode?: CountryCode | null, email?: string | null }, lines: { nodes: Array< | { id: string, quantity: number, merchandise: { id: string, title: string, image?: { url: any, altText?: string | null } | null, product: { title: string, onlineStoreUrl?: any | null, handle: string }, price: { amount: any, currencyCode: CurrencyCode } }, discountAllocations: Array< | { code: string, discountedAmount: { amount: any, currencyCode: CurrencyCode } } | Record @@ -7947,7 +7963,7 @@ export type AddCouponMutationVariables = Exact<{ }>; -export type AddCouponMutation = { payload?: { cart?: { id: string, checkoutUrl: any, totalQuantity: number, lines: { nodes: Array< +export type AddCouponMutation = { payload?: { cart?: { id: string, checkoutUrl: any, totalQuantity: number, buyerIdentity: { countryCode?: CountryCode | null, email?: string | null }, lines: { nodes: Array< | { id: string, quantity: number, merchandise: { id: string, title: string, image?: { url: any, altText?: string | null } | null, product: { title: string, onlineStoreUrl?: any | null, handle: string }, price: { amount: any, currencyCode: CurrencyCode } }, discountAllocations: Array< | { code: string, discountedAmount: { amount: any, currencyCode: CurrencyCode } } | Record @@ -7968,7 +7984,7 @@ export type UpdateItemsMutationVariables = Exact<{ }>; -export type UpdateItemsMutation = { payload?: { cart?: { id: string, checkoutUrl: any, totalQuantity: number, lines: { nodes: Array< +export type UpdateItemsMutation = { payload?: { cart?: { id: string, checkoutUrl: any, totalQuantity: number, buyerIdentity: { countryCode?: CountryCode | null, email?: string | null }, lines: { nodes: Array< | { id: string, quantity: number, merchandise: { id: string, title: string, image?: { url: any, altText?: string | null } | null, product: { title: string, onlineStoreUrl?: any | null, handle: string }, price: { amount: any, currencyCode: CurrencyCode } }, discountAllocations: Array< | { code: string, discountedAmount: { amount: any, currencyCode: CurrencyCode } } | Record @@ -7983,6 +7999,27 @@ export type UpdateItemsMutation = { payload?: { cart?: { id: string, checkoutUrl | { discountedAmount: { amount: any, currencyCode: CurrencyCode } } > } | null } | null }; +export type CartBuyerIdentityUpdateMutationVariables = Exact<{ + cartId: Scalars['ID']['input']; + buyerIdentity: CartBuyerIdentityInput; +}>; + + +export type CartBuyerIdentityUpdateMutation = { cartBuyerIdentityUpdate?: { cart?: { id: string, checkoutUrl: any, totalQuantity: number, buyerIdentity: { countryCode?: CountryCode | null, email?: string | null }, lines: { nodes: Array< + | { id: string, quantity: number, merchandise: { id: string, title: string, image?: { url: any, altText?: string | null } | null, product: { title: string, onlineStoreUrl?: any | null, handle: string }, price: { amount: any, currencyCode: CurrencyCode } }, discountAllocations: Array< + | { code: string, discountedAmount: { amount: any, currencyCode: CurrencyCode } } + | Record + >, cost: { totalAmount: { amount: any, currencyCode: CurrencyCode }, subtotalAmount: { amount: any, currencyCode: CurrencyCode }, amountPerQuantity: { amount: any, currencyCode: CurrencyCode }, compareAtAmountPerQuantity?: { amount: any, currencyCode: CurrencyCode } | null } } + | { id: string, quantity: number, merchandise: { id: string, title: string, image?: { url: any, altText?: string | null } | null, product: { title: string, onlineStoreUrl?: any | null, handle: string }, price: { amount: any, currencyCode: CurrencyCode } }, discountAllocations: Array< + | { code: string, discountedAmount: { amount: any, currencyCode: CurrencyCode } } + | Record + >, cost: { totalAmount: { amount: any, currencyCode: CurrencyCode }, subtotalAmount: { amount: any, currencyCode: CurrencyCode }, amountPerQuantity: { amount: any, currencyCode: CurrencyCode }, compareAtAmountPerQuantity?: { amount: any, currencyCode: CurrencyCode } | null } } + > }, cost: { totalTaxAmount?: { amount: any, currencyCode: CurrencyCode } | null, subtotalAmount: { amount: any, currencyCode: CurrencyCode }, totalAmount: { amount: any, currencyCode: CurrencyCode }, checkoutChargeAmount: { amount: any, currencyCode: CurrencyCode } }, discountCodes: Array<{ code: string, applicable: boolean }>, discountAllocations: Array< + | { discountedAmount: { amount: any, currencyCode: CurrencyCode } } + | { discountedAmount: { amount: any, currencyCode: CurrencyCode } } + | { discountedAmount: { amount: any, currencyCode: CurrencyCode } } + > } | null, userErrors: Array<{ field?: Array | null, message: string }> } | null }; + export type SignInWithEmailAndPasswordMutationVariables = Exact<{ email: Scalars['String']['input']; password: Scalars['String']['input']; diff --git a/shopify/utils/transform.ts b/shopify/utils/transform.ts index b5f080c52..ee945fec6 100644 --- a/shopify/utils/transform.ts +++ b/shopify/utils/transform.ts @@ -161,17 +161,27 @@ export const toProduct = ( .filter((metafield) => metafield && metafield.key && metafield.value) .map((metafield): PropertyValue => { const { key, value, reference, references } = metafield || {}; - const hasReferenceImage = reference && "image" in reference; - const referenceImageUrl = hasReferenceImage ? reference.image?.url : null; - const hasEdges = references?.edges && references.edges.length > 0; - const edgeImages = hasEdges - ? references.edges.map((edge) => - edge.node && "image" in edge.node ? edge.node.image?.url : null + const referenceImageUrl = + (reference as { image?: { url?: string } } | undefined)?.image?.url ?? + null; + + const edgeImages = Array.isArray(references?.edges) + ? references.edges.map( + (edge) => + (edge?.node as { image?: { url?: string } } | undefined)?.image + ?.url ?? null, ) : null; - const valueToReturn = referenceImageUrl || edgeImages || value; + const validEdgeImages = edgeImages?.filter((url) => !!url) as + | string[] + | undefined; + + const valueToReturn = referenceImageUrl ?? + (validEdgeImages && validEdgeImages.length > 0 + ? validEdgeImages.join(",") + : value); return { "@type": "PropertyValue", diff --git a/shopify/utils/types.ts b/shopify/utils/types.ts index cf02cad48..55d8d2424 100644 --- a/shopify/utils/types.ts +++ b/shopify/utils/types.ts @@ -1,10 +1,13 @@ import { - CountryCode, CurrencyCode, OrderCancelReason, OrderFinancialStatus, OrderFulfillmentStatus, } from "./enums.ts"; +import { + CountryCode, + LanguageCode, +} from "./storefront/storefront.graphql.gen.ts"; type Attribute = { key: string; @@ -189,3 +192,8 @@ export interface Metafield { namespace: string; key: string; } + +export interface LanguageContextArgs { + languageCode: LanguageCode; + countryCode: CountryCode; +}