diff --git a/packages/hydrogen-react/docs/generated/generated_docs_data.json b/packages/hydrogen-react/docs/generated/generated_docs_data.json index c893d17f91..bd8638a1a5 100644 --- a/packages/hydrogen-react/docs/generated/generated_docs_data.json +++ b/packages/hydrogen-react/docs/generated/generated_docs_data.json @@ -640,7 +640,7 @@ "filePath": "/CartProvider.tsx", "syntaxKind": "TypeAliasDeclaration", "name": "CartProviderProps", - "value": "{\n /** Any `ReactNode` elements. */\n children: React.ReactNode;\n /** Maximum number of cart lines to fetch. Defaults to 250 cart lines. */\n numCartLines?: number;\n /** A callback that is invoked when the process to create a cart begins, but before the cart is created in the Storefront API. */\n onCreate?: () => void;\n /** A callback that is invoked when the process to add a line item to the cart begins, but before the line item is added to the Storefront API. */\n onLineAdd?: () => void;\n /** A callback that is invoked when the process to remove a line item to the cart begins, but before the line item is removed from the Storefront API. */\n onLineRemove?: () => void;\n /** A callback that is invoked when the process to update a line item in the cart begins, but before the line item is updated in the Storefront API. */\n onLineUpdate?: () => void;\n /** A callback that is invoked when the process to add or update a note in the cart begins, but before the note is added or updated in the Storefront API. */\n onNoteUpdate?: () => void;\n /** A callback that is invoked when the process to update the buyer identity begins, but before the buyer identity is updated in the Storefront API. */\n onBuyerIdentityUpdate?: () => void;\n /** A callback that is invoked when the process to update the cart attributes begins, but before the attributes are updated in the Storefront API. */\n onAttributesUpdate?: () => void;\n /** A callback that is invoked when the process to update the cart discount codes begins, but before the discount codes are updated in the Storefront API. */\n onDiscountCodesUpdate?: () => void;\n /** A callback that is invoked when the process to create a cart completes */\n onCreateComplete?: () => void;\n /** A callback that is invoked when the process to add a line item to the cart completes */\n onLineAddComplete?: () => void;\n /** A callback that is invoked when the process to remove a line item to the cart completes */\n onLineRemoveComplete?: () => void;\n /** A callback that is invoked when the process to update a line item in the cart completes */\n onLineUpdateComplete?: () => void;\n /** A callback that is invoked when the process to add or update a note in the cart completes */\n onNoteUpdateComplete?: () => void;\n /** A callback that is invoked when the process to update the buyer identity completes */\n onBuyerIdentityUpdateComplete?: () => void;\n /** A callback that is invoked when the process to update the cart attributes completes */\n onAttributesUpdateComplete?: () => void;\n /** A callback that is invoked when the process to update the cart discount codes completes */\n onDiscountCodesUpdateComplete?: () => void;\n /** An object with fields that correspond to the Storefront API's [Cart object](https://shopify.dev/api/storefront/2023-07/objects/cart). */\n data?: PartialDeep;\n /** A fragment used to query the Storefront API's [Cart object](https://shopify.dev/api/storefront/2023-07/objects/cart) for all queries and mutations. A default value is used if no argument is provided. */\n cartFragment?: string;\n /** A customer access token that's accessible on the server if there's a customer login. */\n customerAccessToken?: CartBuyerIdentityInput['customerAccessToken'];\n /** The ISO country code for i18n. */\n countryCode?: CountryCode;\n}", + "value": "{\n /** Any `ReactNode` elements. */\n children: React.ReactNode;\n /** Maximum number of cart lines to fetch. Defaults to 250 cart lines. */\n numCartLines?: number;\n /** A callback that is invoked when the process to create a cart begins, but before the cart is created in the Storefront API. */\n onCreate?: () => void;\n /** A callback that is invoked when the process to add a line item to the cart begins, but before the line item is added to the Storefront API. */\n onLineAdd?: () => void;\n /** A callback that is invoked when the process to remove a line item to the cart begins, but before the line item is removed from the Storefront API. */\n onLineRemove?: () => void;\n /** A callback that is invoked when the process to update a line item in the cart begins, but before the line item is updated in the Storefront API. */\n onLineUpdate?: () => void;\n /** A callback that is invoked when the process to add or update a note in the cart begins, but before the note is added or updated in the Storefront API. */\n onNoteUpdate?: () => void;\n /** A callback that is invoked when the process to update the buyer identity begins, but before the buyer identity is updated in the Storefront API. */\n onBuyerIdentityUpdate?: () => void;\n /** A callback that is invoked when the process to update the cart attributes begins, but before the attributes are updated in the Storefront API. */\n onAttributesUpdate?: () => void;\n /** A callback that is invoked when the process to update the cart discount codes begins, but before the discount codes are updated in the Storefront API. */\n onDiscountCodesUpdate?: () => void;\n /** A callback that is invoked when the process to create a cart completes */\n onCreateComplete?: () => void;\n /** A callback that is invoked when the process to add a line item to the cart completes */\n onLineAddComplete?: () => void;\n /** A callback that is invoked when the process to remove a line item to the cart completes */\n onLineRemoveComplete?: () => void;\n /** A callback that is invoked when the process to update a line item in the cart completes */\n onLineUpdateComplete?: () => void;\n /** A callback that is invoked when the process to add or update a note in the cart completes */\n onNoteUpdateComplete?: () => void;\n /** A callback that is invoked when the process to update the buyer identity completes */\n onBuyerIdentityUpdateComplete?: () => void;\n /** A callback that is invoked when the process to update the cart attributes completes */\n onAttributesUpdateComplete?: () => void;\n /** A callback that is invoked when the process to update the cart discount codes completes */\n onDiscountCodesUpdateComplete?: () => void;\n /** An object with fields that correspond to the Storefront API's [Cart object](https://shopify.dev/api/storefront/2023-07/objects/cart). */\n data?: PartialDeep;\n /** A fragment used to query the Storefront API's [Cart object](https://shopify.dev/api/storefront/2023-07/objects/cart) for all queries and mutations. A default value is used if no argument is provided. */\n cartFragment?: string;\n /** A customer access token that's accessible on the server if there's a customer login. */\n customerAccessToken?: CartBuyerIdentityInput['customerAccessToken'];\n /** The ISO country code for i18n. */\n countryCode?: CountryCode;\n /** The ISO luanguage code for i18n. */\n languageCode?: LanguageCode;\n}", "description": "", "members": [ { @@ -817,6 +817,14 @@ "value": "CountryCode", "description": "The ISO country code for i18n.", "isOptional": true + }, + { + "filePath": "/CartProvider.tsx", + "syntaxKind": "PropertySignature", + "name": "languageCode", + "value": "LanguageCode", + "description": "The ISO luanguage code for i18n.", + "isOptional": true } ] } @@ -4477,12 +4485,12 @@ "tabs": [ { "title": "JavaScript", - "code": "import {ShopPayButton} from '@shopify/hydrogen-react';\n\nexport function AddVariantQuantity1({variantId, storeDomain}) {\n return <ShopPayButton variantIds={[variantId]} storeDomain={storeDomain} />;\n}\n\nexport function AddVariantQuantityMultiple({variantId, quantity, storeDomain}) {\n return (\n <ShopPayButton\n variantIdsAndQuantities={[{id: variantId, quantity}]}\n storeDomain={storeDomain}\n />\n );\n}\n", + "code": "import {ShopPayButton} from '@shopify/hydrogen-react';\n\nexport function AddVariantQuantity1({variantId, storeDomain}) {\n return <ShopPayButton variantIds={[variantId]} storeDomain={storeDomain} />;\n}\n\nexport function AddVariantQuantityMultiple({variantId, quantity, storeDomain}) {\n return (\n <ShopPayButton\n variantIdsAndQuantities={[{id: variantId, quantity}]}\n storeDomain={storeDomain}\n />\n );\n}\n\nexport function ChannelAttribution({channel, variantId, storeDomain}) {\n return (\n <ShopPayButton\n channel={channel}\n variantIds={[variantId]}\n storeDomain={storeDomain}\n />\n );\n}\n", "language": "jsx" }, { "title": "TypeScript", - "code": "import {ShopPayButton} from '@shopify/hydrogen-react';\n\nexport function AddVariantQuantity1({\n variantId,\n storeDomain,\n}: {\n variantId: string;\n storeDomain: string;\n}) {\n return <ShopPayButton variantIds={[variantId]} storeDomain={storeDomain} />;\n}\n\nexport function AddVariantQuantityMultiple({\n variantId,\n quantity,\n storeDomain,\n}: {\n variantId: string;\n quantity: number;\n storeDomain: string;\n}) {\n return (\n <ShopPayButton\n variantIdsAndQuantities={[{id: variantId, quantity}]}\n storeDomain={storeDomain}\n />\n );\n}\n", + "code": "import {ShopPayButton} from '@shopify/hydrogen-react';\n\nexport function AddVariantQuantity1({\n variantId,\n storeDomain,\n}: {\n variantId: string;\n storeDomain: string;\n}) {\n return <ShopPayButton variantIds={[variantId]} storeDomain={storeDomain} />;\n}\n\nexport function AddVariantQuantityMultiple({\n variantId,\n quantity,\n storeDomain,\n}: {\n variantId: string;\n quantity: number;\n storeDomain: string;\n}) {\n return (\n <ShopPayButton\n variantIdsAndQuantities={[{id: variantId, quantity}]}\n storeDomain={storeDomain}\n />\n );\n}\n\nexport function ChannelAttribution({\n channel,\n variantId,\n storeDomain,\n}: {\n channel: 'headless' | 'hydrogen';\n variantId: string;\n storeDomain: string;\n}) {\n return (\n <ShopPayButton\n channel={channel}\n variantIds={[variantId]}\n storeDomain={storeDomain}\n />\n );\n}\n", "language": "tsx" } ], @@ -4522,7 +4530,7 @@ "filePath": "/ShopPayButton.tsx", "syntaxKind": "TypeAliasDeclaration", "name": "ShopPayButtonProps", - "value": "ShopPayButtonStyleProps & ShopPayDomainProps & (ShopPayVariantIds | ShopPayVariantAndQuantities)", + "value": "ShopPayButtonStyleProps & ShopPayDomainProps & ShopPayChannelAttribution & (ShopPayVariantIds | ShopPayVariantAndQuantities)", "description": "" }, "ShopPayButtonStyleProps": { @@ -4567,6 +4575,30 @@ } ] }, + "ShopPayChannelAttribution": { + "filePath": "/ShopPayButton.tsx", + "syntaxKind": "TypeAliasDeclaration", + "name": "ShopPayChannelAttribution", + "value": "{\n /** A string that adds channel attribution to the order. Can be either `headless` or `hydrogen` */\n channel?: Channel;\n}", + "description": "", + "members": [ + { + "filePath": "/ShopPayButton.tsx", + "syntaxKind": "PropertySignature", + "name": "channel", + "value": "Channel", + "description": "A string that adds channel attribution to the order. Can be either `headless` or `hydrogen`", + "isOptional": true + } + ] + }, + "Channel": { + "filePath": "/ShopPayButton.tsx", + "syntaxKind": "TypeAliasDeclaration", + "name": "Channel", + "value": "'headless' | 'hydrogen'", + "description": "The valid values for `channel` at checkout for custom storefront channel attribution can be one of either `custom-storefronts` or `headless-storefronts`.\n\nTo prevent from confusion, the values exposed on the `ShopPayButton` are simplified to match the channel display name rather than the registered channel identifier." + }, "ShopPayVariantIds": { "filePath": "/ShopPayButton.tsx", "syntaxKind": "TypeAliasDeclaration", @@ -5999,14 +6031,14 @@ { "filePath": "/flatten-connection.ts", "syntaxKind": "MethodSignature", - "name": "__@iterator@441", + "name": "__@iterator@414", "value": "() => IterableIterator", "description": "Iterator" }, { "filePath": "/flatten-connection.ts", "syntaxKind": "MethodSignature", - "name": "__@unscopables@443", + "name": "__@unscopables@416", "value": "() => { copyWithin: boolean; entries: boolean; fill: boolean; find: boolean; findIndex: boolean; keys: boolean; values: boolean; }", "description": "Returns an object whose properties have the value 'true'\r\nwhen they will be absent when used in a 'with' statement." }, @@ -6313,8 +6345,41 @@ "filePath": "/load-script.tsx", "syntaxKind": "TypeAliasDeclaration", "name": "LoadScriptParams", - "value": "[src: string, options?: { module?: boolean; in?: \"body\" | \"head\"; }]", + "value": "[src: string, options?: LoadScriptOptions]", "description": "" + }, + "LoadScriptOptions": { + "filePath": "/load-script.tsx", + "syntaxKind": "TypeAliasDeclaration", + "name": "LoadScriptOptions", + "value": "{\n module?: boolean;\n in?: 'head' | 'body';\n attributes?: Record;\n}", + "description": "", + "members": [ + { + "filePath": "/load-script.tsx", + "syntaxKind": "PropertySignature", + "name": "module", + "value": "boolean", + "description": "", + "isOptional": true + }, + { + "filePath": "/load-script.tsx", + "syntaxKind": "PropertySignature", + "name": "in", + "value": "\"body\" | \"head\"", + "description": "", + "isOptional": true + }, + { + "filePath": "/load-script.tsx", + "syntaxKind": "PropertySignature", + "name": "attributes", + "value": "Record", + "description": "", + "isOptional": true + } + ] } } } diff --git a/packages/hydrogen-react/src/ShopPayButton.example.jsx b/packages/hydrogen-react/src/ShopPayButton.example.jsx index 732cd80106..68b0e78cff 100644 --- a/packages/hydrogen-react/src/ShopPayButton.example.jsx +++ b/packages/hydrogen-react/src/ShopPayButton.example.jsx @@ -12,3 +12,13 @@ export function AddVariantQuantityMultiple({variantId, quantity, storeDomain}) { /> ); } + +export function ChannelAttribution({channel, variantId, storeDomain}) { + return ( + + ); +} diff --git a/packages/hydrogen-react/src/ShopPayButton.example.tsx b/packages/hydrogen-react/src/ShopPayButton.example.tsx index 492bc96df1..61635770fd 100644 --- a/packages/hydrogen-react/src/ShopPayButton.example.tsx +++ b/packages/hydrogen-react/src/ShopPayButton.example.tsx @@ -26,3 +26,21 @@ export function AddVariantQuantityMultiple({ /> ); } + +export function ChannelAttribution({ + channel, + variantId, + storeDomain, +}: { + channel: 'headless' | 'hydrogen'; + variantId: string; + storeDomain: string; +}) { + return ( + + ); +} diff --git a/packages/hydrogen-react/src/ShopPayButton.stories.tsx b/packages/hydrogen-react/src/ShopPayButton.stories.tsx index 672bea225d..8892da4cfa 100644 --- a/packages/hydrogen-react/src/ShopPayButton.stories.tsx +++ b/packages/hydrogen-react/src/ShopPayButton.stories.tsx @@ -26,3 +26,14 @@ Quantities.args = { className: '', width: '', }; + +export const ChannelAttribution = Template.bind({}); +ChannelAttribution.args = { + channel: 'hydrogen', + variantIdsAndQuantities: [ + {id: 'gid://shopify/ProductVariant/123', quantity: 2}, + ], + storeDomain: 'https://notashop.myshopify.io', + className: '', + width: '', +}; diff --git a/packages/hydrogen-react/src/ShopPayButton.test.tsx b/packages/hydrogen-react/src/ShopPayButton.test.tsx index f14ddadc78..d34bb5a7c5 100644 --- a/packages/hydrogen-react/src/ShopPayButton.test.tsx +++ b/packages/hydrogen-react/src/ShopPayButton.test.tsx @@ -1,12 +1,13 @@ import {vi, describe, expect, it} from 'vitest'; -import {render} from '@testing-library/react'; +import {render, screen} from '@testing-library/react'; import {ShopifyProvider} from './ShopifyProvider.js'; import { ShopPayButton, DoublePropsErrorMessage, MissingPropsErrorMessage, InvalidPropsErrorMessage, + InvalidChannelErrorMessage, MissingStoreDomainErrorMessage, } from './ShopPayButton.js'; import {getShopifyConfig} from './ShopifyProvider.test.js'; @@ -165,4 +166,45 @@ describe(``, () => { 'https://notashop.myshopify.com', ); }); + + it(`throws an error if you pass an invalid channel value`, () => { + expect(() => + render( + , + { + wrapper: ({children}) => ( + + {children} + + ), + }, + ), + ).toThrow(InvalidChannelErrorMessage); + }); + + it(`creates the correct attribute when using 'channel'`, () => { + const {container} = render( + , + { + wrapper: ({children}) => ( + {children} + ), + }, + ); + + const button = container.querySelector('shop-pay-button'); + + expect(button).toHaveAttribute( + 'store-url', + 'https://notashop.myshopify.io', + ); + expect(button).toHaveAttribute('channel', 'custom-storefronts'); + }); }); diff --git a/packages/hydrogen-react/src/ShopPayButton.tsx b/packages/hydrogen-react/src/ShopPayButton.tsx index da7142e9e2..de0f5bebc7 100644 --- a/packages/hydrogen-react/src/ShopPayButton.tsx +++ b/packages/hydrogen-react/src/ShopPayButton.tsx @@ -5,6 +5,7 @@ import {parseGid} from './analytics-utils.js'; // By using 'never' in the "or" cases below, it makes these props "exclusive" and means that you cannot pass both of them; you must pass either one OR the other. type ShopPayButtonProps = ShopPayButtonStyleProps & ShopPayDomainProps & + ShopPayChannelAttribution & (ShopPayVariantIds | ShopPayVariantAndQuantities); type ShopPayButtonStyleProps = { @@ -36,11 +37,26 @@ type ShopPayVariantAndQuantities = { }>; }; +/** + * The valid values for `channel` at checkout for custom storefront channel attribution can be + * one of either `custom-storefronts` or `headless-storefronts`. + * + * To prevent from confusion, the values exposed on the `ShopPayButton` are simplified to match + * the channel display name rather than the registered channel identifier. + */ +type Channel = 'headless' | 'hydrogen'; + +type ShopPayChannelAttribution = { + /** A string that adds channel attribution to the order. Can be either `headless` or `hydrogen` */ + channel?: Channel; +}; + declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace JSX { interface IntrinsicElements { 'shop-pay-button': { + channel?: string; variants: string; 'store-url': string; }; @@ -51,12 +67,22 @@ declare global { const SHOPJS_URL = 'https://cdn.shopify.com/shopifycloud/shop-js/v1.0/client.js'; +const CHANNEL_MAP: Record = { + headless: 'headless-storefronts', + hydrogen: 'custom-storefronts', +}; + +function isChannel(channel: string): channel is Channel { + return Object.keys(CHANNEL_MAP).includes(channel); +} + /** * The `ShopPayButton` component renders a button that redirects to the Shop Pay checkout. * It renders a [``](https://shopify.dev/custom-storefronts/tools/web-components) custom element, for which it will lazy-load the source code automatically. * It relies on the `` context provider. */ export function ShopPayButton({ + channel, variantIds, className, variantIdsAndQuantities, @@ -68,6 +94,7 @@ export function ShopPayButton({ const shopPayLoadedStatus = useLoadScript(SHOPJS_URL); let ids: string[] = []; + let channelAttribution: string | undefined; if (!storeDomain || storeDomain === defaultShopifyContext.storeDomain) { throw new Error(MissingStoreDomainErrorMessage); @@ -81,6 +108,14 @@ export function ShopPayButton({ throw new Error(MissingPropsErrorMessage); } + if (channel) { + if (isChannel(channel)) { + channelAttribution = CHANNEL_MAP[channel]; + } else { + throw new Error(InvalidChannelErrorMessage); + } + } + if (variantIds) { ids = variantIds.reduce((prev, curr) => { const bareId = parseGid(curr).id; @@ -114,7 +149,11 @@ export function ShopPayButton({ return (
{shopPayLoadedStatus === 'done' && ( - + )}
); @@ -125,3 +164,4 @@ export const MissingStoreDomainErrorMessage = export const InvalidPropsErrorMessage = `You must pass in "variantIds" in the form of ["gid://shopify/ProductVariant/1"]`; export const MissingPropsErrorMessage = `You must pass in either "variantIds" or "variantIdsAndQuantities" to ShopPayButton`; export const DoublePropsErrorMessage = `You must provide either a variantIds or variantIdsAndQuantities prop, but not both in the ShopPayButton component`; +export const InvalidChannelErrorMessage = `Invalid channel attribution value. Must be either "headless" or "hydrogen"`;