From e8cc49feff18f5ee72d5f6965ff2094addc23466 Mon Sep 17 00:00:00 2001 From: Quinton Chester Date: Mon, 6 Nov 2023 14:29:08 -0600 Subject: [PATCH] Adds optional channel prop to ShopPayButton (#1447) --- .changeset/small-zoos-grab.md | 5 ++ .changeset/witty-lamps-fly.md | 5 ++ .../docs/generated/generated_docs_data.json | 59 ++++++++++++------- .../src/ShopPayButton.example.jsx | 10 ++++ .../src/ShopPayButton.example.tsx | 18 ++++++ .../src/ShopPayButton.stories.tsx | 11 ++++ .../hydrogen-react/src/ShopPayButton.test.tsx | 43 ++++++++++++++ packages/hydrogen-react/src/ShopPayButton.tsx | 30 +++++++++- packages/hydrogen/src/index.ts | 3 +- packages/hydrogen/src/shop/ShopPayButton.tsx | 6 ++ 10 files changed, 167 insertions(+), 23 deletions(-) create mode 100644 .changeset/small-zoos-grab.md create mode 100644 .changeset/witty-lamps-fly.md create mode 100644 packages/hydrogen/src/shop/ShopPayButton.tsx diff --git a/.changeset/small-zoos-grab.md b/.changeset/small-zoos-grab.md new file mode 100644 index 0000000000..e6353730b3 --- /dev/null +++ b/.changeset/small-zoos-grab.md @@ -0,0 +1,5 @@ +--- +'@shopify/hydrogen-react': patch +--- + +This change adds an optional prop to the `ShopPayButton` that adds order attribution support for either the Headless or Hydrogen sales channel. diff --git a/.changeset/witty-lamps-fly.md b/.changeset/witty-lamps-fly.md new file mode 100644 index 0000000000..bf45c9c2ec --- /dev/null +++ b/.changeset/witty-lamps-fly.md @@ -0,0 +1,5 @@ +--- +'@shopify/hydrogen': patch +--- + +Adds default channel value of "hydrogen" to the ShopPayButton component exported out of the @shopify/hydrogen package. diff --git a/packages/hydrogen-react/docs/generated/generated_docs_data.json b/packages/hydrogen-react/docs/generated/generated_docs_data.json index 0a25f0f62b..cee8128a6c 100644 --- a/packages/hydrogen-react/docs/generated/generated_docs_data.json +++ b/packages/hydrogen-react/docs/generated/generated_docs_data.json @@ -2019,7 +2019,7 @@ "filePath": "/MediaFile.tsx", "syntaxKind": "PropertySignature", "name": "aria-current", - "value": "boolean | \"time\" | \"step\" | \"page\" | \"date\" | \"true\" | \"false\" | \"location\"", + "value": "boolean | \"time\" | \"step\" | \"date\" | \"true\" | \"false\" | \"page\" | \"location\"", "description": "Indicates the element that represents the current item within a container or set of related elements.", "isOptional": true }, @@ -4367,12 +4367,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" } ], @@ -4412,7 +4412,7 @@ "filePath": "/ShopPayButton.tsx", "syntaxKind": "TypeAliasDeclaration", "name": "ShopPayButtonProps", - "value": "ShopPayButtonStyleProps & ShopPayDomainProps & (ShopPayVariantIds | ShopPayVariantAndQuantities)", + "value": "ShopPayButtonStyleProps & ShopPayDomainProps & ShopPayChannelAttribution & (ShopPayVariantIds | ShopPayVariantAndQuantities)", "description": "" }, "ShopPayButtonStyleProps": { @@ -4457,6 +4457,23 @@ } ] }, + "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?: 'headless' | 'hydrogen';\n}", + "description": "", + "members": [ + { + "filePath": "/ShopPayButton.tsx", + "syntaxKind": "PropertySignature", + "name": "channel", + "value": "\"headless\" | \"hydrogen\"", + "description": "A string that adds channel attribution to the order. Can be either `headless` or `hydrogen`", + "isOptional": true + } + ] + }, "ShopPayVariantIds": { "filePath": "/ShopPayButton.tsx", "syntaxKind": "TypeAliasDeclaration", @@ -5688,7 +5705,7 @@ "syntaxKind": "MethodSignature", "name": "pop", "value": "() => unknown", - "description": "Removes the last element from an array and returns it. If the array is empty, undefined is returned and the array is not modified." + "description": "Removes the last element from an array and returns it.\r\nIf the array is empty, undefined is returned and the array is not modified." }, { "filePath": "/flatten-connection.ts", @@ -5702,7 +5719,7 @@ "syntaxKind": "MethodSignature", "name": "concat", "value": "{ (...items: ConcatArray[]): unknown[]; (...items: unknown[]): unknown[]; }", - "description": "Combines two or more arrays. This method returns a new array without modifying any existing arrays." + "description": "Combines two or more arrays.\r\nThis method returns a new array without modifying any existing arrays." }, { "filePath": "/flatten-connection.ts", @@ -5716,28 +5733,28 @@ "syntaxKind": "MethodSignature", "name": "reverse", "value": "() => unknown[]", - "description": "Reverses the elements in an array in place. This method mutates the array and returns a reference to the same array." + "description": "Reverses the elements in an array in place.\r\nThis method mutates the array and returns a reference to the same array." }, { "filePath": "/flatten-connection.ts", "syntaxKind": "MethodSignature", "name": "shift", "value": "() => unknown", - "description": "Removes the first element from an array and returns it. If the array is empty, undefined is returned and the array is not modified." + "description": "Removes the first element from an array and returns it.\r\nIf the array is empty, undefined is returned and the array is not modified." }, { "filePath": "/flatten-connection.ts", "syntaxKind": "MethodSignature", "name": "slice", "value": "(start?: number, end?: number) => unknown[]", - "description": "Returns a copy of a section of an array. For both start and end, a negative index can be used to indicate an offset from the end of the array. For example, -2 refers to the second to last element of the array." + "description": "Returns a copy of a section of an array.\r\nFor both start and end, a negative index can be used to indicate an offset from the end of the array.\r\nFor example, -2 refers to the second to last element of the array." }, { "filePath": "/flatten-connection.ts", "syntaxKind": "MethodSignature", "name": "sort", "value": "(compareFn?: (a: unknown, b: unknown) => number) => FlattenConnectionReturnForDoc", - "description": "Sorts an array in place. This method mutates the array and returns a reference to the same array." + "description": "Sorts an array in place.\r\nThis method mutates the array and returns a reference to the same array." }, { "filePath": "/flatten-connection.ts", @@ -5820,15 +5837,15 @@ "filePath": "/flatten-connection.ts", "syntaxKind": "MethodSignature", "name": "find", - "value": "{ (predicate: (value: unknown, index: number, obj: unknown[]) => value is S, thisArg?: any): S; (predicate: (value: unknown, index: number, obj: unknown[]) => unknown, thisArg?: any): unknown; }", - "description": "Returns the value of the first element in the array where predicate is true, and undefined otherwise." + "value": "{ (predicate: (this: void, value: unknown, index: number, obj: unknown[]) => value is S, thisArg?: any): S; (predicate: (value: unknown, index: number, obj: unknown[]) => unknown, thisArg?: any): unknown; }", + "description": "Returns the value of the first element in the array where predicate is true, and undefined\r\notherwise." }, { "filePath": "/flatten-connection.ts", "syntaxKind": "MethodSignature", "name": "findIndex", "value": "(predicate: (value: unknown, index: number, obj: unknown[]) => unknown, thisArg?: any) => number", - "description": "Returns the index of the first element in the array where predicate is true, and -1 otherwise." + "description": "Returns the index of the first element in the array where predicate is true, and -1\r\notherwise." }, { "filePath": "/flatten-connection.ts", @@ -5842,7 +5859,7 @@ "syntaxKind": "MethodSignature", "name": "copyWithin", "value": "(target: number, start: number, end?: number) => FlattenConnectionReturnForDoc", - "description": "Returns the this object after copying a section of the array identified by start and end to the same array starting at position target" + "description": "Returns the this object after copying a section of the array identified by start and end\r\nto the same array starting at position target" }, { "filePath": "/flatten-connection.ts", @@ -5877,28 +5894,28 @@ "syntaxKind": "MethodSignature", "name": "flatMap", "value": "(callback: (this: This, value: unknown, index: number, array: unknown[]) => U | readonly U[], thisArg?: This) => U[]", - "description": "Calls a defined callback function on each element of an array. Then, flattens the result into a new array. This is identical to a map followed by flat with depth 1." + "description": "Calls a defined callback function on each element of an array. Then, flattens the result into\r\na new array.\r\nThis is identical to a map followed by flat with depth 1." }, { "filePath": "/flatten-connection.ts", "syntaxKind": "MethodSignature", "name": "flat", "value": "(this: A, depth?: D) => FlatArray[]", - "description": "Returns a new array with all sub-array elements concatenated into it recursively up to the specified depth." + "description": "Returns a new array with all sub-array elements concatenated into it recursively up to the\r\nspecified depth." }, { "filePath": "/flatten-connection.ts", "syntaxKind": "MethodSignature", - "name": "__@iterator@448", + "name": "__@iterator@441", "value": "() => IterableIterator", "description": "Iterator" }, { "filePath": "/flatten-connection.ts", - "syntaxKind": "PropertySignature", - "name": "__@unscopables@450", - "value": "{ [x: number]: boolean; length?: boolean; toString?: boolean; toLocaleString?: boolean; pop?: boolean; push?: boolean; concat?: boolean; join?: boolean; reverse?: boolean; shift?: boolean; slice?: boolean; sort?: boolean; splice?: boolean; unshift?: boolean; indexOf?: boolean; lastIndexOf?: boolean; every?: boolean; some?: boolean; forEach?: boolean; map?: boolean; filter?: boolean; reduce?: boolean; reduceRight?: boolean; find?: boolean; findIndex?: boolean; fill?: boolean; copyWithin?: boolean; entries?: boolean; keys?: boolean; values?: boolean; includes?: boolean; flatMap?: boolean; flat?: boolean; [Symbol.iterator]?: boolean; readonly [Symbol.unscopables]?: boolean; at?: boolean; }", - "description": "Is an object whose properties have the value 'true' when they will be absent when used in a 'with' statement." + "syntaxKind": "MethodSignature", + "name": "__@unscopables@443", + "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." }, { "filePath": "/flatten-connection.ts", 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..2e50cc0e24 100644 --- a/packages/hydrogen-react/src/ShopPayButton.test.tsx +++ b/packages/hydrogen-react/src/ShopPayButton.test.tsx @@ -7,6 +7,7 @@ import { DoublePropsErrorMessage, MissingPropsErrorMessage, InvalidPropsErrorMessage, + InvalidChannelErrorMessage, MissingStoreDomainErrorMessage, } from './ShopPayButton.js'; import {getShopifyConfig} from './ShopifyProvider.test.js'; @@ -165,4 +166,46 @@ 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 channel = 'hydrogen'; + 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', channel); + }); }); diff --git a/packages/hydrogen-react/src/ShopPayButton.tsx b/packages/hydrogen-react/src/ShopPayButton.tsx index da7142e9e2..8c10bca8d5 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,17 @@ type ShopPayVariantAndQuantities = { }>; }; +type ShopPayChannelAttribution = { + /** A string that adds channel attribution to the order. Can be either `headless` or `hydrogen` */ + channel?: 'headless' | 'hydrogen'; +}; + 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 +58,19 @@ declare global { const SHOPJS_URL = 'https://cdn.shopify.com/shopifycloud/shop-js/v1.0/client.js'; +function isChannel( + channel: string, +): channel is Exclude { + return channel === 'headless' || channel === 'hydrogen'; +} + /** * 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 +82,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 +96,14 @@ export function ShopPayButton({ throw new Error(MissingPropsErrorMessage); } + if (channel) { + if (isChannel(channel)) { + channelAttribution = channel; + } else { + throw new Error(InvalidChannelErrorMessage); + } + } + if (variantIds) { ids = variantIds.reduce((prev, curr) => { const bareId = parseGid(curr).id; @@ -114,7 +137,11 @@ export function ShopPayButton({ return (
{shopPayLoadedStatus === 'done' && ( - + )}
); @@ -125,3 +152,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"`; diff --git a/packages/hydrogen/src/index.ts b/packages/hydrogen/src/index.ts index cd59e8ec4a..7c5b68fb65 100644 --- a/packages/hydrogen/src/index.ts +++ b/packages/hydrogen/src/index.ts @@ -63,6 +63,8 @@ export { OptimisticInput, } from './optimistic-ui/optimistic-ui'; +export {ShopPayButton} from './shop/ShopPayButton'; + export { AnalyticsEventName, AnalyticsPageType, @@ -79,7 +81,6 @@ export { parseMetafield, sendShopifyAnalytics, ShopifySalesChannel, - ShopPayButton, storefrontApiCustomScalars, useLoadScript, useMoney, diff --git a/packages/hydrogen/src/shop/ShopPayButton.tsx b/packages/hydrogen/src/shop/ShopPayButton.tsx new file mode 100644 index 0000000000..2bb6d31335 --- /dev/null +++ b/packages/hydrogen/src/shop/ShopPayButton.tsx @@ -0,0 +1,6 @@ +import {ShopPayButton as ShopPayButtonBase} from '@shopify/hydrogen-react'; +import {ComponentProps} from 'react'; + +export function ShopPayButton(props: ComponentProps) { + return ; +}