From 9c44be423779cffcb650f62cd6d284c83b42ba54 Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Wed, 17 Jan 2024 10:17:55 +0900 Subject: [PATCH 1/4] Fix variant redirect with locale pathnames in demo-store (#1641) --- .../app/routes/($locale).products.$productHandle.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/templates/demo-store/app/routes/($locale).products.$productHandle.tsx b/templates/demo-store/app/routes/($locale).products.$productHandle.tsx index 12e96e63b3..bcf18cdc4a 100644 --- a/templates/demo-store/app/routes/($locale).products.$productHandle.tsx +++ b/templates/demo-store/app/routes/($locale).products.$productHandle.tsx @@ -120,16 +120,17 @@ function redirectToFirstVariant({ product: ProductQuery['product']; request: Request; }) { - const searchParams = new URLSearchParams(new URL(request.url).search); + const url = new URL(request.url); + const searchParams = new URLSearchParams(url.search); + const firstVariant = product!.variants.nodes[0]; for (const option of firstVariant.selectedOptions) { searchParams.set(option.name, option.value); } - return redirect( - `/products/${product!.handle}?${searchParams.toString()}`, - 302, - ); + url.search = searchParams.toString(); + + return redirect(url.href.replace(url.origin, ''), 302); } export default function Product() { From 3e7b6e8a3bf66bad7fc0f9c224f1c163dbe3e288 Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Wed, 17 Jan 2024 12:03:24 +0900 Subject: [PATCH 2/4] Remove deprecated flags (#1640) * Remove deprecated flags * Changesets --- .changeset/quick-apes-pump.md | 5 ++ packages/cli/oclif.manifest.json | 51 +++---------------- packages/cli/src/commands/hydrogen/build.ts | 6 +-- packages/cli/src/commands/hydrogen/codegen.ts | 3 -- packages/cli/src/commands/hydrogen/dev.ts | 2 - packages/cli/src/lib/flags.ts | 2 - packages/cli/src/lib/transpile/project.ts | 2 +- 7 files changed, 14 insertions(+), 57 deletions(-) create mode 100644 .changeset/quick-apes-pump.md diff --git a/.changeset/quick-apes-pump.md b/.changeset/quick-apes-pump.md new file mode 100644 index 0000000000..564c088b60 --- /dev/null +++ b/.changeset/quick-apes-pump.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-hydrogen': major +--- + +Remove deprecated CLI flags. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index ff2b9dfee3..af79814f12 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -45,10 +45,7 @@ "type": "boolean", "description": "Generate types for the Storefront API queries found in your project.", "required": false, - "allowNo": false, - "aliases": [ - "codegen-unstable" - ] + "allowNo": false }, "codegen-config-path": { "name": "codegen-config-path", @@ -66,24 +63,6 @@ "description": "Applies the current files on top of Hydrogen's starter template in a temporary directory.", "required": false, "allowNo": false - }, - "base": { - "name": "base", - "type": "option", - "hidden": true, - "multiple": false - }, - "entry": { - "name": "entry", - "type": "option", - "hidden": true, - "multiple": false - }, - "target": { - "name": "target", - "type": "option", - "hidden": true, - "multiple": false } }, "args": {} @@ -122,41 +101,34 @@ "pluginName": "@shopify/cli-hydrogen", "pluginAlias": "@shopify/cli-hydrogen", "pluginType": "core", - "aliases": [ - "codegen-unstable" - ], - "deprecateAliases": true, + "aliases": [], "flags": { "path": { "name": "path", "type": "option", "description": "The path to the directory of the Hydrogen storefront. The default is the current directory.", - "multiple": false, - "deprecateAliases": true + "multiple": false }, "codegen-config-path": { "name": "codegen-config-path", "type": "option", "description": "Specify a path to a codegen configuration file. Defaults to `/codegen.ts` if it exists.", "required": false, - "multiple": false, - "deprecateAliases": true + "multiple": false }, "force-sfapi-version": { "name": "force-sfapi-version", "type": "option", "description": "Force generating Storefront API types for a specific version instead of using the one provided in Hydrogen. A token can also be provided with this format: `:`.", "hidden": true, - "multiple": false, - "deprecateAliases": true + "multiple": false }, "watch": { "name": "watch", "type": "boolean", "description": "Watch the project for changes to update types on file save.", "required": false, - "allowNo": false, - "deprecateAliases": true + "allowNo": false } }, "args": {} @@ -292,10 +264,7 @@ "type": "boolean", "description": "Generate types for the Storefront API queries found in your project. It updates the types on file save.", "required": false, - "allowNo": false, - "aliases": [ - "codegen-unstable" - ] + "allowNo": false }, "codegen-config-path": { "name": "codegen-config-path", @@ -332,12 +301,6 @@ "multiple": false, "default": 9229 }, - "host": { - "name": "host", - "type": "option", - "hidden": true, - "multiple": false - }, "env-branch": { "name": "env-branch", "type": "option", diff --git a/packages/cli/src/commands/hydrogen/build.ts b/packages/cli/src/commands/hydrogen/build.ts index aaf9b7ac12..6fb2060d97 100644 --- a/packages/cli/src/commands/hydrogen/build.ts +++ b/packages/cli/src/commands/hydrogen/build.ts @@ -26,7 +26,7 @@ import { RemixConfig, type ServerMode, } from '../../lib/remix-config.js'; -import {deprecated, commonFlags, flagsToCamelObject} from '../../lib/flags.js'; +import {commonFlags, flagsToCamelObject} from '../../lib/flags.js'; import {checkLockfileStatus} from '../../lib/check-lockfile.js'; import {findMissingRoutes} from '../../lib/missing-routes.js'; import {createRemixLogger, muteRemixLogs} from '../../lib/log.js'; @@ -71,10 +71,6 @@ export default class Build extends Command { codegen: commonFlags.codegen, 'codegen-config-path': commonFlags.codegenConfigPath, diff: commonFlags.diff, - - base: deprecated('--base')(), - entry: deprecated('--entry')(), - target: deprecated('--target')(), }; async run(): Promise { diff --git a/packages/cli/src/commands/hydrogen/codegen.ts b/packages/cli/src/commands/hydrogen/codegen.ts index dc1df4fbcb..caee1d72bc 100644 --- a/packages/cli/src/commands/hydrogen/codegen.ts +++ b/packages/cli/src/commands/hydrogen/codegen.ts @@ -30,9 +30,6 @@ export default class Codegen extends Command { }), }; - static aliases = ['codegen-unstable']; - static deprecateAliases = true; - async run(): Promise { const {flags} = await this.parse(Codegen); const directory = flags.path ? path.resolve(flags.path) : process.cwd(); diff --git a/packages/cli/src/commands/hydrogen/dev.ts b/packages/cli/src/commands/hydrogen/dev.ts index 61cda6d1f3..fe7cb8eb1d 100644 --- a/packages/cli/src/commands/hydrogen/dev.ts +++ b/packages/cli/src/commands/hydrogen/dev.ts @@ -14,7 +14,6 @@ import { } from '../../lib/remix-config.js'; import {createRemixLogger, enhanceH2Logs, muteDevLogs} from '../../lib/log.js'; import { - deprecated, commonFlags, flagsToCamelObject, overrideFlag, @@ -62,7 +61,6 @@ export default class Dev extends Command { }), debug: commonFlags.debug, 'inspector-port': commonFlags.inspectorPort, - host: deprecated('--host')(), ['env-branch']: commonFlags.envBranch, ['disable-version-check']: Flags.boolean({ description: 'Skip the version check when running `hydrogen dev`', diff --git a/packages/cli/src/lib/flags.ts b/packages/cli/src/lib/flags.ts index dd4c1c4ae1..05bdfed8e1 100644 --- a/packages/cli/src/lib/flags.ts +++ b/packages/cli/src/lib/flags.ts @@ -62,8 +62,6 @@ export const commonFlags = { 'Generate types for the Storefront API queries found in your project.', required: false, default: false, - deprecateAliases: true, - aliases: ['codegen-unstable'], }), codegenConfigPath: Flags.string({ description: diff --git a/packages/cli/src/lib/transpile/project.ts b/packages/cli/src/lib/transpile/project.ts index dc81ec979d..63b16ba764 100644 --- a/packages/cli/src/lib/transpile/project.ts +++ b/packages/cli/src/lib/transpile/project.ts @@ -135,7 +135,7 @@ export async function transpileProject(projectDir: string, keepTypes = true) { } } - const codegenFlag = /\s*--codegen(-unstable)?/; + const codegenFlag = /\s*--codegen/; if (pkgJson.scripts?.dev) { pkgJson.scripts.dev = pkgJson.scripts.dev.replace(codegenFlag, ''); } From 4d067f379125de42862aebedeb4745bb59d75726 Mon Sep 17 00:00:00 2001 From: Fran Dios Date: Thu, 18 Jan 2024 09:45:10 +0900 Subject: [PATCH 3/4] Avoid duplicating the locale in variant links (#1648) --- templates/demo-store/app/components/Link.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/templates/demo-store/app/components/Link.tsx b/templates/demo-store/app/components/Link.tsx index 0474b7b34e..db90ef40fb 100644 --- a/templates/demo-store/app/components/Link.tsx +++ b/templates/demo-store/app/components/Link.tsx @@ -33,8 +33,10 @@ export function Link(props: LinkProps) { let toWithLocale = to; - if (typeof to === 'string') { - toWithLocale = selectedLocale ? `${selectedLocale.pathPrefix}${to}` : to; + if (typeof toWithLocale === 'string' && selectedLocale?.pathPrefix) { + if (!toWithLocale.toLowerCase().startsWith(selectedLocale.pathPrefix)) { + toWithLocale = `${selectedLocale.pathPrefix}${to}`; + } } if (typeof className === 'function') { From 1820241f58e86d11864643c8d1683bf4da60ea02 Mon Sep 17 00:00:00 2001 From: Michelle Chen Date: Thu, 18 Jan 2024 18:50:20 -0500 Subject: [PATCH 4/4] Use Customer Account API in `templates/skeleton` & `template/demo-store` (#1576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ add type generation for customer account api in app/graphql/customer folder * ✨ add customer client creation into remix server context * 🔥 remove account activate, recover, register and reset. Since these are not supported in the new CA API with new customer flow. * 💥 get customer login flow and account/orders landing page working 💥 get login check working in header/layout 💥 edit account profile to use new api 💥 edit account address to customer account api 💥 edit order details page to use customer account api 💥 get customer login flow and account/orders landing page working 💥 get login check working in header/layout 💥 edit account profile to use new api 💥 edit account address to customer account api 💥 edit order details page to use customer account api * 🗒️ Doc Changes: - add note about /account not working with mock.shop - add warming for using createCustomerClient without proper credential - add changlog * ✅ Test update - update test snapshot - Show error in tests - Fix create and build test error - Fix generating auth routes - Fix standard routes with auth - Fix setup i18n test * Update templates/skeleton/app/routes/account.$.tsx Co-authored-by: Bret Little * ✨ add an option to change auth url from the default /authorize * ✨ add defer loading for login check in root (#1636) * cart edit * 🔥 remove customer-api example * Update .changeset/tasty-plums-pump.md Co-authored-by: Bret Little * Update packages/hydrogen/src/customer/customer.ts Co-authored-by: Bret Little * Update packages/hydrogen/src/customer/customer.ts Co-authored-by: Bret Little * Update packages/hydrogen/src/customer/customer.ts Co-authored-by: Bret Little * demo-store customer account api update (#1647) * ✨ move demo-store to caapi * update login button * login flow + account page * customer profile edit * address edit * cart edit * order id page * fix eslint error, strange my VS code is giving different suggestions * Update packages/hydrogen/src/customer/customer.ts Co-authored-by: Bret Little * Update packages/hydrogen/src/customer/customer.ts Co-authored-by: Bret Little * remove redirectPath from authorize --------- Co-authored-by: Bret Little * implement redirectPath for skeleton template * use /account/authorize as the default authUrl --------- Co-authored-by: Bret Little --- .changeset/tasty-plums-pump.md | 11 + .graphqlrc.yml | 11 +- examples/customer-api/.gitignore | 1 - examples/customer-api/README.md | 37 - examples/customer-api/app/entry.client.tsx | 12 - examples/customer-api/app/entry.server.tsx | 40 - examples/customer-api/app/root.tsx | 74 -- examples/customer-api/app/routes/_index.tsx | 81 -- .../customer-api/app/routes/authorize.tsx | 9 - examples/customer-api/app/routes/logout.tsx | 6 - examples/customer-api/app/styles/app.css | 31 - examples/customer-api/example.env | 7 - examples/customer-api/package.json | 42 - examples/customer-api/public/favicon.svg | 28 - examples/customer-api/remix.config.js | 20 - examples/customer-api/remix.env.d.ts | 40 - examples/customer-api/server.ts | 153 ---- examples/customer-api/tsconfig.json | 26 - package-lock.json | 62 -- package.json | 1 - packages/cli/src/commands/hydrogen/build.ts | 6 +- .../cli/src/commands/hydrogen/setup.test.ts | 1 - packages/cli/src/lib/codegen.ts | 14 + packages/cli/src/lib/missing-routes.ts | 10 +- .../cli/src/lib/setups/i18n/replacers.test.ts | 28 +- .../docs/generated/generated_docs_data.json | 109 ++- .../hydrogen/src/customer/customer.doc.ts | 4 +- .../hydrogen/src/customer/customer.test.ts | 4 +- packages/hydrogen/src/customer/customer.ts | 38 +- packages/hydrogen/src/hydrogen.d.ts | 1 + packages/hydrogen/src/utils/callsites.ts | 8 +- templates/demo-store/.env | 6 +- templates/demo-store/.graphqlrc.yml | 13 +- templates/demo-store/README.md | 15 + .../app/components/AccountAddressBook.tsx | 8 +- .../app/components/AccountDetails.tsx | 15 +- .../demo-store/app/components/Layout.tsx | 12 +- .../demo-store/app/components/OrderCard.tsx | 43 +- .../CustomerAddressMutations.ts | 58 ++ .../customer-account/CustomerDetailsQuery.tsx | 83 ++ .../customer-account/CustomerOrderQuery.ts | 87 ++ .../CustomerUpdateMutation.tsx | 11 + templates/demo-store/app/lib/utils.ts | 47 +- templates/demo-store/app/root.tsx | 35 +- .../app/routes/($locale).account.$.tsx | 24 + ....account.activate.$id.$activationToken.tsx | 240 ------ .../routes/($locale).account.address.$id.tsx | 268 +++--- .../app/routes/($locale).account.edit.tsx | 191 +---- .../app/routes/($locale).account.login.tsx | 254 ------ .../app/routes/($locale).account.logout.ts | 27 - .../routes/($locale).account.orders.$id.tsx | 273 ++---- .../app/routes/($locale).account.recover.tsx | 155 ---- .../app/routes/($locale).account.register.tsx | 211 ----- ...$locale).account.reset.$id.$resetToken.tsx | 236 ------ .../app/routes/($locale).account.tsx | 176 ++-- .../routes/($locale).account_.authorize.ts | 5 + .../app/routes/($locale).account_.login.tsx | 13 + .../app/routes/($locale).account_.logout.ts | 20 + .../demo-store/app/routes/($locale).cart.tsx | 16 +- .../customer-accountapi.generated.d.ts | 503 +++++++++++ templates/demo-store/remix.env.d.ts | 12 +- templates/demo-store/server.ts | 13 + .../demo-store/storefrontapi.generated.d.ts | 556 +------------ templates/skeleton/.graphqlrc.yml | 11 +- templates/skeleton/README.md | 23 + templates/skeleton/app/components/Header.tsx | 6 +- templates/skeleton/app/components/Layout.tsx | 2 +- .../CustomerAddressMutations.ts | 61 ++ .../customer-account/CustomerDetailsQuery.ts | 39 + .../customer-account/CustomerOrderQuery.ts | 87 ++ .../customer-account/CustomerOrdersQuery.ts | 58 ++ .../CustomerUpdateMutation.ts | 24 + templates/skeleton/app/root.tsx | 53 +- templates/skeleton/app/routes/account.$.tsx | 10 +- .../skeleton/app/routes/account._index.tsx | 5 + .../skeleton/app/routes/account.addresses.tsx | 426 +++++----- .../app/routes/account.orders.$id.tsx | 252 ++---- .../app/routes/account.orders._index.tsx | 144 +--- .../skeleton/app/routes/account.profile.tsx | 226 ++--- templates/skeleton/app/routes/account.tsx | 140 +--- ...account_.activate.$id.$activationToken.tsx | 161 ---- .../app/routes/account_.authorize.tsx | 5 + .../skeleton/app/routes/account_.login.tsx | 145 +--- .../skeleton/app/routes/account_.logout.tsx | 29 +- .../skeleton/app/routes/account_.recover.tsx | 129 --- .../skeleton/app/routes/account_.register.tsx | 207 ----- .../routes/account_.reset.$id.$resetToken.tsx | 136 --- templates/skeleton/app/routes/cart.tsx | 17 +- .../customer-accountapi.generated.d.ts | 506 ++++++++++++ templates/skeleton/remix.env.d.ts | 11 +- templates/skeleton/server.ts | 13 + .../skeleton/storefrontapi.generated.d.ts | 779 +----------------- 92 files changed, 2711 insertions(+), 5535 deletions(-) create mode 100644 .changeset/tasty-plums-pump.md delete mode 100644 examples/customer-api/.gitignore delete mode 100644 examples/customer-api/README.md delete mode 100644 examples/customer-api/app/entry.client.tsx delete mode 100644 examples/customer-api/app/entry.server.tsx delete mode 100644 examples/customer-api/app/root.tsx delete mode 100644 examples/customer-api/app/routes/_index.tsx delete mode 100644 examples/customer-api/app/routes/authorize.tsx delete mode 100644 examples/customer-api/app/routes/logout.tsx delete mode 100644 examples/customer-api/app/styles/app.css delete mode 100644 examples/customer-api/example.env delete mode 100644 examples/customer-api/package.json delete mode 100644 examples/customer-api/public/favicon.svg delete mode 100644 examples/customer-api/remix.config.js delete mode 100644 examples/customer-api/remix.env.d.ts delete mode 100644 examples/customer-api/server.ts delete mode 100644 examples/customer-api/tsconfig.json create mode 100644 templates/demo-store/app/graphql/customer-account/CustomerAddressMutations.ts create mode 100644 templates/demo-store/app/graphql/customer-account/CustomerDetailsQuery.tsx create mode 100644 templates/demo-store/app/graphql/customer-account/CustomerOrderQuery.ts create mode 100644 templates/demo-store/app/graphql/customer-account/CustomerUpdateMutation.tsx create mode 100644 templates/demo-store/app/routes/($locale).account.$.tsx delete mode 100644 templates/demo-store/app/routes/($locale).account.activate.$id.$activationToken.tsx delete mode 100644 templates/demo-store/app/routes/($locale).account.login.tsx delete mode 100644 templates/demo-store/app/routes/($locale).account.logout.ts delete mode 100644 templates/demo-store/app/routes/($locale).account.recover.tsx delete mode 100644 templates/demo-store/app/routes/($locale).account.register.tsx delete mode 100644 templates/demo-store/app/routes/($locale).account.reset.$id.$resetToken.tsx create mode 100644 templates/demo-store/app/routes/($locale).account_.authorize.ts create mode 100644 templates/demo-store/app/routes/($locale).account_.login.tsx create mode 100644 templates/demo-store/app/routes/($locale).account_.logout.ts create mode 100644 templates/demo-store/customer-accountapi.generated.d.ts create mode 100644 templates/skeleton/app/graphql/customer-account/CustomerAddressMutations.ts create mode 100644 templates/skeleton/app/graphql/customer-account/CustomerDetailsQuery.ts create mode 100644 templates/skeleton/app/graphql/customer-account/CustomerOrderQuery.ts create mode 100644 templates/skeleton/app/graphql/customer-account/CustomerOrdersQuery.ts create mode 100644 templates/skeleton/app/graphql/customer-account/CustomerUpdateMutation.ts create mode 100644 templates/skeleton/app/routes/account._index.tsx delete mode 100644 templates/skeleton/app/routes/account_.activate.$id.$activationToken.tsx create mode 100644 templates/skeleton/app/routes/account_.authorize.tsx delete mode 100644 templates/skeleton/app/routes/account_.recover.tsx delete mode 100644 templates/skeleton/app/routes/account_.register.tsx delete mode 100644 templates/skeleton/app/routes/account_.reset.$id.$resetToken.tsx create mode 100644 templates/skeleton/customer-accountapi.generated.d.ts diff --git a/.changeset/tasty-plums-pump.md b/.changeset/tasty-plums-pump.md new file mode 100644 index 0000000000..7bbc218628 --- /dev/null +++ b/.changeset/tasty-plums-pump.md @@ -0,0 +1,11 @@ +--- +'skeleton': patch +'@shopify/hydrogen': patch +'@shopify/cli-hydrogen': patch +--- + +✨ Use the new Customer Account API in the account section of the skeleton template + +✨ Add an `authUrl` option to `createCustomerClient` that defines the route in your app that authorizes a user after logging in. The default value is `/account/authorize`. + +✨ Add an optional `redirectPath` parameter to customer client's login method. This param defines the final path the user lands on at the end of the oAuth flow. It defaults to `/` diff --git a/.graphqlrc.yml b/.graphqlrc.yml index 65197b3bb1..7c7141644c 100644 --- a/.graphqlrc.yml +++ b/.graphqlrc.yml @@ -4,8 +4,15 @@ projects: default: schema: 'packages/hydrogen-react/storefront.schema.json' documents: - - 'templates/**/app/**/*.{graphql,js,ts,jsx,tsx}' - - 'examples/**/app/**/*.{graphql,js,ts,jsx,tsx}' + - 'templates/**/app/**/*.{graphql,js,ts,jsx,tsx}' + - 'examples/**/app/**/*.{graphql,js,ts,jsx,tsx}' + - '!templates/**/app/graphql/**/*.{graphql,js,ts,jsx,tsx}' + - '!examples/**/app/graphql/**/*.{graphql,js,ts,jsx,tsx}' + customer-account: + schema: 'packages/hydrogen-react/customer-account.schema.json' + documents: + - 'templates/**/app/graphql/customer-account/**/*.{graphql,js,ts,jsx,tsx}' + - 'examples/**/app/graphql/customer-account/**/*.{graphql,js,ts,jsx,tsx}' admin: schema: 'packages/cli/admin.schema.json' documents: 'packages/cli/src/**/graphql/admin/**/*.ts' diff --git a/examples/customer-api/.gitignore b/examples/customer-api/.gitignore deleted file mode 100644 index ad5f2cad45..0000000000 --- a/examples/customer-api/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.shopify diff --git a/examples/customer-api/README.md b/examples/customer-api/README.md deleted file mode 100644 index e53bf99b8d..0000000000 --- a/examples/customer-api/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# Hydrogen Customer Account API Example - -**Caution: The Customer API and this example are both in an unstable pre-release state and may have breaking changes in a future release.** - -This is an example using the new Shopify [Customer Account API](https://shopify.dev/docs/api/customer) - -## Requirements - -1. Hydrogen 2023.7 or later -1. A `Hydrogen` or `Headless` app/channel installed to your store and a storefront created -1. [Ngrok](https://ngrok.com/) for pointing a public https domain to your local machine required for oAuth - -## Setup - -### Setup public domain using ngrok - -1. Setup a [ngrok](https://ngrok.com/) account and add a permanent domain (ie. `https://.app`). -1. Install the [ngrok CLI](https://ngrok.com/download) to use in terminal -1. Start ngrok using `npm run ngrok --domain=.app` - -### Include public domain in Customer Account API settings - -1. Go to your Shopify admin => `Hydrogen` or `Headless` app/channel => Customer Account API => Application setup -1. Edit `Callback URI(s)` to include `https://.app/authorize` -1. Edit `Javascript origin(s)` to include your public domain `https://.app` or keep it blank -1. Edit `Logout URI` to include your public domain `https://.app` or keep it blank - -### Prepare Environment variables - -To preview this example with mock data, copy `example.env` and rename it to `.env` in the root of your project. - -Alternatly, run [`npx shopify hydrogen link`](https://shopify.dev/docs/custom-storefronts/hydrogen/cli#link) or [`npx shopify hydrogen env pull`](https://shopify.dev/docs/custom-storefronts/hydrogen/cli#env-pull) to link this example to your own test shop - -### Start example - -1. In a seperate terminal, start hydrogen with `npm run dev` -1. Goto `https://.app` to start the login process diff --git a/examples/customer-api/app/entry.client.tsx b/examples/customer-api/app/entry.client.tsx deleted file mode 100644 index ba957c430e..0000000000 --- a/examples/customer-api/app/entry.client.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import {RemixBrowser} from '@remix-run/react'; -import {startTransition, StrictMode} from 'react'; -import {hydrateRoot} from 'react-dom/client'; - -startTransition(() => { - hydrateRoot( - document, - - - , - ); -}); diff --git a/examples/customer-api/app/entry.server.tsx b/examples/customer-api/app/entry.server.tsx deleted file mode 100644 index 61db2b9507..0000000000 --- a/examples/customer-api/app/entry.server.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type {EntryContext} from '@shopify/remix-oxygen'; -import {RemixServer} from '@remix-run/react'; -import isbot from 'isbot'; -import {renderToReadableStream} from 'react-dom/server'; -import {createContentSecurityPolicy} from '@shopify/hydrogen'; - -export default async function handleRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext, -) { - const {nonce, header, NonceProvider} = createContentSecurityPolicy(); - - const body = await renderToReadableStream( - - - , - { - nonce, - signal: request.signal, - onError(error) { - // eslint-disable-next-line no-console - console.error(error); - responseStatusCode = 500; - }, - }, - ); - - if (isbot(request.headers.get('user-agent'))) { - await body.allReady; - } - - responseHeaders.set('Content-Type', 'text/html'); - responseHeaders.set('Content-Security-Policy', header); - return new Response(body, { - headers: responseHeaders, - status: responseStatusCode, - }); -} diff --git a/examples/customer-api/app/root.tsx b/examples/customer-api/app/root.tsx deleted file mode 100644 index 4c8271640c..0000000000 --- a/examples/customer-api/app/root.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { - type LinksFunction, - type LoaderFunctionArgs, -} from '@shopify/remix-oxygen'; -import { - Links, - Meta, - Outlet, - Scripts, - LiveReload, - ScrollRestoration, - useLoaderData, -} from '@remix-run/react'; -import type {Shop} from '@shopify/hydrogen/storefront-api-types'; -import styles from './styles/app.css'; -import favicon from '../public/favicon.svg'; -import {useNonce} from '@shopify/hydrogen'; - -export const links: LinksFunction = () => { - return [ - {rel: 'stylesheet', href: styles}, - { - rel: 'preconnect', - href: 'https://cdn.shopify.com', - }, - { - rel: 'preconnect', - href: 'https://shop.app', - }, - {rel: 'icon', type: 'image/svg+xml', href: favicon}, - ]; -}; - -export async function loader({context}: LoaderFunctionArgs) { - const layout = await context.storefront.query<{shop: Shop}>(LAYOUT_QUERY); - return {layout}; -} - -export default function App() { - const data = useLoaderData(); - const nonce = useNonce(); - - const {name} = data.layout.shop; - - return ( - - - - - - - - -

Customer API Example

-

- This is an example of Hydrogen using the Customer API -

- - - - - - - ); -} - -const LAYOUT_QUERY = `#graphql - query layout { - shop { - name - description - } - } -`; diff --git a/examples/customer-api/app/routes/_index.tsx b/examples/customer-api/app/routes/_index.tsx deleted file mode 100644 index 0b34174597..0000000000 --- a/examples/customer-api/app/routes/_index.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import {Form, useLoaderData, useRouteError} from '@remix-run/react'; -import {type LoaderFunctionArgs, json} from '@shopify/remix-oxygen'; - -export async function loader({context}: LoaderFunctionArgs) { - if (await context.customerAccount.isLoggedIn()) { - const {data} = await context.customerAccount.query<{ - customer: {firstName: string; lastName: string}; - }>(`#graphql - query getCustomer { - customer { - firstName - lastName - } - } - `); - - return json( - { - customer: data.customer, - }, - { - headers: { - 'Set-Cookie': await context.session.commit(), - }, - }, - ); - } - - return json( - {customer: null}, - { - headers: { - 'Set-Cookie': await context.session.commit(), - }, - }, - ); -} - -export function ErrorBoundary() { - const error = useRouteError() as Error; - return ( - <> -

- Error loading the user: -

-

{error.message}

- -
- -
- - ); -} - -export default function () { - const {customer} = useLoaderData(); - - return ( -
- {customer ? ( - <> -
- - Welcome {customer.firstName} {customer.lastName} - -
-
-
- -
-
- - ) : null} - {!customer ? ( -
- -
- ) : null} -
- ); -} diff --git a/examples/customer-api/app/routes/authorize.tsx b/examples/customer-api/app/routes/authorize.tsx deleted file mode 100644 index 28a1cd02e7..0000000000 --- a/examples/customer-api/app/routes/authorize.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import {ActionFunctionArgs, LoaderFunctionArgs} from '@shopify/remix-oxygen'; - -export async function action({context}: ActionFunctionArgs) { - return context.customerAccount.login(); -} - -export async function loader({context}: LoaderFunctionArgs) { - return context.customerAccount.authorize('/'); -} diff --git a/examples/customer-api/app/routes/logout.tsx b/examples/customer-api/app/routes/logout.tsx deleted file mode 100644 index 67972d0856..0000000000 --- a/examples/customer-api/app/routes/logout.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import {ActionFunctionArgs} from '@shopify/remix-oxygen'; - -// Do not put logout on a loader (GET request) in case it get trigger during prefetch -export async function action({context}: ActionFunctionArgs) { - return context.customerAccount.logout(); -} diff --git a/examples/customer-api/app/styles/app.css b/examples/customer-api/app/styles/app.css deleted file mode 100644 index 44b8ed7a48..0000000000 --- a/examples/customer-api/app/styles/app.css +++ /dev/null @@ -1,31 +0,0 @@ -body { - margin: 0; - background: #ffffff; - font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, - Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; - padding: 20px; -} - -h1, -h2, -p { - margin: 0; - padding: 0; -} - -h1 { - font-size: 3rem; - font-weight: 700; - line-height: 1.4; -} - -h2 { - font-size: 1.2rem; - font-weight: 700; - line-height: 1.4; -} - -p { - font-size: 1rem; - line-height: 1.4; -} diff --git a/examples/customer-api/example.env b/examples/customer-api/example.env deleted file mode 100644 index 6b26a2c117..0000000000 --- a/examples/customer-api/example.env +++ /dev/null @@ -1,7 +0,0 @@ -# These variables are only available locally in MiniOxygen - -SESSION_SECRET="foobar" -PUBLIC_STOREFRONT_API_TOKEN="3b580e70970c4528da70c98e097c2fa0" -PUBLIC_STORE_DOMAIN="hydrogen-preview.myshopify.com" -PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID="shp_0200d288-18b6-4799-8172-426957e6148e" -PUBLIC_CUSTOMER_ACCOUNT_API_URL="https://shopify.com/55145660472" diff --git a/examples/customer-api/package.json b/examples/customer-api/package.json deleted file mode 100644 index d30781a62a..0000000000 --- a/examples/customer-api/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "example-customer-api", - "private": true, - "sideEffects": false, - "scripts": { - "build": "shopify hydrogen build", - "dev": "shopify hydrogen dev --worker", - "ngrok": "ngrok http --domain=${npm_config_domain} 3000", - "preview": "npm run build && shopify hydrogen preview --worker", - "lint": "eslint --no-error-on-unmatched-pattern --ext .js,.ts,.jsx,.tsx .", - "typecheck": "tsc --noEmit" - }, - "prettier": "@shopify/prettier-config", - "dependencies": { - "@remix-run/react": "2.1.0", - "@remix-run/server-runtime": "2.1.0", - "@shopify/cli": "3.52.0", - "@shopify/cli-hydrogen": "^6.1.0", - "@shopify/hydrogen": "^2023.10.3", - "@shopify/remix-oxygen": "^2.0.2", - "graphql": "^16.6.0", - "graphql-tag": "^2.12.6", - "isbot": "^3.6.6", - "react": "^18.2.0", - "react-dom": "^18.2.0" - }, - "devDependencies": { - "@remix-run/dev": "2.1.0", - "@shopify/oxygen-workers-types": "^4.0.0", - "@shopify/prettier-config": "^1.1.2", - "@types/eslint": "^8.4.10", - "@types/react": "^18.2.22", - "@types/react-dom": "^18.2.7", - "eslint": "^8.20.0", - "eslint-plugin-hydrogen": "0.12.2", - "prettier": "^2.8.4", - "typescript": "^5.2.2" - }, - "engines": { - "node": ">=18.0.0" - } -} diff --git a/examples/customer-api/public/favicon.svg b/examples/customer-api/public/favicon.svg deleted file mode 100644 index f6c649733d..0000000000 --- a/examples/customer-api/public/favicon.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - - - diff --git a/examples/customer-api/remix.config.js b/examples/customer-api/remix.config.js deleted file mode 100644 index d00cc56cf4..0000000000 --- a/examples/customer-api/remix.config.js +++ /dev/null @@ -1,20 +0,0 @@ -/** @type {import('@remix-run/dev').AppConfig} */ -module.exports = { - appDirectory: 'app', - ignoredRouteFiles: ['**/.*'], - watchPaths: ['./public'], - server: './server.ts', - /** - * The following settings are required to deploy Hydrogen apps to Oxygen: - */ - publicPath: (process.env.HYDROGEN_ASSET_BASE_URL ?? '/') + 'build/', - assetsBuildDirectory: 'dist/client/build', - serverBuildPath: 'dist/worker/index.js', - serverMainFields: ['browser', 'module', 'main'], - serverConditions: ['worker', process.env.NODE_ENV], - serverDependenciesToBundle: 'all', - serverModuleFormat: 'esm', - serverPlatform: 'neutral', - - serverMinify: process.env.NODE_ENV === 'production', -}; diff --git a/examples/customer-api/remix.env.d.ts b/examples/customer-api/remix.env.d.ts deleted file mode 100644 index aa14d0dc60..0000000000 --- a/examples/customer-api/remix.env.d.ts +++ /dev/null @@ -1,40 +0,0 @@ -/// -/// -/// - -import type {Storefront} from '@shopify/hydrogen'; -import type {AppSession} from './server'; -import type {CustomerClient} from '@shopify/hydrogen'; - -declare global { - /** - * A global `process` object is only available during build to access NODE_ENV. - */ - const process: {env: {NODE_ENV: 'production' | 'development'}}; - - /** - * Declare expected Env parameter in fetch handler. - */ - interface Env { - SESSION_SECRET: string; - PUBLIC_STOREFRONT_API_TOKEN: string; - PRIVATE_STOREFRONT_API_TOKEN: string; - PUBLIC_STORE_DOMAIN: string; - PUBLIC_STOREFRONT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_URL: string; - } -} - -/** - * Declare local additions to `AppLoadContext` to include the session utilities we injected in `server.ts`. - */ -declare module '@shopify/remix-oxygen' { - export interface AppLoadContext { - session: AppSession; - storefront: Storefront; - env: Env; - customerAccount: CustomerClient; - waitUntil: ExecutionContext['waitUntil']; - } -} diff --git a/examples/customer-api/server.ts b/examples/customer-api/server.ts deleted file mode 100644 index 049887e4b9..0000000000 --- a/examples/customer-api/server.ts +++ /dev/null @@ -1,153 +0,0 @@ -// Virtual entry point for the app -import * as remixBuild from '@remix-run/dev/server-build'; -import { - createStorefrontClient, - storefrontRedirect, - createCustomerClient, - type HydrogenSession, -} from '@shopify/hydrogen'; -import { - createRequestHandler, - getStorefrontHeaders, - createCookieSessionStorage, - type SessionStorage, - type Session, -} from '@shopify/remix-oxygen'; - -/** - * Export a fetch handler in module format. - */ -export default { - async fetch( - request: Request, - env: Env, - executionContext: ExecutionContext, - ): Promise { - try { - /** - * Open a cache instance in the worker and a custom session instance. - */ - if (!env?.SESSION_SECRET) { - throw new Error('SESSION_SECRET environment variable is not set'); - } - - const waitUntil = (p: Promise) => executionContext.waitUntil(p); - const [cache, session] = await Promise.all([ - caches.open('hydrogen'), - AppSession.init(request, [env.SESSION_SECRET]), - ]); - - /** - * Create Hydrogen's Storefront client. - */ - const {storefront} = createStorefrontClient({ - cache, - waitUntil, - i18n: {language: 'EN', country: 'US'}, - publicStorefrontToken: env.PUBLIC_STOREFRONT_API_TOKEN, - privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN, - storeDomain: env.PUBLIC_STORE_DOMAIN, - storefrontId: env.PUBLIC_STOREFRONT_ID, - storefrontHeaders: getStorefrontHeaders(request), - }); - - /** - * Create a customer client for the new customer API. - */ - const customerAccount = createCustomerClient({ - waitUntil, - request, - session, - customerAccountId: env.PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID, - customerAccountUrl: env.PUBLIC_CUSTOMER_ACCOUNT_API_URL, - }); - - /** - * Create a Remix request handler and pass - * Hydrogen's Storefront client to the loader context. - */ - const handleRequest = createRequestHandler({ - build: remixBuild, - mode: process.env.NODE_ENV, - getLoadContext: () => ({ - session, - storefront, - customerAccount, - env, - waitUntil, - }), - }); - - const response = await handleRequest(request); - - if (response.status === 404) { - /** - * Check for redirects only when there's a 404 from the app. - * If the redirect doesn't exist, then `storefrontRedirect` - * will pass through the 404 response. - */ - return storefrontRedirect({request, response, storefront}); - } - - return response; - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - return new Response('An unexpected error occurred', {status: 500}); - } - }, -}; - -/** - * This is a custom session implementation for your Hydrogen shop. - * Feel free to customize it to your needs, add helper methods, or - * swap out the cookie-based implementation with something else! - */ -export class AppSession implements HydrogenSession { - constructor( - private sessionStorage: SessionStorage, - private session: Session, - ) {} - - static async init(request: Request, secrets: string[]) { - const storage = createCookieSessionStorage({ - cookie: { - name: 'session', - httpOnly: true, - path: '/', - sameSite: 'lax', - secrets, - }, - }); - - const session = await storage - .getSession(request.headers.get('Cookie')) - .catch(() => storage.getSession()); - - return new this(storage, session); - } - - get(key: string) { - return this.session.get(key); - } - - destroy() { - return this.sessionStorage.destroySession(this.session); - } - - flash(key: string, value: any) { - this.session.flash(key, value); - } - - unset(key: string) { - this.session.unset(key); - } - - set(key: string, value: any) { - this.session.set(key, value); - } - - commit() { - return this.sessionStorage.commitSession(this.session); - } -} diff --git a/examples/customer-api/tsconfig.json b/examples/customer-api/tsconfig.json deleted file mode 100644 index 68a0375c5d..0000000000 --- a/examples/customer-api/tsconfig.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "include": ["./**/*.d.ts", "./**/*.ts", "./**/*.tsx"], - "compilerOptions": { - "lib": ["DOM", "DOM.Iterable", "ES2022"], - "isolatedModules": true, - "esModuleInterop": true, - "jsx": "react-jsx", - "moduleResolution": "Bundler", - "resolveJsonModule": true, - "module": "ES2022", - "target": "ES2022", - "strict": true, - "allowJs": true, - "forceConsistentCasingInFileNames": true, - "skipLibCheck": true, - "baseUrl": ".", - "types": ["@shopify/oxygen-workers-types"], - "paths": { - "~/*": ["app/*"] - }, - - // Remix takes care of building everything in `./app` with `remix build`. - // Wrangler takes care of building everything in `./worker` with `wrangler start` / `wrangler publish`. - "noEmit": true - } -} diff --git a/package-lock.json b/package-lock.json index cf610e2729..a25a96566f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "templates/demo-store", "templates/skeleton", "examples/express", - "examples/customer-api", "examples/subscriptions", "examples/optimistic-cart-ui", "examples/third-party-queries-caching", @@ -75,37 +74,6 @@ "node": ">=18.0.0" } }, - "examples/customer-api": { - "name": "example-customer-api", - "dependencies": { - "@remix-run/react": "2.1.0", - "@remix-run/server-runtime": "2.1.0", - "@shopify/cli": "3.52.0", - "@shopify/cli-hydrogen": "^6.1.0", - "@shopify/hydrogen": "^2023.10.3", - "@shopify/remix-oxygen": "^2.0.2", - "graphql": "^16.6.0", - "graphql-tag": "^2.12.6", - "isbot": "^3.6.6", - "react": "^18.2.0", - "react-dom": "^18.2.0" - }, - "devDependencies": { - "@remix-run/dev": "2.1.0", - "@shopify/oxygen-workers-types": "^4.0.0", - "@shopify/prettier-config": "^1.1.2", - "@types/eslint": "^8.4.10", - "@types/react": "^18.2.22", - "@types/react-dom": "^18.2.7", - "eslint": "^8.20.0", - "eslint-plugin-hydrogen": "0.12.2", - "prettier": "^2.8.4", - "typescript": "^5.2.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "examples/express": { "name": "example-hydrogen-express", "dependencies": { @@ -14886,10 +14854,6 @@ "node": ">=12.0.0" } }, - "node_modules/example-customer-api": { - "resolved": "examples/customer-api", - "link": true - }, "node_modules/example-hydrogen-express": { "resolved": "examples/express", "link": true @@ -42726,32 +42690,6 @@ "version": "2.0.2", "dev": true }, - "example-customer-api": { - "version": "file:examples/customer-api", - "requires": { - "@remix-run/dev": "2.1.0", - "@remix-run/react": "2.1.0", - "@remix-run/server-runtime": "2.1.0", - "@shopify/cli": "3.52.0", - "@shopify/cli-hydrogen": "^6.1.0", - "@shopify/hydrogen": "^2023.10.3", - "@shopify/oxygen-workers-types": "^4.0.0", - "@shopify/prettier-config": "^1.1.2", - "@shopify/remix-oxygen": "^2.0.2", - "@types/eslint": "^8.4.10", - "@types/react": "^18.2.22", - "@types/react-dom": "^18.2.7", - "eslint": "^8.20.0", - "eslint-plugin-hydrogen": "0.12.2", - "graphql": "^16.6.0", - "graphql-tag": "^2.12.6", - "isbot": "^3.6.6", - "prettier": "^2.8.4", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "typescript": "^5.2.2" - } - }, "example-hydrogen-express": { "version": "file:examples/express", "requires": { diff --git a/package.json b/package.json index c8119c9642..3b7b598638 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,6 @@ "templates/demo-store", "templates/skeleton", "examples/express", - "examples/customer-api", "examples/subscriptions", "examples/optimistic-cart-ui", "examples/third-party-queries-caching", diff --git a/packages/cli/src/commands/hydrogen/build.ts b/packages/cli/src/commands/hydrogen/build.ts index 6fb2060d97..88e19f6a4b 100644 --- a/packages/cli/src/commands/hydrogen/build.ts +++ b/packages/cli/src/commands/hydrogen/build.ts @@ -167,7 +167,11 @@ export async function runBuild({ fileWatchCache: createFileWatchCache(), }).catch((thrown) => { logThrown(thrown); - process.exit(1); + if (process.env.SHOPIFY_UNIT_TEST) { + throw thrown; + } else { + process.exit(1); + } }), useCodegen && codegen({...remixConfig, configFilePath: codegenConfigPath}), ]); diff --git a/packages/cli/src/commands/hydrogen/setup.test.ts b/packages/cli/src/commands/hydrogen/setup.test.ts index 43a95ce0fc..cab8850470 100644 --- a/packages/cli/src/commands/hydrogen/setup.test.ts +++ b/packages/cli/src/commands/hydrogen/setup.test.ts @@ -77,7 +77,6 @@ describe('setup', () => { expect(output).toMatch(/Markets:\s*Subfolders/); expect(output).toMatch('Routes'); expect(output).toMatch('Home (/ & /:catchAll)'); - expect(output).toMatch('Account (/account/*)'); }); }); }); diff --git a/packages/cli/src/lib/codegen.ts b/packages/cli/src/lib/codegen.ts index cb569806e1..60f20a7772 100644 --- a/packages/cli/src/lib/codegen.ts +++ b/packages/cli/src/lib/codegen.ts @@ -194,6 +194,19 @@ async function generateDefaultConfig( const defaultGlob = '*!(*.d).{ts,tsx,js,jsx}'; // No d.ts files const appDirRelative = relativePath(rootDirectory, appDirectory); + const caapiSchema = getSchema('customer-account'); + const caapiProject = findGqlProject(caapiSchema, gqlConfig); + + const customerAccountAPIConfig = caapiProject?.documents + ? { + ['customer-accountapi.generated.d.ts']: { + preset, + schema: caapiSchema, + documents: caapiProject?.documents, + }, + } + : undefined; + return { filepath: 'virtual:codegen', config: { @@ -228,6 +241,7 @@ async function generateDefaultConfig( }, }), }, + ...customerAccountAPIConfig, }, }, }; diff --git a/packages/cli/src/lib/missing-routes.ts b/packages/cli/src/lib/missing-routes.ts index 2cddf6c5e2..5609b2de3a 100644 --- a/packages/cli/src/lib/missing-routes.ts +++ b/packages/cli/src/lib/missing-routes.ts @@ -30,12 +30,16 @@ const REQUIRED_ROUTES = [ 'account', 'account/login', - 'account/register', // 'account/addresses', // 'account/orders', 'account/orders/:orderId', - 'account/reset/:id/:token', - 'account/activate/:id/:token', + // -- Added for CAAPI: + 'account/authorize', + + // -- These were removed when migrating to CAAPI: + // 'account/register', + // 'account/reset/:id/:token', + // 'account/activate/:id/:token', // 'password', // 'opening_soon', diff --git a/packages/cli/src/lib/setups/i18n/replacers.test.ts b/packages/cli/src/lib/setups/i18n/replacers.test.ts index f328e38cbb..fe073404cb 100644 --- a/packages/cli/src/lib/setups/i18n/replacers.test.ts +++ b/packages/cli/src/lib/setups/i18n/replacers.test.ts @@ -59,12 +59,15 @@ describe('i18n replacers', () => { // Enhance TypeScript's built-in typings. import "@total-typescript/ts-reset"; - import type { Storefront, HydrogenCart } from "@shopify/hydrogen"; + import type { + Storefront, + CustomerClient, + HydrogenCart, + } from "@shopify/hydrogen"; import type { LanguageCode, CountryCode, } from "@shopify/hydrogen/storefront-api-types"; - import type { CustomerAccessToken } from "@shopify/hydrogen/storefront-api-types"; import type { AppSession } from "~/lib/session"; declare global { @@ -100,16 +103,10 @@ describe('i18n replacers', () => { env: Env; cart: HydrogenCart; storefront: Storefront; + customerAccount: CustomerClient; session: AppSession; waitUntil: ExecutionContext["waitUntil"]; } - - /** - * Declare the data we expect to access via \`context.session\`. - */ - export interface SessionData { - customerAccessToken: CustomerAccessToken; - } } " `); @@ -149,6 +146,7 @@ describe('i18n replacers', () => { createCartHandler, createStorefrontClient, storefrontRedirect, + createCustomerClient, } from "@shopify/hydrogen"; import { createRequestHandler, @@ -195,6 +193,17 @@ describe('i18n replacers', () => { storefrontHeaders: getStorefrontHeaders(request), }); + /** + * Create a client for Customer Account API. + */ + const customerAccount = createCustomerClient({ + waitUntil, + request, + session, + customerAccountId: env.PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID, + customerAccountUrl: env.PUBLIC_CUSTOMER_ACCOUNT_API_URL, + }); + /* * Create a cart handler that will be used to * create and update the cart in the session. @@ -216,6 +225,7 @@ describe('i18n replacers', () => { getLoadContext: (): AppLoadContext => ({ session, storefront, + customerAccount, cart, env, waitUntil, diff --git a/packages/hydrogen/docs/generated/generated_docs_data.json b/packages/hydrogen/docs/generated/generated_docs_data.json index 4c91f446bf..12647f8519 100644 --- a/packages/hydrogen/docs/generated/generated_docs_data.json +++ b/packages/hydrogen/docs/generated/generated_docs_data.json @@ -1496,7 +1496,7 @@ "filePath": "/storefront.ts", "syntaxKind": "TypeAliasDeclaration", "name": "StorefrontQueryOptions", - "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n}", + "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n /** The name to be shown in the Subrequest Profiler */\n displayName?: string;\n}", "description": "" }, "CachingStrategy": { @@ -2714,7 +2714,7 @@ "filePath": "/storefront.ts", "syntaxKind": "TypeAliasDeclaration", "name": "StorefrontQueryOptions", - "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n}", + "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n /** The name to be shown in the Subrequest Profiler */\n displayName?: string;\n}", "description": "" }, "CachingStrategy": { @@ -3191,7 +3191,7 @@ "filePath": "/storefront.ts", "syntaxKind": "TypeAliasDeclaration", "name": "StorefrontQueryOptions", - "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n}", + "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n /** The name to be shown in the Subrequest Profiler */\n displayName?: string;\n}", "description": "" }, "CachingStrategy": { @@ -3668,7 +3668,7 @@ "filePath": "/storefront.ts", "syntaxKind": "TypeAliasDeclaration", "name": "StorefrontQueryOptions", - "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n}", + "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n /** The name to be shown in the Subrequest Profiler */\n displayName?: string;\n}", "description": "" }, "CachingStrategy": { @@ -4145,7 +4145,7 @@ "filePath": "/storefront.ts", "syntaxKind": "TypeAliasDeclaration", "name": "StorefrontQueryOptions", - "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n}", + "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n /** The name to be shown in the Subrequest Profiler */\n displayName?: string;\n}", "description": "" }, "CachingStrategy": { @@ -4615,7 +4615,7 @@ "filePath": "/storefront.ts", "syntaxKind": "TypeAliasDeclaration", "name": "StorefrontQueryOptions", - "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n}", + "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n /** The name to be shown in the Subrequest Profiler */\n displayName?: string;\n}", "description": "" }, "CachingStrategy": { @@ -5043,7 +5043,7 @@ "filePath": "/storefront.ts", "syntaxKind": "TypeAliasDeclaration", "name": "StorefrontQueryOptions", - "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n}", + "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n /** The name to be shown in the Subrequest Profiler */\n displayName?: string;\n}", "description": "" }, "CachingStrategy": { @@ -5520,7 +5520,7 @@ "filePath": "/storefront.ts", "syntaxKind": "TypeAliasDeclaration", "name": "StorefrontQueryOptions", - "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n}", + "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n /** The name to be shown in the Subrequest Profiler */\n displayName?: string;\n}", "description": "" }, "CachingStrategy": { @@ -5990,7 +5990,7 @@ "filePath": "/storefront.ts", "syntaxKind": "TypeAliasDeclaration", "name": "StorefrontQueryOptions", - "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n}", + "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n /** The name to be shown in the Subrequest Profiler */\n displayName?: string;\n}", "description": "" }, "CachingStrategy": { @@ -6467,7 +6467,7 @@ "filePath": "/storefront.ts", "syntaxKind": "TypeAliasDeclaration", "name": "StorefrontQueryOptions", - "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n}", + "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n /** The name to be shown in the Subrequest Profiler */\n displayName?: string;\n}", "description": "" }, "CachingStrategy": { @@ -6937,7 +6937,7 @@ "filePath": "/storefront.ts", "syntaxKind": "TypeAliasDeclaration", "name": "StorefrontQueryOptions", - "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n}", + "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n /** The name to be shown in the Subrequest Profiler */\n displayName?: string;\n}", "description": "" }, "CachingStrategy": { @@ -7414,7 +7414,7 @@ "filePath": "/storefront.ts", "syntaxKind": "TypeAliasDeclaration", "name": "StorefrontQueryOptions", - "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n}", + "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n /** The name to be shown in the Subrequest Profiler */\n displayName?: string;\n}", "description": "" }, "CachingStrategy": { @@ -7884,7 +7884,7 @@ "filePath": "/storefront.ts", "syntaxKind": "TypeAliasDeclaration", "name": "StorefrontQueryOptions", - "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n}", + "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n /** The name to be shown in the Subrequest Profiler */\n displayName?: string;\n}", "description": "" }, "CachingStrategy": { @@ -8208,7 +8208,7 @@ "name": "StorefrontClient", "value": "StorefrontClient" }, - "value": "export function createStorefrontClient(\n options: CreateStorefrontClientOptions,\n): StorefrontClient {\n const {\n storefrontHeaders,\n cache,\n waitUntil,\n i18n,\n storefrontId,\n ...clientOptions\n } = options;\n const H2_PREFIX_WARN = '[h2:warn:createStorefrontClient] ';\n\n if (process.env.NODE_ENV === 'development' && !cache) {\n warnOnce(\n H2_PREFIX_WARN +\n 'Storefront API client created without a cache instance. This may slow down your sub-requests.',\n );\n }\n\n const {\n getPublicTokenHeaders,\n getPrivateTokenHeaders,\n getStorefrontApiUrl,\n getShopifyDomain,\n } = createStorefrontUtilities(clientOptions);\n\n const getHeaders = clientOptions.privateStorefrontToken\n ? getPrivateTokenHeaders\n : getPublicTokenHeaders;\n\n const defaultHeaders = getHeaders({\n contentType: 'json',\n buyerIp: storefrontHeaders?.buyerIp || '',\n });\n\n defaultHeaders[STOREFRONT_REQUEST_GROUP_ID_HEADER] =\n storefrontHeaders?.requestGroupId || generateUUID();\n\n if (storefrontId) defaultHeaders[SHOPIFY_STOREFRONT_ID_HEADER] = storefrontId;\n if (LIB_VERSION) defaultHeaders['user-agent'] = `Hydrogen ${LIB_VERSION}`;\n\n if (storefrontHeaders && storefrontHeaders.cookie) {\n const cookies = getShopifyCookies(storefrontHeaders.cookie ?? '');\n\n if (cookies[SHOPIFY_Y])\n defaultHeaders[SHOPIFY_STOREFRONT_Y_HEADER] = cookies[SHOPIFY_Y];\n if (cookies[SHOPIFY_S])\n defaultHeaders[SHOPIFY_STOREFRONT_S_HEADER] = cookies[SHOPIFY_S];\n }\n\n // Remove any headers that are identifiable to the user or request\n const cacheKeyHeader = JSON.stringify({\n 'content-type': defaultHeaders['content-type'],\n 'user-agent': defaultHeaders['user-agent'],\n [SDK_VARIANT_HEADER]: defaultHeaders[SDK_VARIANT_HEADER],\n [SDK_VARIANT_SOURCE_HEADER]: defaultHeaders[SDK_VARIANT_SOURCE_HEADER],\n [SDK_VERSION_HEADER]: defaultHeaders[SDK_VERSION_HEADER],\n [STOREFRONT_ACCESS_TOKEN_HEADER]:\n defaultHeaders[STOREFRONT_ACCESS_TOKEN_HEADER],\n });\n\n async function fetchStorefrontApi({\n query,\n mutation,\n variables,\n cache: cacheOptions,\n headers = [],\n storefrontApiVersion,\n }: {variables?: GenericVariables} & (\n | StorefrontQueryOptions\n | StorefrontMutationOptions\n )): Promise {\n const userHeaders =\n headers instanceof Headers\n ? Object.fromEntries(headers.entries())\n : Array.isArray(headers)\n ? Object.fromEntries(headers)\n : headers;\n\n query = query ?? mutation;\n\n const queryVariables = {...variables};\n\n if (i18n) {\n if (!variables?.country && /\\$country/.test(query)) {\n queryVariables.country = i18n.country;\n }\n\n if (!variables?.language && /\\$language/.test(query)) {\n queryVariables.language = i18n.language;\n }\n }\n\n const url = getStorefrontApiUrl({storefrontApiVersion});\n const graphqlData = JSON.stringify({query, variables: queryVariables});\n const requestInit = {\n method: 'POST',\n headers: {...defaultHeaders, ...userHeaders},\n body: graphqlData,\n } satisfies RequestInit;\n\n const cacheKey = [\n url,\n requestInit.method,\n cacheKeyHeader,\n requestInit.body,\n ];\n\n const [body, response] = await fetchWithServerCache(url, requestInit, {\n cacheInstance: mutation ? undefined : cache,\n cache: cacheOptions || CacheDefault(),\n cacheKey,\n shouldCacheResponse: checkGraphQLErrors,\n waitUntil,\n debugInfo: {\n graphql: graphqlData,\n requestId: requestInit.headers[STOREFRONT_REQUEST_GROUP_ID_HEADER],\n purpose: storefrontHeaders?.purpose,\n },\n });\n\n const errorOptions: GraphQLErrorOptions = {\n response,\n type: mutation ? 'mutation' : 'query',\n query,\n queryVariables,\n errors: undefined,\n };\n\n if (!response.ok) {\n /**\n * The Storefront API might return a string error, or a JSON-formatted {error: string}.\n * We try both and conform them to a single {errors} format.\n */\n let errors;\n try {\n errors = parseJSON(body);\n } catch (_e) {\n errors = [{message: body}];\n }\n\n throwGraphQLError({...errorOptions, errors});\n }\n\n const {data, errors} = body as GraphQLApiResponse;\n\n if (errors?.length) {\n throwGraphQLError({\n ...errorOptions,\n errors,\n ErrorConstructor: StorefrontApiError,\n });\n }\n\n return data as T;\n }\n\n return {\n storefront: {\n /**\n * Sends a GraphQL query to the Storefront API.\n *\n * Example:\n *\n * ```js\n * async function loader ({context: {storefront}}) {\n * const data = await storefront.query('query { ... }', {\n * variables: {},\n * cache: storefront.CacheLong()\n * });\n * }\n * ```\n */\n query(query, options?) {\n query = minifyQuery(query);\n assertQuery(query, 'storefront.query');\n\n const result = fetchStorefrontApi({\n ...options,\n query,\n });\n\n // This is a no-op, but we need to catch the promise to avoid unhandled rejections\n // we cannot return the catch no-op, or it would swallow the error\n result.catch(() => {});\n\n return result;\n },\n /**\n * Sends a GraphQL mutation to the Storefront API.\n *\n * Example:\n *\n * ```js\n * async function loader ({context: {storefront}}) {\n * await storefront.mutate('mutation { ... }', {\n * variables: {},\n * });\n * }\n * ```\n */\n mutate(mutation, options?) {\n mutation = minifyQuery(mutation);\n assertMutation(mutation, 'storefront.mutate');\n\n const result = fetchStorefrontApi({\n ...options,\n mutation,\n });\n\n // This is a no-op, but we need to catch the promise to avoid unhandled rejections\n // we cannot return the catch no-op, or it would swallow the error\n result.catch(() => {});\n\n return result;\n },\n cache,\n CacheNone,\n CacheLong,\n CacheShort,\n CacheCustom,\n generateCacheControlHeader,\n getPublicTokenHeaders,\n getPrivateTokenHeaders,\n getShopifyDomain,\n getApiUrl: getStorefrontApiUrl,\n /**\n * Wether it's a GraphQL error returned in the Storefront API response.\n *\n * Example:\n *\n * ```js\n * async function loader ({context: {storefront}}) {\n * try {\n * await storefront.query(...);\n * } catch(error) {\n * if (storefront.isApiError(error)) {\n * // ...\n * }\n *\n * throw error;\n * }\n * }\n * ```\n */\n isApiError: isStorefrontApiError,\n i18n: (i18n ?? defaultI18n) as TI18n,\n },\n };\n}" + "value": "export function createStorefrontClient(\n options: CreateStorefrontClientOptions,\n): StorefrontClient {\n const {\n storefrontHeaders,\n cache,\n waitUntil,\n i18n,\n storefrontId,\n ...clientOptions\n } = options;\n const H2_PREFIX_WARN = '[h2:warn:createStorefrontClient] ';\n\n if (process.env.NODE_ENV === 'development' && !cache) {\n warnOnce(\n H2_PREFIX_WARN +\n 'Storefront API client created without a cache instance. This may slow down your sub-requests.',\n );\n }\n\n const {\n getPublicTokenHeaders,\n getPrivateTokenHeaders,\n getStorefrontApiUrl,\n getShopifyDomain,\n } = createStorefrontUtilities(clientOptions);\n\n const getHeaders = clientOptions.privateStorefrontToken\n ? getPrivateTokenHeaders\n : getPublicTokenHeaders;\n\n const defaultHeaders = getHeaders({\n contentType: 'json',\n buyerIp: storefrontHeaders?.buyerIp || '',\n });\n\n defaultHeaders[STOREFRONT_REQUEST_GROUP_ID_HEADER] =\n storefrontHeaders?.requestGroupId || generateUUID();\n\n if (storefrontId) defaultHeaders[SHOPIFY_STOREFRONT_ID_HEADER] = storefrontId;\n if (LIB_VERSION) defaultHeaders['user-agent'] = `Hydrogen ${LIB_VERSION}`;\n\n if (storefrontHeaders && storefrontHeaders.cookie) {\n const cookies = getShopifyCookies(storefrontHeaders.cookie ?? '');\n\n if (cookies[SHOPIFY_Y])\n defaultHeaders[SHOPIFY_STOREFRONT_Y_HEADER] = cookies[SHOPIFY_Y];\n if (cookies[SHOPIFY_S])\n defaultHeaders[SHOPIFY_STOREFRONT_S_HEADER] = cookies[SHOPIFY_S];\n }\n\n // Remove any headers that are identifiable to the user or request\n const cacheKeyHeader = JSON.stringify({\n 'content-type': defaultHeaders['content-type'],\n 'user-agent': defaultHeaders['user-agent'],\n [SDK_VARIANT_HEADER]: defaultHeaders[SDK_VARIANT_HEADER],\n [SDK_VARIANT_SOURCE_HEADER]: defaultHeaders[SDK_VARIANT_SOURCE_HEADER],\n [SDK_VERSION_HEADER]: defaultHeaders[SDK_VERSION_HEADER],\n [STOREFRONT_ACCESS_TOKEN_HEADER]:\n defaultHeaders[STOREFRONT_ACCESS_TOKEN_HEADER],\n });\n\n async function fetchStorefrontApi({\n query,\n mutation,\n variables,\n cache: cacheOptions,\n headers = [],\n storefrontApiVersion,\n displayName,\n }: {variables?: GenericVariables} & (\n | StorefrontQueryOptions\n | StorefrontMutationOptions\n )): Promise {\n const userHeaders =\n headers instanceof Headers\n ? Object.fromEntries(headers.entries())\n : Array.isArray(headers)\n ? Object.fromEntries(headers)\n : headers;\n\n query = query ?? mutation;\n\n const queryVariables = {...variables};\n\n if (i18n) {\n if (!variables?.country && /\\$country/.test(query)) {\n queryVariables.country = i18n.country;\n }\n\n if (!variables?.language && /\\$language/.test(query)) {\n queryVariables.language = i18n.language;\n }\n }\n\n const url = getStorefrontApiUrl({storefrontApiVersion});\n const graphqlData = JSON.stringify({query, variables: queryVariables});\n const requestInit = {\n method: 'POST',\n headers: {...defaultHeaders, ...userHeaders},\n body: graphqlData,\n } satisfies RequestInit;\n\n const cacheKey = [\n url,\n requestInit.method,\n cacheKeyHeader,\n requestInit.body,\n ];\n\n let stackOffset = 1;\n if (process.env.NODE_ENV === 'development') {\n if (/fragment CartApi(Query|Mutation) on Cart/.test(query)) {\n // The cart handler is wrapping storefront.query/mutate,\n // so we need to go up one more stack frame to show\n // the caller in /subrequest-profiler\n stackOffset = 2;\n }\n }\n\n const [body, response] = await fetchWithServerCache(url, requestInit, {\n cacheInstance: mutation ? undefined : cache,\n cache: cacheOptions || CacheDefault(),\n cacheKey,\n shouldCacheResponse: checkGraphQLErrors,\n waitUntil,\n debugInfo: {\n graphql: graphqlData,\n requestId: requestInit.headers[STOREFRONT_REQUEST_GROUP_ID_HEADER],\n purpose: storefrontHeaders?.purpose,\n stackInfo: getCallerStackLine?.(stackOffset),\n displayName,\n },\n });\n\n const errorOptions: GraphQLErrorOptions = {\n response,\n type: mutation ? 'mutation' : 'query',\n query,\n queryVariables,\n errors: undefined,\n };\n\n if (!response.ok) {\n /**\n * The Storefront API might return a string error, or a JSON-formatted {error: string}.\n * We try both and conform them to a single {errors} format.\n */\n let errors;\n try {\n errors = parseJSON(body);\n } catch (_e) {\n errors = [{message: body}];\n }\n\n throwGraphQLError({...errorOptions, errors});\n }\n\n const {data, errors} = body as GraphQLApiResponse;\n\n if (errors?.length) {\n throwGraphQLError({\n ...errorOptions,\n errors,\n ErrorConstructor: StorefrontApiError,\n });\n }\n\n return data as T;\n }\n\n return {\n storefront: {\n /**\n * Sends a GraphQL query to the Storefront API.\n *\n * Example:\n *\n * ```js\n * async function loader ({context: {storefront}}) {\n * const data = await storefront.query('query { ... }', {\n * variables: {},\n * cache: storefront.CacheLong()\n * });\n * }\n * ```\n */\n query(query, options?) {\n query = minifyQuery(query);\n assertQuery(query, 'storefront.query');\n\n const result = fetchStorefrontApi({\n ...options,\n query,\n });\n\n // This is a no-op, but we need to catch the promise to avoid unhandled rejections\n // we cannot return the catch no-op, or it would swallow the error\n result.catch(() => {});\n\n return result;\n },\n /**\n * Sends a GraphQL mutation to the Storefront API.\n *\n * Example:\n *\n * ```js\n * async function loader ({context: {storefront}}) {\n * await storefront.mutate('mutation { ... }', {\n * variables: {},\n * });\n * }\n * ```\n */\n mutate(mutation, options?) {\n mutation = minifyQuery(mutation);\n assertMutation(mutation, 'storefront.mutate');\n\n const result = fetchStorefrontApi({\n ...options,\n mutation,\n });\n\n // This is a no-op, but we need to catch the promise to avoid unhandled rejections\n // we cannot return the catch no-op, or it would swallow the error\n result.catch(() => {});\n\n return result;\n },\n cache,\n CacheNone,\n CacheLong,\n CacheShort,\n CacheCustom,\n generateCacheControlHeader,\n getPublicTokenHeaders,\n getPrivateTokenHeaders,\n getShopifyDomain,\n getApiUrl: getStorefrontApiUrl,\n /**\n * Wether it's a GraphQL error returned in the Storefront API response.\n *\n * Example:\n *\n * ```js\n * async function loader ({context: {storefront}}) {\n * try {\n * await storefront.query(...);\n * } catch(error) {\n * if (storefront.isApiError(error)) {\n * // ...\n * }\n *\n * throw error;\n * }\n * }\n * ```\n */\n isApiError: isStorefrontApiError,\n i18n: (i18n ?? defaultI18n) as TI18n,\n },\n };\n}" }, "CreateStorefrontClientOptions": { "filePath": "/storefront.ts", @@ -8470,7 +8470,7 @@ "filePath": "/storefront.ts", "syntaxKind": "TypeAliasDeclaration", "name": "StorefrontQueryOptions", - "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n}", + "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n /** The name to be shown in the Subrequest Profiler */\n displayName?: string;\n}", "description": "" }, "CachingStrategy": { @@ -8841,7 +8841,7 @@ "url": "/docs/api/hydrogen/current/utilities/createstorefrontclient" } ], - "description": "\nThe `createCustomerClient` function creates a GraphQL client for querying the [Customer Account API](https://shopify.dev/docs/api/customer). It also provides methods to authenticate and check if the user is logged in.\n\nSee an end to end [example on using the Customer Account API client](https://github.com/Shopify/hydrogen/tree/main/examples/customer-api).", + "description": "\nThe `createCustomerClient` function creates a GraphQL client for querying the [Customer Account API](https://shopify.dev/docs/api/customer). It also provides methods to authenticate and check if the user is logged in.", "type": "utility", "defaultExample": { "description": "I am the default example", @@ -8885,13 +8885,13 @@ "name": "CustomerClient", "value": "CustomerClient" }, - "value": "export function createCustomerClient({\n session,\n customerAccountId,\n customerAccountUrl,\n customerApiVersion = DEFAULT_CUSTOMER_API_VERSION,\n request,\n waitUntil,\n}: CustomerClientOptions): CustomerClient {\n if (customerApiVersion !== DEFAULT_CUSTOMER_API_VERSION) {\n console.log(\n `[h2:warn:createCustomerClient] You are using Customer Account API version ${customerApiVersion} when this version of Hydrogen was built for ${DEFAULT_CUSTOMER_API_VERSION}.`,\n );\n }\n\n if (!request?.url) {\n throw new Error(\n '[h2:error:createCustomerClient] The request object does not contain a URL.',\n );\n }\n const url = new URL(request.url);\n const origin =\n url.protocol === 'http:' ? url.origin.replace('http', 'https') : url.origin;\n\n const locks: Locks = {};\n\n const logSubRequestEvent =\n process.env.NODE_ENV === 'development'\n ? (query: string, startTime: number) => {\n (globalThis as any).__H2O_LOG_EVENT?.({\n eventType: 'subrequest',\n url: `https://shopify.dev/?${hashKey([\n `Customer Account `,\n /((query|mutation) [^\\s\\(]+)/g.exec(query)?.[0] ||\n query.substring(0, 10),\n ])}`,\n startTime,\n waitUntil,\n ...getDebugHeaders(request),\n });\n }\n : undefined;\n\n async function fetchCustomerAPI({\n query,\n type,\n variables = {},\n }: {\n query: string;\n type: 'query' | 'mutation';\n variables?: GenericVariables;\n }) {\n const customerAccount = session.get(CUSTOMER_ACCOUNT_SESSION_KEY);\n const accessToken = customerAccount?.accessToken;\n const expiresAt = customerAccount?.expiresAt;\n\n if (!accessToken || !expiresAt)\n throw new BadRequest(\n 'Unauthorized',\n 'Login before querying the Customer Account API.',\n );\n\n await checkExpires({\n locks,\n expiresAt,\n session,\n customerAccountId,\n customerAccountUrl,\n origin,\n });\n\n const startTime = new Date().getTime();\n\n const response = await fetch(\n `${customerAccountUrl}/account/customer/api/${customerApiVersion}/graphql`,\n {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'User-Agent': USER_AGENT,\n Origin: origin,\n Authorization: accessToken,\n },\n body: JSON.stringify({\n operationName: 'SomeQuery',\n query,\n variables,\n }),\n },\n );\n\n logSubRequestEvent?.(query, startTime);\n\n const body = await response.text();\n\n const errorOptions: GraphQLErrorOptions = {\n response,\n type,\n query,\n queryVariables: variables,\n errors: undefined,\n client: 'customer',\n };\n\n if (!response.ok) {\n /**\n * The Customer API might return a string error, or a JSON-formatted {error: string}.\n * We try both and conform them to a single {errors} format.\n */\n let errors;\n try {\n errors = parseJSON(body);\n } catch (_e) {\n errors = [{message: body}];\n }\n\n throwGraphQLError({...errorOptions, errors});\n }\n\n try {\n return parseJSON(body);\n } catch (e) {\n throwGraphQLError({...errorOptions, errors: [{message: body}]});\n }\n }\n\n async function isLoggedIn() {\n const customerAccount = session.get(CUSTOMER_ACCOUNT_SESSION_KEY);\n const accessToken = customerAccount?.accessToken;\n const expiresAt = customerAccount?.expiresAt;\n\n if (!accessToken || !expiresAt) return false;\n\n const startTime = new Date().getTime();\n\n try {\n await checkExpires({\n locks,\n expiresAt,\n session,\n customerAccountId,\n customerAccountUrl,\n origin,\n });\n\n logSubRequestEvent?.(' check expires', startTime);\n } catch {\n return false;\n }\n\n return true;\n }\n\n return {\n login: async () => {\n const loginUrl = new URL(customerAccountUrl + '/auth/oauth/authorize');\n\n const state = await generateState();\n const nonce = await generateNonce();\n\n loginUrl.searchParams.set('client_id', customerAccountId);\n loginUrl.searchParams.set('scope', 'openid email');\n loginUrl.searchParams.append('response_type', 'code');\n loginUrl.searchParams.append('redirect_uri', origin + '/authorize');\n loginUrl.searchParams.set(\n 'scope',\n 'openid email https://api.customers.com/auth/customer.graphql',\n );\n loginUrl.searchParams.append('state', state);\n loginUrl.searchParams.append('nonce', nonce);\n\n const verifier = await generateCodeVerifier();\n const challenge = await generateCodeChallenge(verifier);\n\n session.set(CUSTOMER_ACCOUNT_SESSION_KEY, {\n ...session.get(CUSTOMER_ACCOUNT_SESSION_KEY),\n codeVerifier: verifier,\n state,\n nonce,\n });\n\n loginUrl.searchParams.append('code_challenge', challenge);\n loginUrl.searchParams.append('code_challenge_method', 'S256');\n\n return redirect(loginUrl.toString(), {\n headers: {\n 'Set-Cookie': await session.commit(),\n },\n });\n },\n logout: async () => {\n const idToken = session.get(CUSTOMER_ACCOUNT_SESSION_KEY)?.idToken;\n\n clearSession(session);\n\n return redirect(\n `${customerAccountUrl}/auth/logout?id_token_hint=${idToken}`,\n {\n status: 302,\n\n headers: {\n 'Set-Cookie': await session.commit(),\n },\n },\n );\n },\n isLoggedIn,\n getAccessToken: async () => {\n const hasAccessToken = await isLoggedIn;\n\n if (!hasAccessToken) {\n return;\n } else {\n return session.get(CUSTOMER_ACCOUNT_SESSION_KEY)?.accessToken;\n }\n },\n mutate(mutation, options?) {\n mutation = minifyQuery(mutation);\n assertMutation(mutation, 'customer.mutate');\n\n return fetchCustomerAPI({query: mutation, type: 'mutation', ...options});\n },\n query(query, options?) {\n query = minifyQuery(query);\n assertQuery(query, 'customer.query');\n\n return fetchCustomerAPI({query, type: 'query', ...options});\n },\n authorize: async (redirectPath = '/') => {\n const code = url.searchParams.get('code');\n const state = url.searchParams.get('state');\n\n if (!code || !state) {\n clearSession(session);\n throw new BadRequest(\n 'Unauthorized',\n 'No code or state parameter found in the redirect URL.',\n );\n }\n\n if (session.get(CUSTOMER_ACCOUNT_SESSION_KEY)?.state !== state) {\n clearSession(session);\n throw new BadRequest(\n 'Unauthorized',\n 'The session state does not match the state parameter. Make sure that the session is configured correctly and passed to `createCustomerClient`.',\n );\n }\n\n const clientId = customerAccountId;\n const body = new URLSearchParams();\n\n body.append('grant_type', 'authorization_code');\n body.append('client_id', clientId);\n body.append('redirect_uri', origin + '/authorize');\n body.append('code', code);\n\n // Public Client\n const codeVerifier = session.get(\n CUSTOMER_ACCOUNT_SESSION_KEY,\n )?.codeVerifier;\n\n if (!codeVerifier)\n throw new BadRequest(\n 'Unauthorized',\n 'No code verifier found in the session. Make sure that the session is configured correctly and passed to `createCustomerClient`.',\n );\n\n body.append('code_verifier', codeVerifier);\n\n const headers = {\n 'content-type': 'application/x-www-form-urlencoded',\n 'User-Agent': USER_AGENT,\n Origin: origin,\n };\n\n const response = await fetch(`${customerAccountUrl}/auth/oauth/token`, {\n method: 'POST',\n headers,\n body,\n });\n\n if (!response.ok) {\n throw new Response(await response.text(), {\n status: response.status,\n headers: {\n 'Content-Type': 'text/html; charset=utf-8',\n },\n });\n }\n\n const {access_token, expires_in, id_token, refresh_token} =\n await response.json();\n\n const sessionNonce = session.get(CUSTOMER_ACCOUNT_SESSION_KEY)?.nonce;\n const responseNonce = await getNonce(id_token);\n\n if (sessionNonce !== responseNonce) {\n throw new BadRequest(\n 'Unauthorized',\n `Returned nonce does not match: ${sessionNonce} !== ${responseNonce}`,\n );\n }\n\n const customerAccessToken = await exchangeAccessToken(\n access_token,\n customerAccountId,\n customerAccountUrl,\n origin,\n );\n\n session.set(CUSTOMER_ACCOUNT_SESSION_KEY, {\n accessToken: customerAccessToken,\n expiresAt:\n new Date(\n new Date().getTime() + (expires_in! - 120) * 1000,\n ).getTime() + '',\n refreshToken: refresh_token,\n idToken: id_token,\n });\n\n return redirect(redirectPath, {\n headers: {\n 'Set-Cookie': await session.commit(),\n },\n });\n },\n };\n}" + "value": "export function createCustomerClient({\n session,\n customerAccountId,\n customerAccountUrl,\n customerApiVersion = DEFAULT_CUSTOMER_API_VERSION,\n request,\n waitUntil,\n authUrl = '/account/authorize',\n}: CustomerClientOptions): CustomerClient {\n if (customerApiVersion !== DEFAULT_CUSTOMER_API_VERSION) {\n console.log(\n `[h2:warn:createCustomerClient] You are using Customer Account API version ${customerApiVersion} when this version of Hydrogen was built for ${DEFAULT_CUSTOMER_API_VERSION}.`,\n );\n }\n\n if (!customerAccountId || !customerAccountUrl) {\n console.log(\n \"[h2:warn:createCustomerClient] customerAccountId and customerAccountUrl need to be provided to use Customer Account API. mock.shop doesn't automatically supply these variables. Use `h2 env pull` to link your store credentials.\",\n );\n }\n\n if (!request?.url) {\n throw new Error(\n '[h2:error:createCustomerClient] The request object does not contain a URL.',\n );\n }\n const url = new URL(request.url);\n const origin =\n url.protocol === 'http:' ? url.origin.replace('http', 'https') : url.origin;\n const redirectUri = authUrl.startsWith('/') ? origin + authUrl : authUrl;\n\n const locks: Locks = {};\n\n const logSubRequestEvent =\n process.env.NODE_ENV === 'development'\n ? (query: string, startTime: number) => {\n globalThis.__H2O_LOG_EVENT?.({\n eventType: 'subrequest',\n url: `https://shopify.dev/?${hashKey([\n `Customer Account `,\n /((query|mutation) [^\\s\\(]+)/g.exec(query)?.[0] ||\n query.substring(0, 10),\n ])}`,\n startTime,\n waitUntil,\n stackInfo: getCallerStackLine?.(2),\n ...getDebugHeaders(request),\n });\n }\n : undefined;\n\n async function fetchCustomerAPI({\n query,\n type,\n variables = {},\n }: {\n query: string;\n type: 'query' | 'mutation';\n variables?: GenericVariables;\n }) {\n const customerAccount = session.get(CUSTOMER_ACCOUNT_SESSION_KEY);\n const accessToken = customerAccount?.accessToken;\n const expiresAt = customerAccount?.expiresAt;\n\n if (!accessToken || !expiresAt)\n throw new BadRequest(\n 'Unauthorized',\n 'Login before querying the Customer Account API.',\n );\n\n await checkExpires({\n locks,\n expiresAt,\n session,\n customerAccountId,\n customerAccountUrl,\n origin,\n });\n\n const startTime = new Date().getTime();\n\n const response = await fetch(\n `${customerAccountUrl}/account/customer/api/${customerApiVersion}/graphql`,\n {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'User-Agent': USER_AGENT,\n Origin: origin,\n Authorization: accessToken,\n },\n body: JSON.stringify({\n operationName: 'SomeQuery',\n query,\n variables,\n }),\n },\n );\n\n logSubRequestEvent?.(query, startTime);\n\n const body = await response.text();\n\n const errorOptions: GraphQLErrorOptions = {\n response,\n type,\n query,\n queryVariables: variables,\n errors: undefined,\n client: 'customer',\n };\n\n if (!response.ok) {\n /**\n * The Customer API might return a string error, or a JSON-formatted {error: string}.\n * We try both and conform them to a single {errors} format.\n */\n let errors;\n try {\n errors = parseJSON(body);\n } catch (_e) {\n errors = [{message: body}];\n }\n\n throwGraphQLError({...errorOptions, errors});\n }\n\n try {\n return parseJSON(body);\n } catch (e) {\n throwGraphQLError({...errorOptions, errors: [{message: body}]});\n }\n }\n\n async function isLoggedIn() {\n const customerAccount = session.get(CUSTOMER_ACCOUNT_SESSION_KEY);\n const accessToken = customerAccount?.accessToken;\n const expiresAt = customerAccount?.expiresAt;\n\n if (!accessToken || !expiresAt) return false;\n\n const startTime = new Date().getTime();\n\n try {\n await checkExpires({\n locks,\n expiresAt,\n session,\n customerAccountId,\n customerAccountUrl,\n origin,\n });\n\n logSubRequestEvent?.(' check expires', startTime);\n } catch {\n return false;\n }\n\n return true;\n }\n\n return {\n login: async (redirectPath?: string) => {\n const loginUrl = new URL(customerAccountUrl + '/auth/oauth/authorize');\n\n const state = await generateState();\n const nonce = await generateNonce();\n\n loginUrl.searchParams.set('client_id', customerAccountId);\n loginUrl.searchParams.set('scope', 'openid email');\n loginUrl.searchParams.append('response_type', 'code');\n loginUrl.searchParams.append('redirect_uri', redirectUri);\n loginUrl.searchParams.set(\n 'scope',\n 'openid email https://api.customers.com/auth/customer.graphql',\n );\n loginUrl.searchParams.append('state', state);\n loginUrl.searchParams.append('nonce', nonce);\n\n const verifier = await generateCodeVerifier();\n const challenge = await generateCodeChallenge(verifier);\n\n session.set(CUSTOMER_ACCOUNT_SESSION_KEY, {\n ...session.get(CUSTOMER_ACCOUNT_SESSION_KEY),\n codeVerifier: verifier,\n state,\n nonce,\n redirectPath,\n });\n\n loginUrl.searchParams.append('code_challenge', challenge);\n loginUrl.searchParams.append('code_challenge_method', 'S256');\n\n return redirect(loginUrl.toString(), {\n headers: {\n 'Set-Cookie': await session.commit(),\n },\n });\n },\n logout: async () => {\n const idToken = session.get(CUSTOMER_ACCOUNT_SESSION_KEY)?.idToken;\n\n clearSession(session);\n\n return redirect(\n `${customerAccountUrl}/auth/logout?id_token_hint=${idToken}`,\n {\n status: 302,\n\n headers: {\n 'Set-Cookie': await session.commit(),\n },\n },\n );\n },\n isLoggedIn,\n getAccessToken: async () => {\n const hasAccessToken = await isLoggedIn;\n\n if (!hasAccessToken) {\n return;\n } else {\n return session.get(CUSTOMER_ACCOUNT_SESSION_KEY)?.accessToken;\n }\n },\n mutate(mutation, options?) {\n mutation = minifyQuery(mutation);\n assertMutation(mutation, 'customer.mutate');\n\n return fetchCustomerAPI({query: mutation, type: 'mutation', ...options});\n },\n query(query, options?) {\n query = minifyQuery(query);\n assertQuery(query, 'customer.query');\n\n return fetchCustomerAPI({query, type: 'query', ...options});\n },\n authorize: async () => {\n const code = url.searchParams.get('code');\n const state = url.searchParams.get('state');\n\n if (!code || !state) {\n clearSession(session);\n throw new BadRequest(\n 'Unauthorized',\n 'No code or state parameter found in the redirect URL.',\n );\n }\n\n if (session.get(CUSTOMER_ACCOUNT_SESSION_KEY)?.state !== state) {\n clearSession(session);\n throw new BadRequest(\n 'Unauthorized',\n 'The session state does not match the state parameter. Make sure that the session is configured correctly and passed to `createCustomerClient`.',\n );\n }\n\n const clientId = customerAccountId;\n const body = new URLSearchParams();\n\n body.append('grant_type', 'authorization_code');\n body.append('client_id', clientId);\n body.append('redirect_uri', redirectUri);\n body.append('code', code);\n\n // Public Client\n const codeVerifier = session.get(\n CUSTOMER_ACCOUNT_SESSION_KEY,\n )?.codeVerifier;\n\n if (!codeVerifier)\n throw new BadRequest(\n 'Unauthorized',\n 'No code verifier found in the session. Make sure that the session is configured correctly and passed to `createCustomerClient`.',\n );\n\n body.append('code_verifier', codeVerifier);\n\n const headers = {\n 'content-type': 'application/x-www-form-urlencoded',\n 'User-Agent': USER_AGENT,\n Origin: origin,\n };\n\n const response = await fetch(`${customerAccountUrl}/auth/oauth/token`, {\n method: 'POST',\n headers,\n body,\n });\n\n if (!response.ok) {\n throw new Response(await response.text(), {\n status: response.status,\n headers: {\n 'Content-Type': 'text/html; charset=utf-8',\n },\n });\n }\n\n const {access_token, expires_in, id_token, refresh_token} =\n await response.json();\n\n const sessionNonce = session.get(CUSTOMER_ACCOUNT_SESSION_KEY)?.nonce;\n const responseNonce = await getNonce(id_token);\n\n if (sessionNonce !== responseNonce) {\n throw new BadRequest(\n 'Unauthorized',\n `Returned nonce does not match: ${sessionNonce} !== ${responseNonce}`,\n );\n }\n\n const customerAccessToken = await exchangeAccessToken(\n access_token,\n customerAccountId,\n customerAccountUrl,\n origin,\n );\n\n const redirectPath = session.get(\n CUSTOMER_ACCOUNT_SESSION_KEY,\n )?.redirectPath;\n\n session.set(CUSTOMER_ACCOUNT_SESSION_KEY, {\n accessToken: customerAccessToken,\n expiresAt:\n new Date(\n new Date().getTime() + (expires_in! - 120) * 1000,\n ).getTime() + '',\n refreshToken: refresh_token,\n idToken: id_token,\n redirectPath: undefined,\n });\n\n return redirect(redirectPath || '/', {\n headers: {\n 'Set-Cookie': await session.commit(),\n },\n });\n },\n };\n}" }, "CustomerClientOptions": { "filePath": "/customer/customer.ts", "syntaxKind": "TypeAliasDeclaration", "name": "CustomerClientOptions", - "value": "{\n /** The client requires a session to persist the auth and refresh token. By default Hydrogen ships with cookie session storage, but you can use [another session storage](https://remix.run/docs/en/main/utils/sessions) implementation. */\n session: HydrogenSession;\n /** Unique UUID prefixed with `shp_` associated with the application, this should be visible in the customer account api settings in the Hydrogen admin channel. */\n customerAccountId: string;\n /** The account URL associated with the application, this should be visible in the customer account api settings in the Hydrogen admin channel. */\n customerAccountUrl: string;\n /** Override the version of the API */\n customerApiVersion?: string;\n /** The object for the current Request. It should be provided by your platform. */\n request: CrossRuntimeRequest;\n /** The waitUntil function is used to keep the current request/response lifecycle alive even after a response has been sent. It should be provided by your platform. */\n waitUntil?: ExecutionContext['waitUntil'];\n}", + "value": "{\n /** The client requires a session to persist the auth and refresh token. By default Hydrogen ships with cookie session storage, but you can use [another session storage](https://remix.run/docs/en/main/utils/sessions) implementation. */\n session: HydrogenSession;\n /** Unique UUID prefixed with `shp_` associated with the application, this should be visible in the customer account api settings in the Hydrogen admin channel. Mock.shop doesn't automatically supply customerAccountId. Use h2 env pull to link your store credentials. */\n customerAccountId: string;\n /** The account URL associated with the application, this should be visible in the customer account api settings in the Hydrogen admin channel. Mock.shop doesn't automatically supply customerAccountUrl. Use h2 env pull to link your store credentials. */\n customerAccountUrl: string;\n /** Override the version of the API */\n customerApiVersion?: string;\n /** The object for the current Request. It should be provided by your platform. */\n request: CrossRuntimeRequest;\n /** The waitUntil function is used to keep the current request/response lifecycle alive even after a response has been sent. It should be provided by your platform. */\n waitUntil?: ExecutionContext['waitUntil'];\n /** This is the route in your app that authorizes the user after logging in. Make sure to call `customer.authorize()` within the loader on this route. It defaults to `/account/authorize`. */\n authUrl?: string;\n}", "description": "", "members": [ { @@ -8906,14 +8906,14 @@ "syntaxKind": "PropertySignature", "name": "customerAccountId", "value": "string", - "description": "Unique UUID prefixed with `shp_` associated with the application, this should be visible in the customer account api settings in the Hydrogen admin channel." + "description": "Unique UUID prefixed with `shp_` associated with the application, this should be visible in the customer account api settings in the Hydrogen admin channel. Mock.shop doesn't automatically supply customerAccountId. Use h2 env pull to link your store credentials." }, { "filePath": "/customer/customer.ts", "syntaxKind": "PropertySignature", "name": "customerAccountUrl", "value": "string", - "description": "The account URL associated with the application, this should be visible in the customer account api settings in the Hydrogen admin channel." + "description": "The account URL associated with the application, this should be visible in the customer account api settings in the Hydrogen admin channel. Mock.shop doesn't automatically supply customerAccountUrl. Use h2 env pull to link your store credentials." }, { "filePath": "/customer/customer.ts", @@ -8937,6 +8937,14 @@ "value": "ExecutionContext", "description": "The waitUntil function is used to keep the current request/response lifecycle alive even after a response has been sent. It should be provided by your platform.", "isOptional": true + }, + { + "filePath": "/customer/customer.ts", + "syntaxKind": "PropertySignature", + "name": "authUrl", + "value": "string", + "description": "This is the route in your app that authorizes the user after logging in. Make sure to call `customer.authorize()` within the loader on this route. It defaults to `/account/authorize`.", + "isOptional": true } ] }, @@ -8976,22 +8984,22 @@ "filePath": "/customer/customer.ts", "syntaxKind": "TypeAliasDeclaration", "name": "CustomerClient", - "value": "{\n /** Start the OAuth login flow. This function should be called and returned from a Remix action. It redirects the user to a login domain. */\n login: () => Promise;\n /** On successful login, the user is redirect back to your app. This function validates the OAuth response and exchanges the authorization code for an access token and refresh token. It also persists the tokens on your session. This function should be called and returned from the Remix loader configured as the redirect URI within the Customer Account API settings. */\n authorize: (redirectPath?: string) => Promise;\n /** Returns if the user is logged in. It also checks if the access token is expired and refreshes it if needed. */\n isLoggedIn: () => Promise;\n /** Returns CustomerAccessToken if the user is logged in. It also run a expirey check and does a token refresh if needed. */\n getAccessToken: () => Promise;\n /** Logout the user by clearing the session and redirecting to the login domain. It should be called and returned from a Remix action. */\n logout: () => Promise;\n /** Execute a GraphQL query against the Customer Account API. Usually you should first check if the user is logged in before querying the API. */\n query: <\n OverrideReturnType extends any = never,\n RawGqlString extends string = string,\n >(\n query: RawGqlString,\n ...options: ClientVariablesInRestParams<\n CustomerAccountQueries,\n RawGqlString\n >\n ) => Promise<\n CustomerAPIResponse<\n ClientReturn\n >\n >;\n /** Execute a GraphQL mutation against the Customer Account API. Usually you should first check if the user is logged in before querying the API. */\n mutate: <\n OverrideReturnType extends any = never,\n RawGqlString extends string = string,\n >(\n mutation: RawGqlString,\n ...options: ClientVariablesInRestParams<\n CustomerAccountMutations,\n RawGqlString\n >\n ) => Promise<\n CustomerAPIResponse<\n ClientReturn\n >\n >;\n}", + "value": "{\n /** Start the OAuth login flow. This function should be called and returned from a Remix action. It redirects the user to a login domain. An optional `redirectPath` parameter defines the final path the user lands on at the end of the oAuth flow. It defaults to `/`. */\n login: (redirectPath?: string) => Promise;\n /** On successful login, the user redirects back to your app. This function validates the OAuth response and exchanges the authorization code for an access token and refresh token. It also persists the tokens on your session. This function should be called and returned from the Remix loader configured as the redirect URI within the Customer Account API settings. */\n authorize: () => Promise;\n /** Returns if the user is logged in. It also checks if the access token is expired and refreshes it if needed. */\n isLoggedIn: () => Promise;\n /** Returns CustomerAccessToken if the user is logged in. It also run a expirey check and does a token refresh if needed. */\n getAccessToken: () => Promise;\n /** Logout the user by clearing the session and redirecting to the login domain. It should be called and returned from a Remix action. */\n logout: () => Promise;\n /** Execute a GraphQL query against the Customer Account API. Usually you should first check if the user is logged in before querying the API. */\n query: <\n OverrideReturnType extends any = never,\n RawGqlString extends string = string,\n >(\n query: RawGqlString,\n ...options: ClientVariablesInRestParams<\n CustomerAccountQueries,\n RawGqlString\n >\n ) => Promise<\n CustomerAPIResponse<\n ClientReturn\n >\n >;\n /** Execute a GraphQL mutation against the Customer Account API. Usually you should first check if the user is logged in before querying the API. */\n mutate: <\n OverrideReturnType extends any = never,\n RawGqlString extends string = string,\n >(\n mutation: RawGqlString,\n ...options: ClientVariablesInRestParams<\n CustomerAccountMutations,\n RawGqlString\n >\n ) => Promise<\n CustomerAPIResponse<\n ClientReturn\n >\n >;\n}", "description": "", "members": [ { "filePath": "/customer/customer.ts", "syntaxKind": "PropertySignature", "name": "login", - "value": "() => Promise", - "description": "Start the OAuth login flow. This function should be called and returned from a Remix action. It redirects the user to a login domain." + "value": "(redirectPath?: string) => Promise", + "description": "Start the OAuth login flow. This function should be called and returned from a Remix action. It redirects the user to a login domain. An optional `redirectPath` parameter defines the final path the user lands on at the end of the oAuth flow. It defaults to `/`." }, { "filePath": "/customer/customer.ts", "syntaxKind": "PropertySignature", "name": "authorize", - "value": "(redirectPath?: string) => Promise", - "description": "On successful login, the user is redirect back to your app. This function validates the OAuth response and exchanges the authorization code for an access token and refresh token. It also persists the tokens on your session. This function should be called and returned from the Remix loader configured as the redirect URI within the Customer Account API settings." + "value": "() => Promise", + "description": "On successful login, the user redirects back to your app. This function validates the OAuth response and exchanges the authorization code for an access token and refresh token. It also persists the tokens on your session. This function should be called and returned from the Remix loader configured as the redirect URI within the Customer Account API settings." }, { "filePath": "/customer/customer.ts", @@ -9719,7 +9727,7 @@ "name": "Promise", "value": "Promise" }, - "value": "export async function storefrontRedirect(\n options: StorefrontRedirect,\n): Promise {\n const {\n storefront,\n request,\n noAdminRedirect,\n response = new Response('Not Found', {status: 404}),\n } = options;\n\n const {pathname, search} = new URL(request.url);\n const redirectFrom = pathname + search;\n\n if (pathname === '/admin' && !noAdminRedirect) {\n return redirect(`${storefront.getShopifyDomain()}/admin`);\n }\n\n try {\n const {urlRedirects} = await storefront.query<{\n urlRedirects: UrlRedirectConnection;\n }>(REDIRECT_QUERY, {\n variables: {query: 'path:' + redirectFrom},\n });\n\n const location = urlRedirects?.edges?.[0]?.node?.target;\n\n if (location) {\n return new Response(null, {status: 301, headers: {location}});\n }\n\n const searchParams = new URLSearchParams(search);\n const redirectTo =\n searchParams.get('return_to') || searchParams.get('redirect');\n\n if (redirectTo) {\n if (isLocalPath(redirectTo)) {\n return redirect(redirectTo);\n } else {\n console.warn(\n `Cross-domain redirects are not supported. Tried to redirect from ${redirectFrom} to ${redirectTo}`,\n );\n }\n }\n } catch (error) {\n console.error(\n `Failed to fetch redirects from Storefront API for route ${redirectFrom}`,\n error,\n );\n }\n\n return response;\n}" + "value": "export async function storefrontRedirect(\n options: StorefrontRedirect,\n): Promise {\n const {\n storefront,\n request,\n noAdminRedirect,\n response = new Response('Not Found', {status: 404}),\n } = options;\n\n const {pathname, search} = new URL(request.url);\n const redirectFrom = pathname + search;\n\n if (pathname === '/admin' && !noAdminRedirect) {\n return redirect(`${storefront.getShopifyDomain()}/admin`);\n }\n\n try {\n const {urlRedirects} = await storefront.query<{\n urlRedirects: UrlRedirectConnection;\n }>(REDIRECT_QUERY, {\n variables: {query: 'path:' + redirectFrom},\n });\n\n const location = urlRedirects?.edges?.[0]?.node?.target;\n\n if (location) {\n return new Response(null, {status: 301, headers: {location}});\n }\n\n const searchParams = new URLSearchParams(search);\n const redirectTo =\n searchParams.get('return_to') || searchParams.get('redirect');\n\n if (redirectTo) {\n if (isLocalPath(request.url, redirectTo)) {\n return redirect(redirectTo);\n } else {\n console.warn(\n `Cross-domain redirects are not supported. Tried to redirect from ${redirectFrom} to ${redirectTo}`,\n );\n }\n }\n } catch (error) {\n console.error(\n `Failed to fetch redirects from Storefront API for route ${redirectFrom}`,\n error,\n );\n }\n\n return response;\n}" }, "StorefrontRedirect": { "filePath": "/routing/redirect.ts", @@ -9911,7 +9919,7 @@ "filePath": "/storefront.ts", "syntaxKind": "TypeAliasDeclaration", "name": "StorefrontQueryOptions", - "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n}", + "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n /** The name to be shown in the Subrequest Profiler */\n displayName?: string;\n}", "description": "" }, "CachingStrategy": { @@ -10161,7 +10169,7 @@ "name": "CreateWithCacheReturn", "value": "CreateWithCacheReturn" }, - "value": "export function createWithCache({\n cache,\n waitUntil,\n request,\n}: CreateWithCacheOptions): CreateWithCacheReturn {\n return function withCache(\n cacheKey: CacheKey,\n strategy: CachingStrategy,\n actionFn: () => T | Promise,\n ) {\n return runWithCache(cacheKey, actionFn, {\n strategy,\n cacheInstance: cache,\n waitUntil,\n debugInfo: getDebugHeaders(request),\n });\n };\n}" + "value": "export function createWithCache({\n cache,\n waitUntil,\n request,\n}: CreateWithCacheOptions): CreateWithCacheReturn {\n return function withCache(\n cacheKey: CacheKey,\n strategy: CachingStrategy,\n actionFn: ({addDebugData}: CacheActionFunctionParam) => T | Promise,\n ) {\n return runWithCache(cacheKey, actionFn, {\n strategy,\n cacheInstance: cache,\n waitUntil,\n debugInfo: {\n ...getDebugHeaders(request),\n stackInfo: getCallerStackLine?.(),\n },\n });\n };\n}" }, "CreateWithCacheOptions": { "filePath": "/with-cache.ts", @@ -10246,7 +10254,7 @@ { "name": "actionFn", "description": "", - "value": "() => U | Promise", + "value": "({ addDebugData }: CacheActionFunctionParam) => U | Promise", "filePath": "/with-cache.ts" } ], @@ -10256,7 +10264,7 @@ "name": "interface Promise {\r\n /**\r\n * Attaches callbacks for the resolution and/or rejection of the Promise.\r\n * @param onfulfilled The callback to execute when the Promise is resolved.\r\n * @param onrejected The callback to execute when the Promise is rejected.\r\n * @returns A Promise for the completion of which ever callback is executed.\r\n */\r\n then(onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null): Promise;\r\n\r\n /**\r\n * Attaches a callback for only the rejection of the Promise.\r\n * @param onrejected The callback to execute when the Promise is rejected.\r\n * @returns A Promise for the completion of the callback.\r\n */\r\n catch(onrejected?: ((reason: any) => TResult | PromiseLike) | undefined | null): Promise;\r\n}, interface Promise { }, Promise: PromiseConstructor, interface Promise {\r\n readonly [Symbol.toStringTag]: string;\r\n}, interface Promise {\r\n /**\r\n * Attaches a callback that is invoked when the Promise is settled (fulfilled or rejected). The\r\n * resolved value cannot be modified from the callback.\r\n * @param onfinally The callback to execute when the Promise is settled (fulfilled or rejected).\r\n * @returns A Promise for the completion of the callback.\r\n */\r\n finally(onfinally?: (() => void) | undefined | null): Promise\r\n}", "value": "interface Promise {\r\n /**\r\n * Attaches callbacks for the resolution and/or rejection of the Promise.\r\n * @param onfulfilled The callback to execute when the Promise is resolved.\r\n * @param onrejected The callback to execute when the Promise is rejected.\r\n * @returns A Promise for the completion of which ever callback is executed.\r\n */\r\n then(onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null): Promise;\r\n\r\n /**\r\n * Attaches a callback for only the rejection of the Promise.\r\n * @param onrejected The callback to execute when the Promise is rejected.\r\n * @returns A Promise for the completion of the callback.\r\n */\r\n catch(onrejected?: ((reason: any) => TResult | PromiseLike) | undefined | null): Promise;\r\n}, interface Promise { }, Promise: PromiseConstructor, interface Promise {\r\n readonly [Symbol.toStringTag]: string;\r\n}, interface Promise {\r\n /**\r\n * Attaches a callback that is invoked when the Promise is settled (fulfilled or rejected). The\r\n * resolved value cannot be modified from the callback.\r\n * @param onfinally The callback to execute when the Promise is settled (fulfilled or rejected).\r\n * @returns A Promise for the completion of the callback.\r\n */\r\n finally(onfinally?: (() => void) | undefined | null): Promise\r\n}" }, - "value": "type CreateWithCacheReturn = (\n cacheKey: CacheKey,\n strategy: CachingStrategy,\n actionFn: () => U | Promise,\n) => Promise;" + "value": "type CreateWithCacheReturn = (\n cacheKey: CacheKey,\n strategy: CachingStrategy,\n actionFn: ({addDebugData}: CacheActionFunctionParam) => U | Promise,\n) => Promise;" }, "CacheKey": { "filePath": "/cache/fetch.ts", @@ -10312,6 +10320,47 @@ } ], "value": "export interface AllCacheOptions {\n /**\n * The caching mode, generally `public`, `private`, or `no-store`.\n */\n mode?: string;\n /**\n * The maximum amount of time in seconds that a resource will be considered fresh. See `max-age` in the [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#:~:text=Response%20Directives-,max%2Dage,-The%20max%2Dage).\n */\n maxAge?: number;\n /**\n * Indicate that the cache should serve the stale response in the background while revalidating the cache. See `stale-while-revalidate` in the [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#stale-while-revalidate).\n */\n staleWhileRevalidate?: number;\n /**\n * Similar to `maxAge` but specific to shared caches. See `s-maxage` in the [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#s-maxage).\n */\n sMaxAge?: number;\n /**\n * Indicate that the cache should serve the stale response if an error occurs while revalidating the cache. See `stale-if-error` in the [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#stale-if-error).\n */\n staleIfError?: number;\n}" + }, + "CacheActionFunctionParam": { + "filePath": "/cache/fetch.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "CacheActionFunctionParam", + "value": "{\n addDebugData: (info: AddDebugDataParam) => void;\n}", + "description": "", + "members": [ + { + "filePath": "/cache/fetch.ts", + "syntaxKind": "PropertySignature", + "name": "addDebugData", + "value": "(info: AddDebugDataParam) => void", + "description": "" + } + ] + }, + "AddDebugDataParam": { + "filePath": "/cache/fetch.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "AddDebugDataParam", + "value": "{\n displayName?: string;\n response?: Response;\n}", + "description": "", + "members": [ + { + "filePath": "/cache/fetch.ts", + "syntaxKind": "PropertySignature", + "name": "displayName", + "value": "string", + "description": "", + "isOptional": true + }, + { + "filePath": "/cache/fetch.ts", + "syntaxKind": "PropertySignature", + "name": "response", + "value": "Response", + "description": "", + "isOptional": true + } + ] } } } diff --git a/packages/hydrogen/src/customer/customer.doc.ts b/packages/hydrogen/src/customer/customer.doc.ts index 9a2ace7849..72df75120e 100644 --- a/packages/hydrogen/src/customer/customer.doc.ts +++ b/packages/hydrogen/src/customer/customer.doc.ts @@ -12,9 +12,7 @@ const data: ReferenceEntityTemplateSchema = { }, ], description: ` -The \`createCustomerClient\` function creates a GraphQL client for querying the [Customer Account API](https://shopify.dev/docs/api/customer). It also provides methods to authenticate and check if the user is logged in. - -See an end to end [example on using the Customer Account API client](https://github.com/Shopify/hydrogen/tree/main/examples/customer-api).`, +The \`createCustomerClient\` function creates a GraphQL client for querying the [Customer Account API](https://shopify.dev/docs/api/customer). It also provides methods to authenticate and check if the user is logged in.`, type: 'utility', defaultExample: { description: 'I am the default example', diff --git a/packages/hydrogen/src/customer/customer.test.ts b/packages/hydrogen/src/customer/customer.test.ts index a6d9dabcf4..881e59ac8b 100644 --- a/packages/hydrogen/src/customer/customer.test.ts +++ b/packages/hydrogen/src/customer/customer.test.ts @@ -130,7 +130,9 @@ describe('customer', () => { 'openid email https://api.customers.com/auth/customer.graphql', ); expect(params.get('response_type')).toBe('code'); - expect(params.get('redirect_uri')).toBe('https://localhost/authorize'); + expect(params.get('redirect_uri')).toBe( + 'https://localhost/account/authorize', + ); expect(params.get('state')).toBeTruthy(); expect(params.get('nonce')).toBeTruthy(); expect(params.get('code_challenge')).toBeTruthy(); diff --git a/packages/hydrogen/src/customer/customer.ts b/packages/hydrogen/src/customer/customer.ts index fe23861e63..8d20f3ade5 100644 --- a/packages/hydrogen/src/customer/customer.ts +++ b/packages/hydrogen/src/customer/customer.ts @@ -67,10 +67,10 @@ export interface CustomerAccountMutations { } export type CustomerClient = { - /** Start the OAuth login flow. This function should be called and returned from a Remix action. It redirects the user to a login domain. */ - login: () => Promise; - /** On successful login, the user is redirect back to your app. This function validates the OAuth response and exchanges the authorization code for an access token and refresh token. It also persists the tokens on your session. This function should be called and returned from the Remix loader configured as the redirect URI within the Customer Account API settings. */ - authorize: (redirectPath?: string) => Promise; + /** Start the OAuth login flow. This function should be called and returned from a Remix action. It redirects the user to a login domain. An optional `redirectPath` parameter defines the final path the user lands on at the end of the oAuth flow. It defaults to `/`. */ + login: (redirectPath?: string) => Promise; + /** On successful login, the user redirects back to your app. This function validates the OAuth response and exchanges the authorization code for an access token and refresh token. It also persists the tokens on your session. This function should be called and returned from the Remix loader configured as the redirect URI within the Customer Account API settings. */ + authorize: () => Promise; /** Returns if the user is logged in. It also checks if the access token is expired and refreshes it if needed. */ isLoggedIn: () => Promise; /** Returns CustomerAccessToken if the user is logged in. It also run a expirey check and does a token refresh if needed. */ @@ -112,9 +112,9 @@ export type CustomerClient = { type CustomerClientOptions = { /** The client requires a session to persist the auth and refresh token. By default Hydrogen ships with cookie session storage, but you can use [another session storage](https://remix.run/docs/en/main/utils/sessions) implementation. */ session: HydrogenSession; - /** Unique UUID prefixed with `shp_` associated with the application, this should be visible in the customer account api settings in the Hydrogen admin channel. */ + /** Unique UUID prefixed with `shp_` associated with the application, this should be visible in the customer account api settings in the Hydrogen admin channel. Mock.shop doesn't automatically supply customerAccountId. Use h2 env pull to link your store credentials. */ customerAccountId: string; - /** The account URL associated with the application, this should be visible in the customer account api settings in the Hydrogen admin channel. */ + /** The account URL associated with the application, this should be visible in the customer account api settings in the Hydrogen admin channel. Mock.shop doesn't automatically supply customerAccountUrl. Use h2 env pull to link your store credentials. */ customerAccountUrl: string; /** Override the version of the API */ customerApiVersion?: string; @@ -122,6 +122,8 @@ type CustomerClientOptions = { request: CrossRuntimeRequest; /** The waitUntil function is used to keep the current request/response lifecycle alive even after a response has been sent. It should be provided by your platform. */ waitUntil?: ExecutionContext['waitUntil']; + /** This is the route in your app that authorizes the user after logging in. Make sure to call `customer.authorize()` within the loader on this route. It defaults to `/account/authorize`. */ + authUrl?: string; }; export function createCustomerClient({ @@ -131,6 +133,7 @@ export function createCustomerClient({ customerApiVersion = DEFAULT_CUSTOMER_API_VERSION, request, waitUntil, + authUrl = '/account/authorize', }: CustomerClientOptions): CustomerClient { if (customerApiVersion !== DEFAULT_CUSTOMER_API_VERSION) { console.log( @@ -138,6 +141,12 @@ export function createCustomerClient({ ); } + if (!customerAccountId || !customerAccountUrl) { + console.log( + "[h2:warn:createCustomerClient] customerAccountId and customerAccountUrl need to be provided to use Customer Account API. mock.shop doesn't automatically supply these variables. Use `h2 env pull` to link your store credentials.", + ); + } + if (!request?.url) { throw new Error( '[h2:error:createCustomerClient] The request object does not contain a URL.', @@ -146,6 +155,7 @@ export function createCustomerClient({ const url = new URL(request.url); const origin = url.protocol === 'http:' ? url.origin.replace('http', 'https') : url.origin; + const redirectUri = authUrl.startsWith('/') ? origin + authUrl : authUrl; const locks: Locks = {}; @@ -278,7 +288,7 @@ export function createCustomerClient({ } return { - login: async () => { + login: async (redirectPath?: string) => { const loginUrl = new URL(customerAccountUrl + '/auth/oauth/authorize'); const state = await generateState(); @@ -287,7 +297,7 @@ export function createCustomerClient({ loginUrl.searchParams.set('client_id', customerAccountId); loginUrl.searchParams.set('scope', 'openid email'); loginUrl.searchParams.append('response_type', 'code'); - loginUrl.searchParams.append('redirect_uri', origin + '/authorize'); + loginUrl.searchParams.append('redirect_uri', redirectUri); loginUrl.searchParams.set( 'scope', 'openid email https://api.customers.com/auth/customer.graphql', @@ -303,6 +313,7 @@ export function createCustomerClient({ codeVerifier: verifier, state, nonce, + redirectPath, }); loginUrl.searchParams.append('code_challenge', challenge); @@ -352,7 +363,7 @@ export function createCustomerClient({ return fetchCustomerAPI({query, type: 'query', ...options}); }, - authorize: async (redirectPath = '/') => { + authorize: async () => { const code = url.searchParams.get('code'); const state = url.searchParams.get('state'); @@ -377,7 +388,7 @@ export function createCustomerClient({ body.append('grant_type', 'authorization_code'); body.append('client_id', clientId); - body.append('redirect_uri', origin + '/authorize'); + body.append('redirect_uri', redirectUri); body.append('code', code); // Public Client @@ -434,6 +445,10 @@ export function createCustomerClient({ origin, ); + const redirectPath = session.get( + CUSTOMER_ACCOUNT_SESSION_KEY, + )?.redirectPath; + session.set(CUSTOMER_ACCOUNT_SESSION_KEY, { accessToken: customerAccessToken, expiresAt: @@ -442,9 +457,10 @@ export function createCustomerClient({ ).getTime() + '', refreshToken: refresh_token, idToken: id_token, + redirectPath: undefined, }); - return redirect(redirectPath, { + return redirect(redirectPath || '/', { headers: { 'Set-Cookie': await session.commit(), }, diff --git a/packages/hydrogen/src/hydrogen.d.ts b/packages/hydrogen/src/hydrogen.d.ts index 4d3eeae02e..686ba927e7 100644 --- a/packages/hydrogen/src/hydrogen.d.ts +++ b/packages/hydrogen/src/hydrogen.d.ts @@ -14,6 +14,7 @@ export interface HydrogenSessionData { idToken?: string; nonce?: string; state?: string; + redirectPath?: string; }; } diff --git a/packages/hydrogen/src/utils/callsites.ts b/packages/hydrogen/src/utils/callsites.ts index f21e428f0e..c4b4dbcc83 100644 --- a/packages/hydrogen/src/utils/callsites.ts +++ b/packages/hydrogen/src/utils/callsites.ts @@ -26,10 +26,10 @@ export const getCallerStackLine = const cs = callsites[2 + stackOffset]; stackInfo = { - file: cs.getFileName() ?? undefined, - func: cs.getFunctionName() ?? undefined, - line: cs.getLineNumber() ?? undefined, - column: cs.getColumnNumber() ?? undefined, + file: cs?.getFileName() ?? undefined, + func: cs?.getFunctionName() ?? undefined, + line: cs?.getLineNumber() ?? undefined, + column: cs?.getColumnNumber() ?? undefined, }; return ''; diff --git a/templates/demo-store/.env b/templates/demo-store/.env index 9d6b66d51d..6a2965f8d3 100644 --- a/templates/demo-store/.env +++ b/templates/demo-store/.env @@ -1,5 +1,7 @@ # These variables are only available locally in MiniOxygen SESSION_SECRET="foobar" -PUBLIC_STOREFRONT_API_TOKEN="3b580e70970c4528da70c98e097c2fa0" -PUBLIC_STORE_DOMAIN="hydrogen-preview.myshopify.com" +PUBLIC_STOREFRONT_API_TOKEN=14cdb1b48742401da3963517bb5a1700 +PUBLIC_STORE_DOMAIN=hydrogen-preview.myshopify.com +PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID=shp_7318c7c5-46d4-4090-a9aa-a08424aafd00 +PUBLIC_CUSTOMER_ACCOUNT_API_URL=https://shopify.com/55145660472 diff --git a/templates/demo-store/.graphqlrc.yml b/templates/demo-store/.graphqlrc.yml index bd38d076bc..eee81eddc0 100644 --- a/templates/demo-store/.graphqlrc.yml +++ b/templates/demo-store/.graphqlrc.yml @@ -1 +1,12 @@ -schema: node_modules/@shopify/hydrogen-react/storefront.schema.json +projects: + default: + schema: 'node_modules/@shopify/hydrogen/storefront.schema.json' + documents: + - '!*.d.ts' + - '*.{ts,tsx,js,jsx}' + - 'app/**/*.{ts,tsx,js,jsx}' + - '!app/graphql/**/*.{ts,tsx,js,jsx}' + customer-account: + schema: 'node_modules/@shopify/hydrogen/customer-account.schema.json' + documents: + - 'app/graphql/customer-account/**/*.{ts,tsx,js,jsx}' diff --git a/templates/demo-store/README.md b/templates/demo-store/README.md index 7da233750f..23d74cf48c 100644 --- a/templates/demo-store/README.md +++ b/templates/demo-store/README.md @@ -41,3 +41,18 @@ npm run build ```bash npm run dev ``` + +## Setup for using Customer Account API (`/account` section) + +### Setup public domain using ngrok + +1. Setup a [ngrok](https://ngrok.com/) account and add a permanent domain (ie. `https://.app`). +1. Install the [ngrok CLI](https://ngrok.com/download) to use in terminal +1. Start ngrok using `ngrok http --domain=.app 3000` + +### Include public domain in Customer Account API settings + +1. Go to your Shopify admin => `Hydrogen` or `Headless` app/channel => Customer Account API => Application setup +1. Edit `Callback URI(s)` to include `https://.app/account/authorize` +1. Edit `Javascript origin(s)` to include your public domain `https://.app` or keep it blank +1. Edit `Logout URI` to include your public domain `https://.app` or keep it blank diff --git a/templates/demo-store/app/components/AccountAddressBook.tsx b/templates/demo-store/app/components/AccountAddressBook.tsx index c053254bfb..1b602fb3ba 100644 --- a/templates/demo-store/app/components/AccountAddressBook.tsx +++ b/templates/demo-store/app/components/AccountAddressBook.tsx @@ -1,7 +1,7 @@ import {Form} from '@remix-run/react'; -import type {MailingAddress} from '@shopify/hydrogen/storefront-api-types'; +import type {CustomerAddress} from '@shopify/hydrogen/customer-account-api-types'; -import type {CustomerDetailsFragment} from 'storefrontapi.generated'; +import type {CustomerDetailsFragment} from 'customer-accountapi.generated'; import {Button, Link, Text} from '~/components'; export function AccountAddressBook({ @@ -9,7 +9,7 @@ export function AccountAddressBook({ addresses, }: { customer: CustomerDetailsFragment; - addresses: MailingAddress[]; + addresses: CustomerAddress[]; }) { return ( <> @@ -52,7 +52,7 @@ function Address({ address, defaultAddress, }: { - address: MailingAddress; + address: CustomerAddress; defaultAddress?: boolean; }) { return ( diff --git a/templates/demo-store/app/components/AccountDetails.tsx b/templates/demo-store/app/components/AccountDetails.tsx index 641d8937f5..7b0dfc5ce1 100644 --- a/templates/demo-store/app/components/AccountDetails.tsx +++ b/templates/demo-store/app/components/AccountDetails.tsx @@ -1,4 +1,4 @@ -import type {CustomerDetailsFragment} from 'storefrontapi.generated'; +import type {CustomerDetailsFragment} from 'customer-accountapi.generated'; import {Link} from '~/components'; export function AccountDetails({ @@ -6,7 +6,7 @@ export function AccountDetails({ }: { customer: CustomerDetailsFragment; }) { - const {firstName, lastName, email, phone} = customer; + const {firstName, lastName, emailAddress, phoneNumber} = customer; return ( <> @@ -14,7 +14,7 @@ export function AccountDetails({

Account Details

-

Profile & Security

+

Profile

-
Contact
-

{phone ?? 'Add mobile'}

+
Phone number
+

{phoneNumber?.phoneNumber ?? 'N/A'}

Email address
-

{email}

- -
Password
-

**************

+

{emailAddress?.emailAddress ?? 'N/A'}

diff --git a/templates/demo-store/app/components/Layout.tsx b/templates/demo-store/app/components/Layout.tsx index bd6215a617..e83ea6e7ad 100644 --- a/templates/demo-store/app/components/Layout.tsx +++ b/templates/demo-store/app/components/Layout.tsx @@ -327,13 +327,13 @@ function AccountLink({className}: {className?: string}) { const rootData = useRootLoaderData(); const isLoggedIn = rootData?.isLoggedIn; - return isLoggedIn ? ( + return ( - - - ) : ( - - + }> + }> + {(isLoggedIn) => (isLoggedIn ? : )} + + ); } diff --git a/templates/demo-store/app/components/OrderCard.tsx b/templates/demo-store/app/components/OrderCard.tsx index 1b57a6e3e5..b904d640ef 100644 --- a/templates/demo-store/app/components/OrderCard.tsx +++ b/templates/demo-store/app/components/OrderCard.tsx @@ -1,6 +1,6 @@ import {flattenConnection, Image} from '@shopify/hydrogen'; -import type {OrderCardFragment} from 'storefrontapi.generated'; +import type {OrderCardFragment} from 'customer-accountapi.generated'; import {Heading, Text, Link} from '~/components'; import {statusMessage} from '~/lib/utils'; @@ -8,6 +8,7 @@ export function OrderCard({order}: {order: OrderCardFragment}) { if (!order?.id) return null; const [legacyOrderId, key] = order!.id!.split('/').pop()!.split('?'); const lineItems = flattenConnection(order?.lineItems); + const fulfillmentStatus = flattenConnection(order?.fulfillments)[0]?.status; return (
  • @@ -16,20 +17,20 @@ export function OrderCard({order}: {order: OrderCardFragment}) { to={`/account/orders/${legacyOrderId}?${key}`} prefetch="intent" > - {lineItems[0].variant?.image && ( + {lineItems[0].image && (
    {lineItems[0].variant?.image?.altText
    )}
    @@ -41,7 +42,7 @@ export function OrderCard({order}: {order: OrderCardFragment}) {
    Order ID
    - Order No. {order.orderNumber} + Order No. {order.number}
    Order Date
    @@ -50,20 +51,22 @@ export function OrderCard({order}: {order: OrderCardFragment}) { {new Date(order.processedAt).toDateString()} -
    Fulfillment Status
    -
    - - - {statusMessage(order.fulfillmentStatus)} - - -
    + {fulfillmentStatus && ( + <> +
    Fulfillment Status
    +
    + + {statusMessage(fulfillmentStatus)} + +
    + + )}
    diff --git a/templates/demo-store/app/graphql/customer-account/CustomerAddressMutations.ts b/templates/demo-store/app/graphql/customer-account/CustomerAddressMutations.ts new file mode 100644 index 0000000000..b6c6c53d84 --- /dev/null +++ b/templates/demo-store/app/graphql/customer-account/CustomerAddressMutations.ts @@ -0,0 +1,58 @@ +// NOTE: https://shopify.dev/docs/api/customer/latest/mutations/customerAddressUpdate +export const UPDATE_ADDRESS_MUTATION = `#graphql + mutation customerAddressUpdate( + $address: CustomerAddressInput! + $addressId: ID! + $defaultAddress: Boolean + ) { + customerAddressUpdate( + address: $address + addressId: $addressId + defaultAddress: $defaultAddress + ) { + userErrors { + code + field + message + } + } + } +` as const; + +// NOTE: https://shopify.dev/docs/api/customer/latest/mutations/customerAddressDelete +export const DELETE_ADDRESS_MUTATION = `#graphql + mutation customerAddressDelete( + $addressId: ID!, + ) { + customerAddressDelete(addressId: $addressId) { + deletedAddressId + userErrors { + code + field + message + } + } + } +` as const; + +// NOTE: https://shopify.dev/docs/api/customer/latest/mutations/customerAddressCreate +export const CREATE_ADDRESS_MUTATION = `#graphql + mutation customerAddressCreate( + $address: CustomerAddressInput! + $defaultAddress: Boolean + ) { + customerAddressCreate( + address: $address + defaultAddress: $defaultAddress + ) { + customerAddress { + id + } + userErrors { + code + field + message + } + } + } +` as const; diff --git a/templates/demo-store/app/graphql/customer-account/CustomerDetailsQuery.tsx b/templates/demo-store/app/graphql/customer-account/CustomerDetailsQuery.tsx new file mode 100644 index 0000000000..05ed3a4101 --- /dev/null +++ b/templates/demo-store/app/graphql/customer-account/CustomerDetailsQuery.tsx @@ -0,0 +1,83 @@ +const CUSTOMER_FRAGMENT = `#graphql + fragment OrderCard on Order { + id + number + processedAt + financialStatus + fulfillments(first: 1) { + nodes { + status + } + } + totalPrice { + amount + currencyCode + } + lineItems(first: 2) { + edges { + node { + title + image { + altText + height + url + width + } + } + } + } + } + + fragment AddressPartial on CustomerAddress { + id + formatted + firstName + lastName + company + address1 + address2 + territoryCode + zoneCode + city + zip + phoneNumber + } + + fragment CustomerDetails on Customer { + firstName + lastName + phoneNumber { + phoneNumber + } + emailAddress { + emailAddress + } + defaultAddress { + ...AddressPartial + } + addresses(first: 6) { + edges { + node { + ...AddressPartial + } + } + } + orders(first: 250, sortKey: PROCESSED_AT, reverse: true) { + edges { + node { + ...OrderCard + } + } + } + } +` as const; + +// NOTE: https://shopify.dev/docs/api/customer/latest/queries/customer +export const CUSTOMER_DETAILS_QUERY = `#graphql + query CustomerDetails { + customer { + ...CustomerDetails + } + } + ${CUSTOMER_FRAGMENT} +` as const; diff --git a/templates/demo-store/app/graphql/customer-account/CustomerOrderQuery.ts b/templates/demo-store/app/graphql/customer-account/CustomerOrderQuery.ts new file mode 100644 index 0000000000..a340520879 --- /dev/null +++ b/templates/demo-store/app/graphql/customer-account/CustomerOrderQuery.ts @@ -0,0 +1,87 @@ +// NOTE: https://shopify.dev/docs/api/customer/latest/queries/order +export const CUSTOMER_ORDER_QUERY = `#graphql + fragment OrderMoney on MoneyV2 { + amount + currencyCode + } + fragment DiscountApplication on DiscountApplication { + value { + __typename + ... on MoneyV2 { + ...OrderMoney + } + ... on PricingPercentageValue { + percentage + } + } + } + fragment OrderLineItemFull on LineItem { + id + title + quantity + price { + ...OrderMoney + } + discountAllocations { + allocatedAmount { + ...OrderMoney + } + discountApplication { + ...DiscountApplication + } + } + totalDiscount { + ...OrderMoney + } + image { + altText + height + url + id + width + } + variantTitle + } + fragment Order on Order { + id + name + statusPageUrl + processedAt + fulfillments(first: 1) { + nodes { + status + } + } + totalTax { + ...OrderMoney + } + totalPrice { + ...OrderMoney + } + subtotal { + ...OrderMoney + } + shippingAddress { + name + formatted(withName: true) + formattedArea + } + discountApplications(first: 100) { + nodes { + ...DiscountApplication + } + } + lineItems(first: 100) { + nodes { + ...OrderLineItemFull + } + } + } + query Order($orderId: ID!) { + order(id: $orderId) { + ... on Order { + ...Order + } + } + } +` as const; diff --git a/templates/demo-store/app/graphql/customer-account/CustomerUpdateMutation.tsx b/templates/demo-store/app/graphql/customer-account/CustomerUpdateMutation.tsx new file mode 100644 index 0000000000..02e2f7f40d --- /dev/null +++ b/templates/demo-store/app/graphql/customer-account/CustomerUpdateMutation.tsx @@ -0,0 +1,11 @@ +export const CUSTOMER_UPDATE_MUTATION = `#graphql +mutation customerUpdate($customer: CustomerUpdateInput!) { + customerUpdate(input: $customer) { + userErrors { + code + field + message + } + } +} +`; diff --git a/templates/demo-store/app/lib/utils.ts b/templates/demo-store/app/lib/utils.ts index 2e9d883bf8..d12f7ea648 100644 --- a/templates/demo-store/app/lib/utils.ts +++ b/templates/demo-store/app/lib/utils.ts @@ -1,14 +1,15 @@ import {useLocation, useMatches} from '@remix-run/react'; import type {MoneyV2} from '@shopify/hydrogen/storefront-api-types'; +import type {FulfillmentStatus} from '@shopify/hydrogen/customer-account-api-types'; import typographicBase from 'typographic-base'; -import {useRootLoaderData} from '~/root'; -import {countries} from '~/data/countries'; import type { ChildMenuItemFragment, MenuFragment, ParentMenuItemFragment, } from 'storefrontapi.generated'; +import {useRootLoaderData} from '~/root'; +import {countries} from '~/data/countries'; import type {I18nLocale} from './type'; @@ -231,32 +232,14 @@ export const getInputStyleClasses = (isError?: string | null) => { }`; }; -export function statusMessage(status: string) { - const translations: Record = { - ATTEMPTED_DELIVERY: 'Attempted delivery', - CANCELED: 'Canceled', - CONFIRMED: 'Confirmed', - DELIVERED: 'Delivered', - FAILURE: 'Failure', - FULFILLED: 'Fulfilled', - IN_PROGRESS: 'In Progress', - IN_TRANSIT: 'In transit', - LABEL_PRINTED: 'Label printed', - LABEL_PURCHASED: 'Label purchased', - LABEL_VOIDED: 'Label voided', - MARKED_AS_FULFILLED: 'Marked as fulfilled', - NOT_DELIVERED: 'Not delivered', - ON_HOLD: 'On Hold', +export function statusMessage(status: FulfillmentStatus) { + const translations: Record = { + SUCCESS: 'Success', + PENDING: 'Pending', OPEN: 'Open', - OUT_FOR_DELIVERY: 'Out for delivery', - PARTIALLY_FULFILLED: 'Partially Fulfilled', - PENDING_FULFILLMENT: 'Pending', - PICKED_UP: 'Displayed as Picked up', - READY_FOR_PICKUP: 'Ready for pickup', - RESTOCKED: 'Restocked', - SCHEDULED: 'Scheduled', - SUBMITTED: 'Submitted', - UNFULFILLED: 'Unfulfilled', + FAILURE: 'Failure', + ERROR: 'Error', + CANCELLED: 'Cancelled', }; try { return translations?.[status]; @@ -265,16 +248,6 @@ export function statusMessage(status: string) { } } -/** - * Errors can exist in an errors object, or nested in a data field. - */ -export function assertApiErrors(data: Record | null | undefined) { - const errorMessage = data?.customerUserErrors?.[0]?.message; - if (errorMessage) { - throw new Error(errorMessage); - } -} - export const DEFAULT_LOCALE: I18nLocale = Object.freeze({ ...countries.default, pathPrefix: '', diff --git a/templates/demo-store/app/root.tsx b/templates/demo-store/app/root.tsx index 60c52539b8..9a26727810 100644 --- a/templates/demo-store/app/root.tsx +++ b/templates/demo-store/app/root.tsx @@ -72,25 +72,30 @@ export const useRootLoaderData = () => { }; export async function loader({request, context}: LoaderFunctionArgs) { - const {session, storefront, cart} = context; - const [customerAccessToken, layout] = await Promise.all([ - session.get('customerAccessToken'), - getLayoutData(context), - ]); + const {storefront, cart} = context; + const layout = await getLayoutData(context); + const isLoggedInPromise = context.customerAccount.isLoggedIn(); const seo = seoPayload.root({shop: layout.shop, url: request.url}); - return defer({ - isLoggedIn: Boolean(customerAccessToken), - layout, - selectedLocale: storefront.i18n, - cart: cart.get(), - analytics: { - shopifySalesChannel: ShopifySalesChannel.hydrogen, - shopId: layout.shop.id, + return defer( + { + isLoggedIn: isLoggedInPromise, + layout, + selectedLocale: storefront.i18n, + cart: cart.get(), + analytics: { + shopifySalesChannel: ShopifySalesChannel.hydrogen, + shopId: layout.shop.id, + }, + seo, }, - seo, - }); + { + headers: { + 'Set-Cookie': await context.session.commit(), + }, + }, + ); } export default function App() { diff --git a/templates/demo-store/app/routes/($locale).account.$.tsx b/templates/demo-store/app/routes/($locale).account.$.tsx new file mode 100644 index 0000000000..98262ed68e --- /dev/null +++ b/templates/demo-store/app/routes/($locale).account.$.tsx @@ -0,0 +1,24 @@ +import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen'; + +// fallback wild card for all unauthenticated routes in account section +export async function loader({request, context, params}: LoaderFunctionArgs) { + const locale = params.locale; + + if (await context.customerAccount.isLoggedIn()) { + return redirect(locale ? `/${locale}/account` : '/account', { + headers: { + 'Set-Cookie': await context.session.commit(), + }, + }); + } + + const loginUrl = + (locale ? `/${locale}/account/login` : '/account/login') + + `?${new URLSearchParams(`redirectPath=${request.url}`).toString()}`; + + return redirect(loginUrl, { + headers: { + 'Set-Cookie': await context.session.commit(), + }, + }); +} diff --git a/templates/demo-store/app/routes/($locale).account.activate.$id.$activationToken.tsx b/templates/demo-store/app/routes/($locale).account.activate.$id.$activationToken.tsx deleted file mode 100644 index 018b0e9f97..0000000000 --- a/templates/demo-store/app/routes/($locale).account.activate.$id.$activationToken.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import {json, redirect, type ActionFunction} from '@shopify/remix-oxygen'; -import {Form, useActionData, type MetaFunction} from '@remix-run/react'; -import {useRef, useState} from 'react'; - -import {getInputStyleClasses} from '~/lib/utils'; - -type ActionData = { - formError?: string; -}; - -const badRequest = (data: ActionData) => json(data, {status: 400}); - -export const handle = { - isPublic: true, -}; - -export const action: ActionFunction = async ({ - request, - context, - params: {locale, id, activationToken}, -}) => { - if ( - !id || - !activationToken || - typeof id !== 'string' || - typeof activationToken !== 'string' - ) { - return badRequest({ - formError: 'Wrong token. The link you followed might be wrong.', - }); - } - - const formData = await request.formData(); - - const password = formData.get('password'); - const passwordConfirm = formData.get('passwordConfirm'); - - if ( - !password || - !passwordConfirm || - typeof password !== 'string' || - typeof passwordConfirm !== 'string' || - password !== passwordConfirm - ) { - return badRequest({ - formError: 'Please provide matching passwords', - }); - } - - const {session, storefront} = context; - - try { - const data = await storefront.mutate(CUSTOMER_ACTIVATE_MUTATION, { - variables: { - id: `gid://shopify/Customer/${id}`, - input: { - password, - activationToken, - }, - }, - }); - - const {accessToken} = data?.customerActivate?.customerAccessToken ?? {}; - - if (!accessToken) { - /** - * Something is wrong with the user's input. - */ - throw new Error(data?.customerActivate?.customerUserErrors.join(', ')); - } - - session.set('customerAccessToken', accessToken); - - return redirect(locale ? `${locale}/account` : '/account', { - headers: { - 'Set-Cookie': await session.commit(), - }, - }); - } catch (error: any) { - if (storefront.isApiError(error)) { - return badRequest({ - formError: 'Something went wrong. Please try again later.', - }); - } - - /** - * The user did something wrong, but the raw error from the API is not super friendly. - * Let's make one up. - */ - return badRequest({ - formError: 'Sorry. We could not activate your account.', - }); - } -}; - -export const meta: MetaFunction = () => { - return [{title: 'Activate Account'}]; -}; - -export default function Activate() { - const actionData = useActionData(); - const [nativePasswordError, setNativePasswordError] = useState( - null, - ); - const [nativePasswordConfirmError, setNativePasswordConfirmError] = useState< - null | string - >(null); - - const passwordInput = useRef(null); - const passwordConfirmInput = useRef(null); - - const validatePasswordConfirm = () => { - if (!passwordConfirmInput.current) return; - - if ( - passwordConfirmInput.current.value.length && - passwordConfirmInput.current.value !== passwordInput.current?.value - ) { - setNativePasswordConfirmError('The two passwords entered did not match.'); - } else if ( - passwordConfirmInput.current.validity.valid || - !passwordConfirmInput.current.value.length - ) { - setNativePasswordConfirmError(null); - } else { - setNativePasswordConfirmError( - passwordConfirmInput.current.validity.valueMissing - ? 'Please re-enter the password' - : 'Passwords must be at least 8 characters', - ); - } - }; - - return ( -
    -
    -

    Activate Account.

    -

    Create your password to activate your account.

    - {/* TODO: Add onSubmit to validate _before_ submission with native? */} -
    - {actionData?.formError && ( -
    -

    {actionData.formError}

    -
    - )} -
    - { - if ( - event.currentTarget.validity.valid || - !event.currentTarget.value.length - ) { - setNativePasswordError(null); - validatePasswordConfirm(); - } else { - setNativePasswordError( - event.currentTarget.validity.valueMissing - ? 'Please enter a password' - : 'Passwords must be at least 8 characters', - ); - } - }} - /> - {nativePasswordError && ( -

    - {' '} - {nativePasswordError}   -

    - )} -
    -
    - - {nativePasswordConfirmError && ( -

    - {' '} - {nativePasswordConfirmError}   -

    - )} -
    -
    - -
    -
    -
    -
    - ); -} - -const CUSTOMER_ACTIVATE_MUTATION = `#graphql - mutation customerActivate($id: ID!, $input: CustomerActivateInput!) { - customerActivate(id: $id, input: $input) { - customerAccessToken { - accessToken - expiresAt - } - customerUserErrors { - code - field - message - } - } - } -`; diff --git a/templates/demo-store/app/routes/($locale).account.address.$id.tsx b/templates/demo-store/app/routes/($locale).account.address.$id.tsx index cfc3212aac..fc58916529 100644 --- a/templates/demo-store/app/routes/($locale).account.address.$id.tsx +++ b/templates/demo-store/app/routes/($locale).account.address.$id.tsx @@ -1,4 +1,9 @@ -import {json, redirect, type ActionFunction} from '@shopify/remix-oxygen'; +import { + json, + redirect, + type ActionFunction, + type AppLoadContext, +} from '@shopify/remix-oxygen'; import { Form, useActionData, @@ -7,60 +12,88 @@ import { useNavigation, } from '@remix-run/react'; import {flattenConnection} from '@shopify/hydrogen'; -import type {MailingAddressInput} from '@shopify/hydrogen/storefront-api-types'; +import type {CustomerAddressInput} from '@shopify/hydrogen/customer-account-api-types'; import invariant from 'tiny-invariant'; import {Button, Text} from '~/components'; -import {assertApiErrors, getInputStyleClasses} from '~/lib/utils'; +import {getInputStyleClasses} from '~/lib/utils'; +import { + UPDATE_ADDRESS_MUTATION, + DELETE_ADDRESS_MUTATION, + CREATE_ADDRESS_MUTATION, +} from '~/graphql/customer-account/CustomerAddressMutations'; +import {doLogout} from './($locale).account_.logout'; import type {AccountOutletContext} from './($locale).account.edit'; interface ActionData { formError?: string; } -const badRequest = (data: ActionData) => json(data, {status: 400}); - export const handle = { renderInModal: true, }; export const action: ActionFunction = async ({request, context, params}) => { - const {storefront, session} = context; + const {customerAccount} = context; const formData = await request.formData(); - const customerAccessToken = await session.get('customerAccessToken'); - invariant(customerAccessToken, 'You must be logged in to edit your account.'); + // Double-check current user is logged in. + // Will throw a logout redirect if not. + if (!(await customerAccount.isLoggedIn())) { + throw await doLogout(context); + } const addressId = formData.get('addressId'); invariant(typeof addressId === 'string', 'You must provide an address id.'); if (request.method === 'DELETE') { try { - const data = await storefront.mutate(DELETE_ADDRESS_MUTATION, { - variables: {customerAccessToken, id: addressId}, - }); + const {data, errors} = await customerAccount.mutate( + DELETE_ADDRESS_MUTATION, + {variables: {addressId}}, + ); + + invariant(!errors?.length, errors?.[0]?.message); - assertApiErrors(data.customerAddressDelete); + invariant( + !data?.customerAddressUpdate?.userErrors?.length, + data?.customerAddressUpdate?.userErrors?.[0]?.message, + ); - return redirect(params.locale ? `${params.locale}/account` : '/account'); + return redirect( + params?.locale ? `${params?.locale}/account` : '/account', + { + headers: { + 'Set-Cookie': await context.session.commit(), + }, + }, + ); } catch (error: any) { - return badRequest({formError: error.message}); + return json( + {formError: error.message}, + { + status: 400, + headers: { + 'Set-Cookie': await context.session.commit(), + }, + }, + ); } } - const address: MailingAddressInput = {}; + const address: CustomerAddressInput = {}; - const keys: (keyof MailingAddressInput)[] = [ + const keys: (keyof CustomerAddressInput)[] = [ 'lastName', 'firstName', 'address1', 'address2', 'city', - 'province', - 'country', + 'zoneCode', + 'territoryCode', 'zip', - 'phone', + 'phoneNumber', 'company', ]; @@ -71,57 +104,86 @@ export const action: ActionFunction = async ({request, context, params}) => { } } - const defaultAddress = formData.get('defaultAddress'); + const defaultAddress = formData.has('defaultAddress') + ? String(formData.get('defaultAddress')) === 'on' + : false; if (addressId === 'add') { try { - const data = await storefront.mutate(CREATE_ADDRESS_MUTATION, { - variables: {customerAccessToken, address}, - }); + const {data, errors} = await customerAccount.mutate( + CREATE_ADDRESS_MUTATION, + {variables: {address, defaultAddress}}, + ); - assertApiErrors(data.customerAddressCreate); + invariant(!errors?.length, errors?.[0]?.message); - const newId = data.customerAddressCreate?.customerAddress?.id; - invariant(newId, 'Expected customer address to be created'); + invariant( + !data?.customerAddressCreate?.userErrors?.length, + data?.customerAddressCreate?.userErrors?.[0]?.message, + ); - if (defaultAddress) { - const data = await storefront.mutate(UPDATE_DEFAULT_ADDRESS_MUTATION, { - variables: {customerAccessToken, addressId: newId}, - }); + invariant( + data?.customerAddressCreate?.customerAddress?.id, + 'Expected customer address to be created', + ); - assertApiErrors(data.customerDefaultAddressUpdate); - } - - return redirect(params.locale ? `${params.locale}/account` : '/account'); + return redirect( + params?.locale ? `${params?.locale}/account` : '/account', + { + headers: { + 'Set-Cookie': await context.session.commit(), + }, + }, + ); } catch (error: any) { - return badRequest({formError: error.message}); + return json( + {formError: error.message}, + { + status: 400, + headers: { + 'Set-Cookie': await context.session.commit(), + }, + }, + ); } } else { try { - const data = await storefront.mutate(UPDATE_ADDRESS_MUTATION, { - variables: { - address, - customerAccessToken, - id: decodeURIComponent(addressId), - }, - }); - - assertApiErrors(data.customerAddressUpdate); - - if (defaultAddress) { - const data = await storefront.mutate(UPDATE_DEFAULT_ADDRESS_MUTATION, { + const {data, errors} = await customerAccount.mutate( + UPDATE_ADDRESS_MUTATION, + { variables: { - customerAccessToken, - addressId: decodeURIComponent(addressId), + address, + addressId, + defaultAddress, }, - }); + }, + ); - assertApiErrors(data.customerDefaultAddressUpdate); - } + invariant(!errors?.length, errors?.[0]?.message); - return redirect(params.locale ? `${params.locale}/account` : '/account'); + invariant( + !data?.customerAddressUpdate?.userErrors?.length, + data?.customerAddressUpdate?.userErrors?.[0]?.message, + ); + + return redirect( + params?.locale ? `${params?.locale}/account` : '/account', + { + headers: { + 'Set-Cookie': await context.session.commit(), + }, + }, + ); } catch (error: any) { - return badRequest({formError: error.message}); + return json( + {formError: error.message}, + { + status: 400, + headers: { + 'Set-Cookie': await context.session.commit(), + }, + }, + ); } } }; @@ -242,14 +304,14 @@ export default function EditAddress() {
    @@ -268,26 +330,26 @@ export default function EditAddress() {
    @@ -329,75 +391,3 @@ export default function EditAddress() { ); } - -const UPDATE_ADDRESS_MUTATION = `#graphql - mutation customerAddressUpdate( - $address: MailingAddressInput! - $customerAccessToken: String! - $id: ID! - ) { - customerAddressUpdate( - address: $address - customerAccessToken: $customerAccessToken - id: $id - ) { - customerUserErrors { - code - field - message - } - } - } -`; - -const DELETE_ADDRESS_MUTATION = `#graphql - mutation customerAddressDelete($customerAccessToken: String!, $id: ID!) { - customerAddressDelete(customerAccessToken: $customerAccessToken, id: $id) { - customerUserErrors { - code - field - message - } - deletedCustomerAddressId - } - } -`; - -const UPDATE_DEFAULT_ADDRESS_MUTATION = `#graphql - mutation customerDefaultAddressUpdate( - $addressId: ID! - $customerAccessToken: String! - ) { - customerDefaultAddressUpdate( - addressId: $addressId - customerAccessToken: $customerAccessToken - ) { - customerUserErrors { - code - field - message - } - } - } -`; - -const CREATE_ADDRESS_MUTATION = `#graphql - mutation customerAddressCreate( - $address: MailingAddressInput! - $customerAccessToken: String! - ) { - customerAddressCreate( - address: $address - customerAccessToken: $customerAccessToken - ) { - customerAddress { - id - } - customerUserErrors { - code - field - message - } - } - } -`; diff --git a/templates/demo-store/app/routes/($locale).account.edit.tsx b/templates/demo-store/app/routes/($locale).account.edit.tsx index 8d5ec4600f..9ca065ce98 100644 --- a/templates/demo-store/app/routes/($locale).account.edit.tsx +++ b/templates/demo-store/app/routes/($locale).account.edit.tsx @@ -8,14 +8,14 @@ import { import type { Customer, CustomerUpdateInput, -} from '@shopify/hydrogen/storefront-api-types'; -import clsx from 'clsx'; +} from '@shopify/hydrogen/customer-account-api-types'; import invariant from 'tiny-invariant'; import {Button, Text} from '~/components'; -import {getInputStyleClasses, assertApiErrors} from '~/lib/utils'; +import {getInputStyleClasses} from '~/lib/utils'; +import {CUSTOMER_UPDATE_MUTATION} from '~/graphql/customer-account/CustomerUpdateMutation'; -import {getCustomer} from './($locale).account'; +import {doLogout} from './($locale).account_.logout'; export interface AccountOutletContext { customer: Customer; @@ -35,8 +35,6 @@ export interface ActionData { }; } -const badRequest = (data: ActionData) => json(data, {status: 400}); - const formDataHas = (formData: FormData, key: string) => { if (!formData.has(key)) return false; @@ -51,38 +49,10 @@ export const handle = { export const action: ActionFunction = async ({request, context, params}) => { const formData = await request.formData(); - const customerAccessToken = await context.session.get('customerAccessToken'); - - invariant( - customerAccessToken, - 'You must be logged in to update your account details.', - ); - // Double-check current user is logged in. // Will throw a logout redirect if not. - await getCustomer(context, customerAccessToken); - - if ( - formDataHas(formData, 'newPassword') && - !formDataHas(formData, 'currentPassword') - ) { - return badRequest({ - fieldErrors: { - currentPassword: - 'Please enter your current password before entering a new password.', - }, - }); - } - - if ( - formData.has('newPassword') && - formData.get('newPassword') !== formData.get('newPassword2') - ) { - return badRequest({ - fieldErrors: { - newPassword2: 'New passwords must match.', - }, - }); + if (!(await context.customerAccount.isLoggedIn())) { + throw await doLogout(context); } try { @@ -92,25 +62,38 @@ export const action: ActionFunction = async ({request, context, params}) => { (customer.firstName = formData.get('firstName') as string); formDataHas(formData, 'lastName') && (customer.lastName = formData.get('lastName') as string); - formDataHas(formData, 'email') && - (customer.email = formData.get('email') as string); - formDataHas(formData, 'phone') && - (customer.phone = formData.get('phone') as string); - formDataHas(formData, 'newPassword') && - (customer.password = formData.get('newPassword') as string); - const data = await context.storefront.mutate(CUSTOMER_UPDATE_MUTATION, { - variables: { - customerAccessToken, - customer, + const {data, errors} = await context.customerAccount.mutate( + CUSTOMER_UPDATE_MUTATION, + { + variables: { + customer, + }, }, - }); + ); + + invariant(!errors?.length, errors?.[0]?.message); - assertApiErrors(data.customerUpdate); + invariant( + !data?.customerUpdate?.userErrors?.length, + data?.customerUpdate?.userErrors?.[0]?.message, + ); - return redirect(params?.locale ? `${params.locale}/account` : '/account'); + return redirect(params?.locale ? `${params.locale}/account` : '/account', { + headers: { + 'Set-Cookie': await context.session.commit(), + }, + }); } catch (error: any) { - return badRequest({formError: error.message}); + return json( + {formError: error?.message}, + { + status: 400, + headers: { + 'Set-Cookie': await context.session.commit(), + }, + }, + ); } }; @@ -164,75 +147,6 @@ export default function AccountDetailsEdit() { defaultValue={customer.lastName ?? ''} />
    -
    - -
    -
    - - {actionData?.fieldErrors?.email && ( -

    - {actionData.fieldErrors.email}   -

    - )} -
    - - Change your password - - - {actionData?.fieldErrors?.currentPassword && ( - - {actionData.fieldErrors.currentPassword}   - - )} - - - - Passwords must be at least 8 characters. - - {actionData?.fieldErrors?.newPassword2 ?
    : null} - {actionData?.fieldErrors?.newPassword2 && ( - - {actionData.fieldErrors.newPassword2}   - - )}
    -
    -
    -

    - New to {shopName}?   - - Create an account - -

    - - Forgot password - -
    - -
    - - ); -} - -const LOGIN_MUTATION = `#graphql - mutation customerAccessTokenCreate($input: CustomerAccessTokenCreateInput!) { - customerAccessTokenCreate(input: $input) { - customerUserErrors { - code - field - message - } - customerAccessToken { - accessToken - expiresAt - } - } - } -`; - -export async function doLogin( - {storefront}: AppLoadContext, - { - email, - password, - }: { - email: string; - password: string; - }, -) { - const data = await storefront.mutate(LOGIN_MUTATION, { - variables: { - input: { - email, - password, - }, - }, - }); - - if (data?.customerAccessTokenCreate?.customerAccessToken?.accessToken) { - return data.customerAccessTokenCreate.customerAccessToken.accessToken; - } - - /** - * Something is wrong with the user's input. - */ - throw new Error( - data?.customerAccessTokenCreate?.customerUserErrors.join(', '), - ); -} diff --git a/templates/demo-store/app/routes/($locale).account.logout.ts b/templates/demo-store/app/routes/($locale).account.logout.ts deleted file mode 100644 index 414de9b71c..0000000000 --- a/templates/demo-store/app/routes/($locale).account.logout.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - redirect, - type ActionFunction, - type AppLoadContext, - type LoaderFunctionArgs, - type ActionFunctionArgs, -} from '@shopify/remix-oxygen'; - -export async function doLogout(context: AppLoadContext) { - const {session} = context; - session.unset('customerAccessToken'); - - // The only file where I have to explicitly type cast i18n to pass typecheck - return redirect(`${context.storefront.i18n.pathPrefix}/account/login`, { - headers: { - 'Set-Cookie': await session.commit(), - }, - }); -} - -export async function loader({context}: LoaderFunctionArgs) { - return redirect(context.storefront.i18n.pathPrefix); -} - -export const action: ActionFunction = async ({context}: ActionFunctionArgs) => { - return doLogout(context); -}; diff --git a/templates/demo-store/app/routes/($locale).account.orders.$id.tsx b/templates/demo-store/app/routes/($locale).account.orders.$id.tsx index 7b4e70e8ed..09ff63d415 100644 --- a/templates/demo-store/app/routes/($locale).account.orders.$id.tsx +++ b/templates/demo-store/app/routes/($locale).account.orders.$id.tsx @@ -4,8 +4,10 @@ import {json, redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen'; import {useLoaderData, type MetaFunction} from '@remix-run/react'; import {Money, Image, flattenConnection} from '@shopify/hydrogen'; +import type {OrderFragment} from 'customer-accountapi.generated'; import {statusMessage} from '~/lib/utils'; import {Link, Heading, PageHeader, Text} from '~/components'; +import {CUSTOMER_ORDER_QUERY} from '~/graphql/customer-account/CustomerOrderQuery'; export const meta: MetaFunction = ({data}) => { return [{title: `Order ${data?.order?.name}`}]; @@ -21,48 +23,79 @@ export async function loader({request, context, params}: LoaderFunctionArgs) { invariant(orderToken, 'Order token is required'); - const customerAccessToken = await context.session.get('customerAccessToken'); + if (!(await context.customerAccount.isLoggedIn())) { + const loginUrl = + (params?.locale ? `/${params.locale}/account/login` : '/account/login') + + `?${new URLSearchParams(`redirectPath=${request.url}`).toString()}`; - if (!customerAccessToken) { - return redirect( - params.locale ? `${params.locale}/account/login` : '/account/login', - ); + return redirect(loginUrl, { + headers: { + 'Set-Cookie': await context.session.commit(), + }, + }); } - const orderId = `gid://shopify/Order/${params.id}?key=${orderToken}`; + try { + const orderId = `gid://shopify/Order/${params.id}?key=${orderToken}`; - const {node: order} = await context.storefront.query(CUSTOMER_ORDER_QUERY, { - variables: {orderId}, - }); + const {data, errors} = await context.customerAccount.query( + CUSTOMER_ORDER_QUERY, + {variables: {orderId}}, + ); - if (!order || !('lineItems' in order)) { - throw new Response('Order not found', {status: 404}); - } + if (errors?.length || !data?.order || !data?.lineItems) { + throw new Error('Order not found'); + } + + const order: OrderFragment = data.order; - const lineItems = flattenConnection(order.lineItems); + const lineItems = flattenConnection(order.lineItems); - const discountApplications = flattenConnection(order.discountApplications); + const discountApplications = flattenConnection(order.discountApplications); - const firstDiscount = discountApplications[0]?.value; + const firstDiscount = discountApplications[0]?.value; - const discountValue = - firstDiscount?.__typename === 'MoneyV2' && firstDiscount; + const discountValue = + firstDiscount?.__typename === 'MoneyV2' && firstDiscount; - const discountPercentage = - firstDiscount?.__typename === 'PricingPercentageValue' && - firstDiscount?.percentage; + const discountPercentage = + firstDiscount?.__typename === 'PricingPercentageValue' && + firstDiscount?.percentage; + + const fulfillmentStatus = flattenConnection(order.fulfillments)[0].status; + + return json( + { + order, + lineItems, + discountValue, + discountPercentage, + fulfillmentStatus, + }, + { + headers: { + 'Set-Cookie': await context.session.commit(), + }, + }, + ); + } catch (error) { + throw new Response(error instanceof Error ? error.message : undefined, { + status: 404, + headers: { + 'Set-Cookie': await context.session.commit(), + }, + }); + } +} - return json({ +export default function OrderRoute() { + const { order, lineItems, discountValue, discountPercentage, - }); -} - -export default function OrderRoute() { - const {order, lineItems, discountValue, discountPercentage} = - useLoaderData(); + fulfillmentStatus, + } = useLoaderData(); return (
    @@ -110,26 +143,22 @@ export default function OrderRoute() { {lineItems.map((lineItem) => ( - +
    - - {lineItem?.variant?.image && ( -
    - -
    - )} - + {lineItem?.image && ( +
    + +
    + )}
    {lineItem.title} - {lineItem.variant!.title} + {lineItem.variantTitle}
    @@ -139,13 +168,13 @@ export default function OrderRoute() { {lineItem.title} - {lineItem.variant!.title} + {lineItem.variantTitle}
    Price
    - +
    Quantity
    @@ -158,14 +187,14 @@ export default function OrderRoute() {
    - + {lineItem.quantity} - + @@ -214,7 +243,7 @@ export default function OrderRoute() { Subtotal - + @@ -232,7 +261,7 @@ export default function OrderRoute() { Tax - + @@ -250,7 +279,7 @@ export default function OrderRoute() { Total - + @@ -262,11 +291,7 @@ export default function OrderRoute() { {order?.shippingAddress ? (
    • - - {order.shippingAddress.firstName && - order.shippingAddress.firstName + ' '} - {order.shippingAddress.lastName} - + {order.shippingAddress.name}
    • {order?.shippingAddress?.formatted ? ( order.shippingAddress.formatted.map((line: string) => ( @@ -284,18 +309,18 @@ export default function OrderRoute() { Status -
      - - {statusMessage(order.fulfillmentStatus!)} - -
      + {fulfillmentStatus && ( +
      + {statusMessage(fulfillmentStatus!)} +
      + )}
    @@ -303,119 +328,3 @@ export default function OrderRoute() { ); } - -const CUSTOMER_ORDER_QUERY = `#graphql - fragment Money on MoneyV2 { - amount - currencyCode - } - fragment AddressFull on MailingAddress { - address1 - address2 - city - company - country - countryCodeV2 - firstName - formatted - id - lastName - name - phone - province - provinceCode - zip - } - fragment DiscountApplication on DiscountApplication { - value { - __typename - ... on MoneyV2 { - amount - currencyCode - } - ... on PricingPercentageValue { - percentage - } - } - } - fragment Image on Image { - altText - height - src: url(transform: {crop: CENTER, maxHeight: 96, maxWidth: 96, scale: 2}) - id - width - } - fragment ProductVariant on ProductVariant { - id - image { - ...Image - } - price { - ...Money - } - product { - handle - } - sku - title - } - fragment LineItemFull on OrderLineItem { - title - quantity - discountAllocations { - allocatedAmount { - ...Money - } - discountApplication { - ...DiscountApplication - } - } - originalTotalPrice { - ...Money - } - discountedTotalPrice { - ...Money - } - variant { - ...ProductVariant - } - } - - query CustomerOrder( - $country: CountryCode - $language: LanguageCode - $orderId: ID! - ) @inContext(country: $country, language: $language) { - node(id: $orderId) { - ... on Order { - id - name - orderNumber - processedAt - fulfillmentStatus - totalTaxV2 { - ...Money - } - totalPriceV2 { - ...Money - } - subtotalPriceV2 { - ...Money - } - shippingAddress { - ...AddressFull - } - discountApplications(first: 100) { - nodes { - ...DiscountApplication - } - } - lineItems(first: 100) { - nodes { - ...LineItemFull - } - } - } - } - } -`; diff --git a/templates/demo-store/app/routes/($locale).account.recover.tsx b/templates/demo-store/app/routes/($locale).account.recover.tsx deleted file mode 100644 index d595f86324..0000000000 --- a/templates/demo-store/app/routes/($locale).account.recover.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { - json, - redirect, - type ActionFunction, - type LoaderFunctionArgs, -} from '@shopify/remix-oxygen'; -import {Form, useActionData, type MetaFunction} from '@remix-run/react'; -import {useState} from 'react'; - -import {Link} from '~/components'; -import {getInputStyleClasses} from '~/lib/utils'; - -export async function loader({context, params}: LoaderFunctionArgs) { - const customerAccessToken = await context.session.get('customerAccessToken'); - - if (customerAccessToken) { - return redirect(params.locale ? `${params.locale}/account` : '/account'); - } - - return new Response(null); -} - -type ActionData = { - formError?: string; - resetRequested?: boolean; -}; - -const badRequest = (data: ActionData) => json(data, {status: 400}); - -export const action: ActionFunction = async ({request, context}) => { - const formData = await request.formData(); - const email = formData.get('email'); - - if (!email || typeof email !== 'string') { - return badRequest({ - formError: 'Please provide an email.', - }); - } - - try { - await context.storefront.mutate(CUSTOMER_RECOVER_MUTATION, { - variables: {email}, - }); - - return json({resetRequested: true}); - } catch (error: any) { - return badRequest({ - formError: 'Something went wrong. Please try again later.', - }); - } -}; - -export const meta: MetaFunction = () => { - return [{title: 'Recover Password'}]; -}; - -export default function Recover() { - const actionData = useActionData(); - const [nativeEmailError, setNativeEmailError] = useState(null); - const isSubmitted = actionData?.resetRequested; - - return ( -
    -
    - {isSubmitted ? ( - <> -

    Request Sent.

    -

    - If that email address is in our system, you will receive an email - with instructions about how to reset your password in a few - minutes. -

    - - ) : ( - <> -

    Forgot Password.

    -

    - Enter the email address associated with your account to receive a - link to reset your password. -

    - {/* TODO: Add onSubmit to validate _before_ submission with native? */} -
    - {actionData?.formError && ( -
    -

    - {actionData.formError} -

    -
    - )} -
    - { - setNativeEmailError( - event.currentTarget.value.length && - !event.currentTarget.validity.valid - ? 'Invalid email address' - : null, - ); - }} - /> - {nativeEmailError && ( -

    - {nativeEmailError}   -

    - )} -
    -
    - -
    -
    -

    - Return to   - - Login - -

    -
    -
    - - )} -
    -
    - ); -} - -const CUSTOMER_RECOVER_MUTATION = `#graphql - mutation customerRecover($email: String!) { - customerRecover(email: $email) { - customerUserErrors { - code - field - message - } - } - } -`; diff --git a/templates/demo-store/app/routes/($locale).account.register.tsx b/templates/demo-store/app/routes/($locale).account.register.tsx deleted file mode 100644 index 14c5ee2218..0000000000 --- a/templates/demo-store/app/routes/($locale).account.register.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import { - redirect, - json, - type ActionFunction, - type LoaderFunctionArgs, -} from '@shopify/remix-oxygen'; -import {Form, useActionData, type MetaFunction} from '@remix-run/react'; -import {useState} from 'react'; - -import {getInputStyleClasses} from '~/lib/utils'; -import {Link} from '~/components'; - -import {doLogin} from './($locale).account.login'; - -export async function loader({context, params}: LoaderFunctionArgs) { - const customerAccessToken = await context.session.get('customerAccessToken'); - - if (customerAccessToken) { - return redirect(params.locale ? `${params.locale}/account` : '/account'); - } - - return new Response(null); -} - -type ActionData = { - formError?: string; -}; - -const badRequest = (data: ActionData) => json(data, {status: 400}); - -export const action: ActionFunction = async ({request, context, params}) => { - const {session, storefront} = context; - const formData = await request.formData(); - - const email = formData.get('email'); - const password = formData.get('password'); - - if ( - !email || - !password || - typeof email !== 'string' || - typeof password !== 'string' - ) { - return badRequest({ - formError: 'Please provide both an email and a password.', - }); - } - - try { - const data = await storefront.mutate(CUSTOMER_CREATE_MUTATION, { - variables: { - input: {email, password}, - }, - }); - - if (!data?.customerCreate?.customer?.id) { - /** - * Something is wrong with the user's input. - */ - throw new Error(data?.customerCreate?.customerUserErrors.join(', ')); - } - - const customerAccessToken = await doLogin(context, {email, password}); - session.set('customerAccessToken', customerAccessToken); - - return redirect(params.locale ? `${params.locale}/account` : '/account', { - headers: { - 'Set-Cookie': await session.commit(), - }, - }); - } catch (error: any) { - if (storefront.isApiError(error)) { - return badRequest({ - formError: 'Something went wrong. Please try again later.', - }); - } - - /** - * The user did something wrong, but the raw error from the API is not super friendly. - * Let's make one up. - */ - return badRequest({ - formError: - 'Sorry. We could not create an account with this email. User might already exist, try to login instead.', - }); - } -}; - -export const meta: MetaFunction = () => { - return [{title: 'Register'}]; -}; - -export default function Register() { - const actionData = useActionData(); - const [nativeEmailError, setNativeEmailError] = useState(null); - const [nativePasswordError, setNativePasswordError] = useState( - null, - ); - - return ( -
    -
    -

    Create an Account.

    - {/* TODO: Add onSubmit to validate _before_ submission with native? */} -
    - {actionData?.formError && ( -
    -

    {actionData.formError}

    -
    - )} -
    - { - setNativeEmailError( - event.currentTarget.value.length && - !event.currentTarget.validity.valid - ? 'Invalid email address' - : null, - ); - }} - /> - {nativeEmailError && ( -

    {nativeEmailError}  

    - )} -
    -
    - { - if ( - event.currentTarget.validity.valid || - !event.currentTarget.value.length - ) { - setNativePasswordError(null); - } else { - setNativePasswordError( - event.currentTarget.validity.valueMissing - ? 'Please enter a password' - : 'Passwords must be at least 8 characters', - ); - } - }} - /> - {nativePasswordError && ( -

    - {' '} - {nativePasswordError}   -

    - )} -
    -
    - -
    -
    -

    - Already have an account?   - - Sign in - -

    -
    -
    -
    -
    - ); -} - -const CUSTOMER_CREATE_MUTATION = `#graphql - mutation customerCreate($input: CustomerCreateInput!) { - customerCreate(input: $input) { - customer { - id - } - customerUserErrors { - code - field - message - } - } - } -`; diff --git a/templates/demo-store/app/routes/($locale).account.reset.$id.$resetToken.tsx b/templates/demo-store/app/routes/($locale).account.reset.$id.$resetToken.tsx deleted file mode 100644 index a16ef4196c..0000000000 --- a/templates/demo-store/app/routes/($locale).account.reset.$id.$resetToken.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import {json, redirect, type ActionFunction} from '@shopify/remix-oxygen'; -import {Form, useActionData, type MetaFunction} from '@remix-run/react'; -import {useRef, useState} from 'react'; - -import {getInputStyleClasses} from '~/lib/utils'; - -type ActionData = { - formError?: string; -}; - -const badRequest = (data: ActionData) => json(data, {status: 400}); - -export const action: ActionFunction = async ({ - request, - context, - params: {locale, id, resetToken}, -}) => { - if ( - !id || - !resetToken || - typeof id !== 'string' || - typeof resetToken !== 'string' - ) { - return badRequest({ - formError: 'Wrong token. Please try to reset your password again.', - }); - } - - const formData = await request.formData(); - - const password = formData.get('password'); - const passwordConfirm = formData.get('passwordConfirm'); - - if ( - !password || - !passwordConfirm || - typeof password !== 'string' || - typeof passwordConfirm !== 'string' || - password !== passwordConfirm - ) { - return badRequest({ - formError: 'Please provide matching passwords', - }); - } - - const {session, storefront} = context; - - try { - const data = await storefront.mutate(CUSTOMER_RESET_MUTATION, { - variables: { - id: `gid://shopify/Customer/${id}`, - input: { - password, - resetToken, - }, - }, - }); - - const {accessToken} = data?.customerReset?.customerAccessToken ?? {}; - - if (!accessToken) { - /** - * Something is wrong with the user's input. - */ - throw new Error(data?.customerReset?.customerUserErrors.join(', ')); - } - - session.set('customerAccessToken', accessToken); - - return redirect(locale ? `${locale}/account` : '/account', { - headers: { - 'Set-Cookie': await session.commit(), - }, - }); - } catch (error: any) { - if (storefront.isApiError(error)) { - return badRequest({ - formError: 'Something went wrong. Please try again later.', - }); - } - - /** - * The user did something wrong, but the raw error from the API is not super friendly. - * Let's make one up. - */ - return badRequest({ - formError: 'Sorry. We could not update your password.', - }); - } -}; - -export const meta: MetaFunction = () => { - return [{title: 'Reset Password'}]; -}; - -export default function Reset() { - const actionData = useActionData(); - const [nativePasswordError, setNativePasswordError] = useState( - null, - ); - const [nativePasswordConfirmError, setNativePasswordConfirmError] = useState< - null | string - >(null); - - const passwordInput = useRef(null); - const passwordConfirmInput = useRef(null); - - const validatePasswordConfirm = () => { - if (!passwordConfirmInput.current) return; - - if ( - passwordConfirmInput.current.value.length && - passwordConfirmInput.current.value !== passwordInput.current?.value - ) { - setNativePasswordConfirmError('The two passwords entered did not match.'); - } else if ( - passwordConfirmInput.current.validity.valid || - !passwordConfirmInput.current.value.length - ) { - setNativePasswordConfirmError(null); - } else { - setNativePasswordConfirmError( - passwordConfirmInput.current.validity.valueMissing - ? 'Please re-enter the password' - : 'Passwords must be at least 8 characters', - ); - } - }; - - return ( -
    -
    -

    Reset Password.

    -

    Enter a new password for your account.

    - {/* TODO: Add onSubmit to validate _before_ submission with native? */} -
    - {actionData?.formError && ( -
    -

    {actionData.formError}

    -
    - )} -
    - { - if ( - event.currentTarget.validity.valid || - !event.currentTarget.value.length - ) { - setNativePasswordError(null); - validatePasswordConfirm(); - } else { - setNativePasswordError( - event.currentTarget.validity.valueMissing - ? 'Please enter a password' - : 'Passwords must be at least 8 characters', - ); - } - }} - /> - {nativePasswordError && ( -

    - {' '} - {nativePasswordError}   -

    - )} -
    -
    - - {nativePasswordConfirmError && ( -

    - {' '} - {nativePasswordConfirmError}   -

    - )} -
    -
    - -
    -
    -
    -
    - ); -} - -const CUSTOMER_RESET_MUTATION = `#graphql - mutation customerReset($id: ID!, $input: CustomerResetInput!) { - customerReset(id: $id, input: $input) { - customerAccessToken { - accessToken - expiresAt - } - customerUserErrors { - code - field - message - } - } - } -`; diff --git a/templates/demo-store/app/routes/($locale).account.tsx b/templates/demo-store/app/routes/($locale).account.tsx index 41c8c99352..41ed1a6c7a 100644 --- a/templates/demo-store/app/routes/($locale).account.tsx +++ b/templates/demo-store/app/routes/($locale).account.tsx @@ -8,7 +8,6 @@ import { } from '@remix-run/react'; import {Suspense} from 'react'; import { - json, defer, redirect, type LoaderFunctionArgs, @@ -19,7 +18,7 @@ import {flattenConnection} from '@shopify/hydrogen'; import type { CustomerDetailsFragment, OrderCardFragment, -} from 'storefrontapi.generated'; +} from 'customer-accountapi.generated'; import { Button, OrderCard, @@ -33,57 +32,72 @@ import { import {FeaturedCollections} from '~/components/FeaturedCollections'; import {usePrefixPathWithLocale} from '~/lib/utils'; import {CACHE_NONE, routeHeaders} from '~/data/cache'; -import {ORDER_CARD_FRAGMENT} from '~/components/OrderCard'; +import {CUSTOMER_DETAILS_QUERY} from '~/graphql/customer-account/CustomerDetailsQuery'; +import {doLogout} from './($locale).account_.logout'; import { getFeaturedData, type FeaturedData, } from './($locale).featured-products'; -import {doLogout} from './($locale).account.logout'; - -// Combining json + defer in a loader breaks the -// types returned by useLoaderData. This is a temporary fix. -type TmpRemixFix = ReturnType>; export const headers = routeHeaders; export async function loader({request, context, params}: LoaderFunctionArgs) { - const {pathname} = new URL(request.url); const locale = params.locale; - const customerAccessToken = await context.session.get('customerAccessToken'); - const isAuthenticated = !!customerAccessToken; - const loginPath = locale ? `/${locale}/account/login` : '/account/login'; - const isAccountPage = /^\/account\/?$/.test(pathname); - if (!isAuthenticated) { - if (isAccountPage) { - throw redirect(loginPath); - } - // pass through to public routes - return json({isAuthenticated: false}) as unknown as TmpRemixFix; + if (!(await context.customerAccount.isLoggedIn())) { + const loginUrl = + (locale ? `/${locale}/account/login` : '/account/login') + + `?${new URLSearchParams(`redirectPath=${request.url}`).toString()}`; + + return redirect(loginUrl, { + headers: { + 'Set-Cookie': await context.session.commit(), + }, + }); } - const customer = await getCustomer(context, customerAccessToken); + try { + const {data, errors} = await context.customerAccount.query( + CUSTOMER_DETAILS_QUERY, + ); + + /** + * If the customer failed to load, we assume their access token is invalid. + */ + if (errors?.length || !data?.customer) { + throw await doLogout(context); + } + + const customer = data?.customer; - const heading = customer - ? customer.firstName - ? `Welcome, ${customer.firstName}.` - : `Welcome to your account.` - : 'Account Details'; + const heading = customer + ? customer.firstName + ? `Welcome, ${customer.firstName}.` + : `Welcome to your account.` + : 'Account Details'; - return defer( - { - isAuthenticated, - customer, - heading, - featuredData: getFeaturedData(context.storefront), - }, - { + return defer( + { + customer, + heading, + featuredDataPromise: getFeaturedData(context.storefront), + }, + { + headers: { + 'Cache-Control': CACHE_NONE, + 'Set-Cookie': await context.session.commit(), + }, + }, + ); + } catch (error) { + throw new Response(error instanceof Error ? error.message : undefined, { + status: 404, headers: { - 'Cache-Control': CACHE_NONE, + 'Set-Cookie': await context.session.commit(), }, - }, - ); + }); + } } export default function Authenticated() { @@ -97,12 +111,6 @@ export default function Authenticated() { return handle?.renderInModal; }); - // Public routes - if (!data.isAuthenticated) { - return ; - } - - // Authenticated routes if (outlet) { if (renderOutletInModal) { return ( @@ -123,11 +131,11 @@ export default function Authenticated() { interface AccountType { customer: CustomerDetailsFragment; - featuredData: Promise; + featuredDataPromise: Promise; heading: string; } -function Account({customer, heading, featuredData}: AccountType) { +function Account({customer, heading, featuredDataPromise}: AccountType) { const orders = flattenConnection(customer.orders); const addresses = flattenConnection(customer.addresses); @@ -146,7 +154,7 @@ function Account({customer, heading, featuredData}: AccountType) { {!orders.length && ( {(data) => ( @@ -208,81 +216,3 @@ function Orders({orders}: OrderCardsProps) { ); } - -const CUSTOMER_QUERY = `#graphql - query CustomerDetails( - $customerAccessToken: String! - $country: CountryCode - $language: LanguageCode - ) @inContext(country: $country, language: $language) { - customer(customerAccessToken: $customerAccessToken) { - ...CustomerDetails - } - } - - fragment AddressPartial on MailingAddress { - id - formatted - firstName - lastName - company - address1 - address2 - country - province - city - zip - phone - } - - fragment CustomerDetails on Customer { - firstName - lastName - phone - email - defaultAddress { - ...AddressPartial - } - addresses(first: 6) { - edges { - node { - ...AddressPartial - } - } - } - orders(first: 250, sortKey: PROCESSED_AT, reverse: true) { - edges { - node { - ...OrderCard - } - } - } - } - - ${ORDER_CARD_FRAGMENT} -` as const; - -export async function getCustomer( - context: AppLoadContext, - customerAccessToken: string, -) { - const {storefront} = context; - - const data = await storefront.query(CUSTOMER_QUERY, { - variables: { - customerAccessToken, - country: context.storefront.i18n.country, - language: context.storefront.i18n.language, - }, - cache: storefront.CacheNone(), - }); - - /** - * If the customer failed to load, we assume their access token is invalid. - */ - if (!data || !data.customer) { - throw await doLogout(context); - } - - return data.customer; -} diff --git a/templates/demo-store/app/routes/($locale).account_.authorize.ts b/templates/demo-store/app/routes/($locale).account_.authorize.ts new file mode 100644 index 0000000000..492e429aa0 --- /dev/null +++ b/templates/demo-store/app/routes/($locale).account_.authorize.ts @@ -0,0 +1,5 @@ +import type {LoaderFunctionArgs} from '@shopify/remix-oxygen'; + +export async function loader({context, params}: LoaderFunctionArgs) { + return context.customerAccount.authorize(); +} diff --git a/templates/demo-store/app/routes/($locale).account_.login.tsx b/templates/demo-store/app/routes/($locale).account_.login.tsx new file mode 100644 index 0000000000..44bedef03d --- /dev/null +++ b/templates/demo-store/app/routes/($locale).account_.login.tsx @@ -0,0 +1,13 @@ +import type {LoaderFunctionArgs} from '@shopify/remix-oxygen'; + +export async function loader({params, request, context}: LoaderFunctionArgs) { + const locale = params.locale; + + return context.customerAccount.login( + new URL(request.url).searchParams.get('redirectPath') || + request.headers.get('Referer') || + locale + ? `/${locale}/account` + : '/account', + ); +} diff --git a/templates/demo-store/app/routes/($locale).account_.logout.ts b/templates/demo-store/app/routes/($locale).account_.logout.ts new file mode 100644 index 0000000000..9eb020379f --- /dev/null +++ b/templates/demo-store/app/routes/($locale).account_.logout.ts @@ -0,0 +1,20 @@ +import { + redirect, + type ActionFunction, + type AppLoadContext, + type LoaderFunctionArgs, + type ActionFunctionArgs, +} from '@shopify/remix-oxygen'; + +export async function doLogout(context: AppLoadContext) { + return context.customerAccount.logout(); +} + +export async function loader({params}: LoaderFunctionArgs) { + const locale = params.locale; + return redirect(locale ? `/${locale}` : '/'); +} + +export const action: ActionFunction = async ({context}: ActionFunctionArgs) => { + return doLogout(context); +}; diff --git a/templates/demo-store/app/routes/($locale).cart.tsx b/templates/demo-store/app/routes/($locale).cart.tsx index fd04bbb8af..455c2fa1df 100644 --- a/templates/demo-store/app/routes/($locale).cart.tsx +++ b/templates/demo-store/app/routes/($locale).cart.tsx @@ -11,12 +11,21 @@ import {isLocalPath} from '~/lib/utils'; import {Cart} from '~/components'; import {useRootLoaderData} from '~/root'; +async function getAccessToken(context: ActionFunctionArgs['context']) { + try { + return await context.customerAccount.getAccessToken(); + } catch { + // just ignore access token if error occur + return undefined; + } +} + export async function action({request, context}: ActionFunctionArgs) { - const {session, cart} = context; + const {cart} = context; const [formData, customerAccessToken] = await Promise.all([ request.formData(), - session.get('customerAccessToken'), + getAccessToken(context), ]); const {action, inputs} = CartForm.getFormInput(formData); @@ -71,6 +80,9 @@ export async function action({request, context}: ActionFunctionArgs) { } const {cart: cartResult, errors} = result; + + headers.append('Set-Cookie', await context.session.commit()); + return json( { cart: cartResult, diff --git a/templates/demo-store/customer-accountapi.generated.d.ts b/templates/demo-store/customer-accountapi.generated.d.ts new file mode 100644 index 0000000000..95f05f1128 --- /dev/null +++ b/templates/demo-store/customer-accountapi.generated.d.ts @@ -0,0 +1,503 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable eslint-comments/no-unlimited-disable */ +/* eslint-disable */ +import * as CustomerAccountAPI from '@shopify/hydrogen/customer-account-api-types'; + +export type CustomerAddressUpdateMutationVariables = CustomerAccountAPI.Exact<{ + address: CustomerAccountAPI.CustomerAddressInput; + addressId: CustomerAccountAPI.Scalars['ID']['input']; + defaultAddress?: CustomerAccountAPI.InputMaybe< + CustomerAccountAPI.Scalars['Boolean']['input'] + >; +}>; + +export type CustomerAddressUpdateMutation = { + customerAddressUpdate?: CustomerAccountAPI.Maybe<{ + userErrors: Array< + Pick< + CustomerAccountAPI.UserErrorsAddressUserErrors, + 'code' | 'field' | 'message' + > + >; + }>; +}; + +export type CustomerAddressDeleteMutationVariables = CustomerAccountAPI.Exact<{ + addressId: CustomerAccountAPI.Scalars['ID']['input']; +}>; + +export type CustomerAddressDeleteMutation = { + customerAddressDelete?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.CustomerAddressDeletePayload, + 'deletedAddressId' + > & { + userErrors: Array< + Pick< + CustomerAccountAPI.UserErrorsCustomerAddressUserErrors, + 'code' | 'field' | 'message' + > + >; + } + >; +}; + +export type CustomerAddressCreateMutationVariables = CustomerAccountAPI.Exact<{ + address: CustomerAccountAPI.CustomerAddressInput; + defaultAddress?: CustomerAccountAPI.InputMaybe< + CustomerAccountAPI.Scalars['Boolean']['input'] + >; +}>; + +export type CustomerAddressCreateMutation = { + customerAddressCreate?: CustomerAccountAPI.Maybe<{ + customerAddress?: CustomerAccountAPI.Maybe< + Pick + >; + userErrors: Array< + Pick< + CustomerAccountAPI.UserErrorsCustomerAddressUserErrors, + 'code' | 'field' | 'message' + > + >; + }>; +}; + +export type OrderCardFragment = Pick< + CustomerAccountAPI.Order, + 'id' | 'number' | 'processedAt' | 'financialStatus' +> & { + fulfillments: {nodes: Array>}; + totalPrice: Pick; + lineItems: { + edges: Array<{ + node: Pick & { + image?: CustomerAccountAPI.Maybe< + Pick + >; + }; + }>; + }; +}; + +export type AddressPartialFragment = Pick< + CustomerAccountAPI.CustomerAddress, + | 'id' + | 'formatted' + | 'firstName' + | 'lastName' + | 'company' + | 'address1' + | 'address2' + | 'territoryCode' + | 'zoneCode' + | 'city' + | 'zip' + | 'phoneNumber' +>; + +export type CustomerDetailsFragment = Pick< + CustomerAccountAPI.Customer, + 'firstName' | 'lastName' +> & { + phoneNumber?: CustomerAccountAPI.Maybe< + Pick + >; + emailAddress?: CustomerAccountAPI.Maybe< + Pick + >; + defaultAddress?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.CustomerAddress, + | 'id' + | 'formatted' + | 'firstName' + | 'lastName' + | 'company' + | 'address1' + | 'address2' + | 'territoryCode' + | 'zoneCode' + | 'city' + | 'zip' + | 'phoneNumber' + > + >; + addresses: { + edges: Array<{ + node: Pick< + CustomerAccountAPI.CustomerAddress, + | 'id' + | 'formatted' + | 'firstName' + | 'lastName' + | 'company' + | 'address1' + | 'address2' + | 'territoryCode' + | 'zoneCode' + | 'city' + | 'zip' + | 'phoneNumber' + >; + }>; + }; + orders: { + edges: Array<{ + node: Pick< + CustomerAccountAPI.Order, + 'id' | 'number' | 'processedAt' | 'financialStatus' + > & { + fulfillments: { + nodes: Array>; + }; + totalPrice: Pick; + lineItems: { + edges: Array<{ + node: Pick & { + image?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.Image, + 'altText' | 'height' | 'url' | 'width' + > + >; + }; + }>; + }; + }; + }>; + }; +}; + +export type CustomerDetailsQueryVariables = CustomerAccountAPI.Exact<{ + [key: string]: never; +}>; + +export type CustomerDetailsQuery = { + customer: Pick & { + phoneNumber?: CustomerAccountAPI.Maybe< + Pick + >; + emailAddress?: CustomerAccountAPI.Maybe< + Pick + >; + defaultAddress?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.CustomerAddress, + | 'id' + | 'formatted' + | 'firstName' + | 'lastName' + | 'company' + | 'address1' + | 'address2' + | 'territoryCode' + | 'zoneCode' + | 'city' + | 'zip' + | 'phoneNumber' + > + >; + addresses: { + edges: Array<{ + node: Pick< + CustomerAccountAPI.CustomerAddress, + | 'id' + | 'formatted' + | 'firstName' + | 'lastName' + | 'company' + | 'address1' + | 'address2' + | 'territoryCode' + | 'zoneCode' + | 'city' + | 'zip' + | 'phoneNumber' + >; + }>; + }; + orders: { + edges: Array<{ + node: Pick< + CustomerAccountAPI.Order, + 'id' | 'number' | 'processedAt' | 'financialStatus' + > & { + fulfillments: { + nodes: Array>; + }; + totalPrice: Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >; + lineItems: { + edges: Array<{ + node: Pick & { + image?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.Image, + 'altText' | 'height' | 'url' | 'width' + > + >; + }; + }>; + }; + }; + }>; + }; + }; +}; + +export type OrderMoneyFragment = Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' +>; + +export type DiscountApplicationFragment = { + value: + | ({__typename: 'MoneyV2'} & Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >) + | ({__typename: 'PricingPercentageValue'} & Pick< + CustomerAccountAPI.PricingPercentageValue, + 'percentage' + >); +}; + +export type OrderLineItemFullFragment = Pick< + CustomerAccountAPI.LineItem, + 'id' | 'title' | 'quantity' | 'variantTitle' +> & { + price?: CustomerAccountAPI.Maybe< + Pick + >; + discountAllocations: Array<{ + allocatedAmount: Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >; + discountApplication: { + value: + | ({__typename: 'MoneyV2'} & Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >) + | ({__typename: 'PricingPercentageValue'} & Pick< + CustomerAccountAPI.PricingPercentageValue, + 'percentage' + >); + }; + }>; + totalDiscount: Pick; + image?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.Image, + 'altText' | 'height' | 'url' | 'id' | 'width' + > + >; +}; + +export type OrderFragment = Pick< + CustomerAccountAPI.Order, + 'id' | 'name' | 'statusPageUrl' | 'processedAt' +> & { + fulfillments: {nodes: Array>}; + totalTax?: CustomerAccountAPI.Maybe< + Pick + >; + totalPrice: Pick; + subtotal?: CustomerAccountAPI.Maybe< + Pick + >; + shippingAddress?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.CustomerAddress, + 'name' | 'formatted' | 'formattedArea' + > + >; + discountApplications: { + nodes: Array<{ + value: + | ({__typename: 'MoneyV2'} & Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >) + | ({__typename: 'PricingPercentageValue'} & Pick< + CustomerAccountAPI.PricingPercentageValue, + 'percentage' + >); + }>; + }; + lineItems: { + nodes: Array< + Pick< + CustomerAccountAPI.LineItem, + 'id' | 'title' | 'quantity' | 'variantTitle' + > & { + price?: CustomerAccountAPI.Maybe< + Pick + >; + discountAllocations: Array<{ + allocatedAmount: Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >; + discountApplication: { + value: + | ({__typename: 'MoneyV2'} & Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >) + | ({__typename: 'PricingPercentageValue'} & Pick< + CustomerAccountAPI.PricingPercentageValue, + 'percentage' + >); + }; + }>; + totalDiscount: Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >; + image?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.Image, + 'altText' | 'height' | 'url' | 'id' | 'width' + > + >; + } + >; + }; +}; + +export type OrderQueryVariables = CustomerAccountAPI.Exact<{ + orderId: CustomerAccountAPI.Scalars['ID']['input']; +}>; + +export type OrderQuery = { + order?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.Order, + 'id' | 'name' | 'statusPageUrl' | 'processedAt' + > & { + fulfillments: { + nodes: Array>; + }; + totalTax?: CustomerAccountAPI.Maybe< + Pick + >; + totalPrice: Pick; + subtotal?: CustomerAccountAPI.Maybe< + Pick + >; + shippingAddress?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.CustomerAddress, + 'name' | 'formatted' | 'formattedArea' + > + >; + discountApplications: { + nodes: Array<{ + value: + | ({__typename: 'MoneyV2'} & Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >) + | ({__typename: 'PricingPercentageValue'} & Pick< + CustomerAccountAPI.PricingPercentageValue, + 'percentage' + >); + }>; + }; + lineItems: { + nodes: Array< + Pick< + CustomerAccountAPI.LineItem, + 'id' | 'title' | 'quantity' | 'variantTitle' + > & { + price?: CustomerAccountAPI.Maybe< + Pick + >; + discountAllocations: Array<{ + allocatedAmount: Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >; + discountApplication: { + value: + | ({__typename: 'MoneyV2'} & Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >) + | ({__typename: 'PricingPercentageValue'} & Pick< + CustomerAccountAPI.PricingPercentageValue, + 'percentage' + >); + }; + }>; + totalDiscount: Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >; + image?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.Image, + 'altText' | 'height' | 'url' | 'id' | 'width' + > + >; + } + >; + }; + } + >; +}; + +export type CustomerUpdateMutationVariables = CustomerAccountAPI.Exact<{ + customer: CustomerAccountAPI.CustomerUpdateInput; +}>; + +export type CustomerUpdateMutation = { + customerUpdate?: CustomerAccountAPI.Maybe<{ + userErrors: Array< + Pick< + CustomerAccountAPI.UserErrorsCustomerUserErrors, + 'code' | 'field' | 'message' + > + >; + }>; +}; + +interface GeneratedQueryTypes { + '#graphql\n query CustomerDetails {\n customer {\n ...CustomerDetails\n }\n }\n #graphql\n fragment OrderCard on Order {\n id\n number\n processedAt\n financialStatus\n fulfillments(first: 1) {\n nodes {\n status\n }\n }\n totalPrice {\n amount\n currencyCode\n }\n lineItems(first: 2) {\n edges {\n node {\n title\n image {\n altText\n height\n url\n width\n }\n }\n }\n }\n }\n\n fragment AddressPartial on CustomerAddress {\n id\n formatted\n firstName\n lastName\n company\n address1\n address2\n territoryCode\n zoneCode\n city\n zip\n phoneNumber\n }\n\n fragment CustomerDetails on Customer {\n firstName\n lastName\n phoneNumber {\n phoneNumber\n }\n emailAddress {\n emailAddress\n }\n defaultAddress {\n ...AddressPartial\n }\n addresses(first: 6) {\n edges {\n node {\n ...AddressPartial\n }\n }\n }\n orders(first: 250, sortKey: PROCESSED_AT, reverse: true) {\n edges {\n node {\n ...OrderCard\n }\n }\n }\n }\n\n': { + return: CustomerDetailsQuery; + variables: CustomerDetailsQueryVariables; + }; + '#graphql\n fragment OrderMoney on MoneyV2 {\n amount\n currencyCode\n }\n fragment DiscountApplication on DiscountApplication {\n value {\n __typename\n ... on MoneyV2 {\n ...OrderMoney\n }\n ... on PricingPercentageValue {\n percentage\n }\n }\n }\n fragment OrderLineItemFull on LineItem {\n id\n title\n quantity\n price {\n ...OrderMoney\n }\n discountAllocations {\n allocatedAmount {\n ...OrderMoney\n }\n discountApplication {\n ...DiscountApplication\n }\n }\n totalDiscount {\n ...OrderMoney\n }\n image {\n altText\n height\n url\n id\n width\n }\n variantTitle\n }\n fragment Order on Order {\n id\n name\n statusPageUrl\n processedAt\n fulfillments(first: 1) {\n nodes {\n status\n }\n }\n totalTax {\n ...OrderMoney\n }\n totalPrice {\n ...OrderMoney\n }\n subtotal {\n ...OrderMoney\n }\n shippingAddress {\n name\n formatted(withName: true)\n formattedArea\n }\n discountApplications(first: 100) {\n nodes {\n ...DiscountApplication\n }\n }\n lineItems(first: 100) {\n nodes {\n ...OrderLineItemFull\n }\n }\n }\n query Order($orderId: ID!) {\n order(id: $orderId) {\n ... on Order {\n ...Order\n }\n }\n }\n': { + return: OrderQuery; + variables: OrderQueryVariables; + }; +} + +interface GeneratedMutationTypes { + '#graphql\n mutation customerAddressUpdate(\n $address: CustomerAddressInput!\n $addressId: ID!\n $defaultAddress: Boolean\n ) {\n customerAddressUpdate(\n address: $address\n addressId: $addressId\n defaultAddress: $defaultAddress\n ) {\n userErrors {\n code\n field\n message\n }\n }\n }\n': { + return: CustomerAddressUpdateMutation; + variables: CustomerAddressUpdateMutationVariables; + }; + '#graphql\n mutation customerAddressDelete(\n $addressId: ID!,\n ) {\n customerAddressDelete(addressId: $addressId) {\n deletedAddressId\n userErrors {\n code\n field\n message\n }\n }\n }\n': { + return: CustomerAddressDeleteMutation; + variables: CustomerAddressDeleteMutationVariables; + }; + '#graphql\n mutation customerAddressCreate(\n $address: CustomerAddressInput!\n $defaultAddress: Boolean\n ) {\n customerAddressCreate(\n address: $address\n defaultAddress: $defaultAddress\n ) {\n customerAddress {\n id\n }\n userErrors {\n code\n field\n message\n }\n }\n }\n': { + return: CustomerAddressCreateMutation; + variables: CustomerAddressCreateMutationVariables; + }; + '#graphql\nmutation customerUpdate($customer: CustomerUpdateInput!) {\n customerUpdate(input: $customer) {\n userErrors {\n code\n field\n message\n }\n }\n}\n': { + return: CustomerUpdateMutation; + variables: CustomerUpdateMutationVariables; + }; +} + +declare module '@shopify/hydrogen' { + interface CustomerAccountQueries extends GeneratedQueryTypes {} + interface CustomerAccountMutations extends GeneratedMutationTypes {} +} diff --git a/templates/demo-store/remix.env.d.ts b/templates/demo-store/remix.env.d.ts index 348d995c05..3d9f27f1b1 100644 --- a/templates/demo-store/remix.env.d.ts +++ b/templates/demo-store/remix.env.d.ts @@ -3,7 +3,7 @@ /// import type {WithCache, HydrogenCart} from '@shopify/hydrogen'; -import type {Storefront} from '~/lib/type'; +import type {Storefront, CustomerClient} from '~/lib/type'; import type {AppSession} from '~/lib/session.server'; declare global { @@ -21,6 +21,8 @@ declare global { PRIVATE_STOREFRONT_API_TOKEN: string; PUBLIC_STORE_DOMAIN: string; PUBLIC_STOREFRONT_ID: string; + PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string; + PUBLIC_CUSTOMER_ACCOUNT_API_URL: string; } } @@ -32,16 +34,10 @@ declare module '@shopify/remix-oxygen' { waitUntil: ExecutionContext['waitUntil']; session: AppSession; storefront: Storefront; + customerAccount: CustomerClient; cart: HydrogenCart; env: Env; } - - /** - * Declare the data we expect to access via `context.session`. - */ - export interface SessionData { - customerAccessToken: string; - } } // Needed to make this file a module. diff --git a/templates/demo-store/server.ts b/templates/demo-store/server.ts index 971976a333..d2ba495efa 100644 --- a/templates/demo-store/server.ts +++ b/templates/demo-store/server.ts @@ -10,6 +10,7 @@ import { createCartHandler, createStorefrontClient, storefrontRedirect, + createCustomerClient, } from '@shopify/hydrogen'; import {AppSession} from '~/lib/session.server'; @@ -52,6 +53,17 @@ export default { storefrontHeaders: getStorefrontHeaders(request), }); + /** + * Create a client for Customer Account API. + */ + const customerAccount = createCustomerClient({ + waitUntil, + request, + session, + customerAccountId: env.PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID, + customerAccountUrl: env.PUBLIC_CUSTOMER_ACCOUNT_API_URL, + }); + const cart = createCartHandler({ storefront, getCartId: cartGetIdDefault(request.headers), @@ -69,6 +81,7 @@ export default { session, waitUntil, storefront, + customerAccount, cart, env, }), diff --git a/templates/demo-store/storefrontapi.generated.d.ts b/templates/demo-store/storefrontapi.generated.d.ts index 8353a1f263..7281633386 100644 --- a/templates/demo-store/storefrontapi.generated.d.ts +++ b/templates/demo-store/storefrontapi.generated.d.ts @@ -453,511 +453,6 @@ export type HomepageFeaturedCollectionsQuery = { }; }; -export type CustomerActivateMutationVariables = StorefrontAPI.Exact<{ - id: StorefrontAPI.Scalars['ID']['input']; - input: StorefrontAPI.CustomerActivateInput; -}>; - -export type CustomerActivateMutation = { - customerActivate?: StorefrontAPI.Maybe<{ - customerAccessToken?: StorefrontAPI.Maybe< - Pick - >; - customerUserErrors: Array< - Pick - >; - }>; -}; - -export type CustomerAddressUpdateMutationVariables = StorefrontAPI.Exact<{ - address: StorefrontAPI.MailingAddressInput; - customerAccessToken: StorefrontAPI.Scalars['String']['input']; - id: StorefrontAPI.Scalars['ID']['input']; -}>; - -export type CustomerAddressUpdateMutation = { - customerAddressUpdate?: StorefrontAPI.Maybe<{ - customerUserErrors: Array< - Pick - >; - }>; -}; - -export type CustomerAddressDeleteMutationVariables = StorefrontAPI.Exact<{ - customerAccessToken: StorefrontAPI.Scalars['String']['input']; - id: StorefrontAPI.Scalars['ID']['input']; -}>; - -export type CustomerAddressDeleteMutation = { - customerAddressDelete?: StorefrontAPI.Maybe< - Pick< - StorefrontAPI.CustomerAddressDeletePayload, - 'deletedCustomerAddressId' - > & { - customerUserErrors: Array< - Pick - >; - } - >; -}; - -export type CustomerDefaultAddressUpdateMutationVariables = - StorefrontAPI.Exact<{ - addressId: StorefrontAPI.Scalars['ID']['input']; - customerAccessToken: StorefrontAPI.Scalars['String']['input']; - }>; - -export type CustomerDefaultAddressUpdateMutation = { - customerDefaultAddressUpdate?: StorefrontAPI.Maybe<{ - customerUserErrors: Array< - Pick - >; - }>; -}; - -export type CustomerAddressCreateMutationVariables = StorefrontAPI.Exact<{ - address: StorefrontAPI.MailingAddressInput; - customerAccessToken: StorefrontAPI.Scalars['String']['input']; -}>; - -export type CustomerAddressCreateMutation = { - customerAddressCreate?: StorefrontAPI.Maybe<{ - customerAddress?: StorefrontAPI.Maybe< - Pick - >; - customerUserErrors: Array< - Pick - >; - }>; -}; - -export type CustomerUpdateMutationVariables = StorefrontAPI.Exact<{ - customerAccessToken: StorefrontAPI.Scalars['String']['input']; - customer: StorefrontAPI.CustomerUpdateInput; -}>; - -export type CustomerUpdateMutation = { - customerUpdate?: StorefrontAPI.Maybe<{ - customerUserErrors: Array< - Pick - >; - }>; -}; - -export type CustomerAccessTokenCreateMutationVariables = StorefrontAPI.Exact<{ - input: StorefrontAPI.CustomerAccessTokenCreateInput; -}>; - -export type CustomerAccessTokenCreateMutation = { - customerAccessTokenCreate?: StorefrontAPI.Maybe<{ - customerUserErrors: Array< - Pick - >; - customerAccessToken?: StorefrontAPI.Maybe< - Pick - >; - }>; -}; - -export type MoneyFragment = Pick< - StorefrontAPI.MoneyV2, - 'amount' | 'currencyCode' ->; - -export type AddressFullFragment = Pick< - StorefrontAPI.MailingAddress, - | 'address1' - | 'address2' - | 'city' - | 'company' - | 'country' - | 'countryCodeV2' - | 'firstName' - | 'formatted' - | 'id' - | 'lastName' - | 'name' - | 'phone' - | 'province' - | 'provinceCode' - | 'zip' ->; - -export type DiscountApplicationFragment = { - value: - | ({__typename: 'MoneyV2'} & Pick< - StorefrontAPI.MoneyV2, - 'amount' | 'currencyCode' - >) - | ({__typename: 'PricingPercentageValue'} & Pick< - StorefrontAPI.PricingPercentageValue, - 'percentage' - >); -}; - -export type ImageFragment = Pick< - StorefrontAPI.Image, - 'altText' | 'height' | 'id' | 'width' -> & {src: StorefrontAPI.Image['url']}; - -export type ProductVariantFragment = Pick< - StorefrontAPI.ProductVariant, - 'id' | 'sku' | 'title' -> & { - image?: StorefrontAPI.Maybe< - Pick & { - src: StorefrontAPI.Image['url']; - } - >; - price: Pick; - product: Pick; -}; - -export type LineItemFullFragment = Pick< - StorefrontAPI.OrderLineItem, - 'title' | 'quantity' -> & { - discountAllocations: Array<{ - allocatedAmount: Pick; - discountApplication: { - value: - | ({__typename: 'MoneyV2'} & Pick< - StorefrontAPI.MoneyV2, - 'amount' | 'currencyCode' - >) - | ({__typename: 'PricingPercentageValue'} & Pick< - StorefrontAPI.PricingPercentageValue, - 'percentage' - >); - }; - }>; - originalTotalPrice: Pick; - discountedTotalPrice: Pick; - variant?: StorefrontAPI.Maybe< - Pick & { - image?: StorefrontAPI.Maybe< - Pick & { - src: StorefrontAPI.Image['url']; - } - >; - price: Pick; - product: Pick; - } - >; -}; - -export type CustomerOrderQueryVariables = StorefrontAPI.Exact<{ - country?: StorefrontAPI.InputMaybe; - language?: StorefrontAPI.InputMaybe; - orderId: StorefrontAPI.Scalars['ID']['input']; -}>; - -export type CustomerOrderQuery = { - node?: StorefrontAPI.Maybe< - Pick< - StorefrontAPI.Order, - 'id' | 'name' | 'orderNumber' | 'processedAt' | 'fulfillmentStatus' - > & { - totalTaxV2?: StorefrontAPI.Maybe< - Pick - >; - totalPriceV2: Pick; - subtotalPriceV2?: StorefrontAPI.Maybe< - Pick - >; - shippingAddress?: StorefrontAPI.Maybe< - Pick< - StorefrontAPI.MailingAddress, - | 'address1' - | 'address2' - | 'city' - | 'company' - | 'country' - | 'countryCodeV2' - | 'firstName' - | 'formatted' - | 'id' - | 'lastName' - | 'name' - | 'phone' - | 'province' - | 'provinceCode' - | 'zip' - > - >; - discountApplications: { - nodes: Array<{ - value: - | ({__typename: 'MoneyV2'} & Pick< - StorefrontAPI.MoneyV2, - 'amount' | 'currencyCode' - >) - | ({__typename: 'PricingPercentageValue'} & Pick< - StorefrontAPI.PricingPercentageValue, - 'percentage' - >); - }>; - }; - lineItems: { - nodes: Array< - Pick & { - discountAllocations: Array<{ - allocatedAmount: Pick< - StorefrontAPI.MoneyV2, - 'amount' | 'currencyCode' - >; - discountApplication: { - value: - | ({__typename: 'MoneyV2'} & Pick< - StorefrontAPI.MoneyV2, - 'amount' | 'currencyCode' - >) - | ({__typename: 'PricingPercentageValue'} & Pick< - StorefrontAPI.PricingPercentageValue, - 'percentage' - >); - }; - }>; - originalTotalPrice: Pick< - StorefrontAPI.MoneyV2, - 'amount' | 'currencyCode' - >; - discountedTotalPrice: Pick< - StorefrontAPI.MoneyV2, - 'amount' | 'currencyCode' - >; - variant?: StorefrontAPI.Maybe< - Pick & { - image?: StorefrontAPI.Maybe< - Pick< - StorefrontAPI.Image, - 'altText' | 'height' | 'id' | 'width' - > & {src: StorefrontAPI.Image['url']} - >; - price: Pick; - product: Pick; - } - >; - } - >; - }; - } - >; -}; - -export type CustomerRecoverMutationVariables = StorefrontAPI.Exact<{ - email: StorefrontAPI.Scalars['String']['input']; -}>; - -export type CustomerRecoverMutation = { - customerRecover?: StorefrontAPI.Maybe<{ - customerUserErrors: Array< - Pick - >; - }>; -}; - -export type CustomerCreateMutationVariables = StorefrontAPI.Exact<{ - input: StorefrontAPI.CustomerCreateInput; -}>; - -export type CustomerCreateMutation = { - customerCreate?: StorefrontAPI.Maybe<{ - customer?: StorefrontAPI.Maybe>; - customerUserErrors: Array< - Pick - >; - }>; -}; - -export type CustomerResetMutationVariables = StorefrontAPI.Exact<{ - id: StorefrontAPI.Scalars['ID']['input']; - input: StorefrontAPI.CustomerResetInput; -}>; - -export type CustomerResetMutation = { - customerReset?: StorefrontAPI.Maybe<{ - customerAccessToken?: StorefrontAPI.Maybe< - Pick - >; - customerUserErrors: Array< - Pick - >; - }>; -}; - -export type CustomerDetailsQueryVariables = StorefrontAPI.Exact<{ - customerAccessToken: StorefrontAPI.Scalars['String']['input']; - country?: StorefrontAPI.InputMaybe; - language?: StorefrontAPI.InputMaybe; -}>; - -export type CustomerDetailsQuery = { - customer?: StorefrontAPI.Maybe< - Pick< - StorefrontAPI.Customer, - 'firstName' | 'lastName' | 'phone' | 'email' - > & { - defaultAddress?: StorefrontAPI.Maybe< - Pick< - StorefrontAPI.MailingAddress, - | 'id' - | 'formatted' - | 'firstName' - | 'lastName' - | 'company' - | 'address1' - | 'address2' - | 'country' - | 'province' - | 'city' - | 'zip' - | 'phone' - > - >; - addresses: { - edges: Array<{ - node: Pick< - StorefrontAPI.MailingAddress, - | 'id' - | 'formatted' - | 'firstName' - | 'lastName' - | 'company' - | 'address1' - | 'address2' - | 'country' - | 'province' - | 'city' - | 'zip' - | 'phone' - >; - }>; - }; - orders: { - edges: Array<{ - node: Pick< - StorefrontAPI.Order, - | 'id' - | 'orderNumber' - | 'processedAt' - | 'financialStatus' - | 'fulfillmentStatus' - > & { - currentTotalPrice: Pick< - StorefrontAPI.MoneyV2, - 'amount' | 'currencyCode' - >; - lineItems: { - edges: Array<{ - node: Pick & { - variant?: StorefrontAPI.Maybe<{ - image?: StorefrontAPI.Maybe< - Pick< - StorefrontAPI.Image, - 'url' | 'altText' | 'height' | 'width' - > - >; - }>; - }; - }>; - }; - }; - }>; - }; - } - >; -}; - -export type AddressPartialFragment = Pick< - StorefrontAPI.MailingAddress, - | 'id' - | 'formatted' - | 'firstName' - | 'lastName' - | 'company' - | 'address1' - | 'address2' - | 'country' - | 'province' - | 'city' - | 'zip' - | 'phone' ->; - -export type CustomerDetailsFragment = Pick< - StorefrontAPI.Customer, - 'firstName' | 'lastName' | 'phone' | 'email' -> & { - defaultAddress?: StorefrontAPI.Maybe< - Pick< - StorefrontAPI.MailingAddress, - | 'id' - | 'formatted' - | 'firstName' - | 'lastName' - | 'company' - | 'address1' - | 'address2' - | 'country' - | 'province' - | 'city' - | 'zip' - | 'phone' - > - >; - addresses: { - edges: Array<{ - node: Pick< - StorefrontAPI.MailingAddress, - | 'id' - | 'formatted' - | 'firstName' - | 'lastName' - | 'company' - | 'address1' - | 'address2' - | 'country' - | 'province' - | 'city' - | 'zip' - | 'phone' - >; - }>; - }; - orders: { - edges: Array<{ - node: Pick< - StorefrontAPI.Order, - | 'id' - | 'orderNumber' - | 'processedAt' - | 'financialStatus' - | 'fulfillmentStatus' - > & { - currentTotalPrice: Pick< - StorefrontAPI.MoneyV2, - 'amount' | 'currencyCode' - >; - lineItems: { - edges: Array<{ - node: Pick & { - variant?: StorefrontAPI.Maybe<{ - image?: StorefrontAPI.Maybe< - Pick< - StorefrontAPI.Image, - 'url' | 'altText' | 'height' | 'width' - > - >; - }>; - }; - }>; - }; - }; - }>; - }; -}; - export type ApiAllProductsQueryVariables = StorefrontAPI.Exact<{ query?: StorefrontAPI.InputMaybe; count?: StorefrontAPI.InputMaybe; @@ -1712,14 +1207,6 @@ interface GeneratedQueryTypes { return: HomepageFeaturedCollectionsQuery; variables: HomepageFeaturedCollectionsQueryVariables; }; - '#graphql\n fragment Money on MoneyV2 {\n amount\n currencyCode\n }\n fragment AddressFull on MailingAddress {\n address1\n address2\n city\n company\n country\n countryCodeV2\n firstName\n formatted\n id\n lastName\n name\n phone\n province\n provinceCode\n zip\n }\n fragment DiscountApplication on DiscountApplication {\n value {\n __typename\n ... on MoneyV2 {\n amount\n currencyCode\n }\n ... on PricingPercentageValue {\n percentage\n }\n }\n }\n fragment Image on Image {\n altText\n height\n src: url(transform: {crop: CENTER, maxHeight: 96, maxWidth: 96, scale: 2})\n id\n width\n }\n fragment ProductVariant on ProductVariant {\n id\n image {\n ...Image\n }\n price {\n ...Money\n }\n product {\n handle\n }\n sku\n title\n }\n fragment LineItemFull on OrderLineItem {\n title\n quantity\n discountAllocations {\n allocatedAmount {\n ...Money\n }\n discountApplication {\n ...DiscountApplication\n }\n }\n originalTotalPrice {\n ...Money\n }\n discountedTotalPrice {\n ...Money\n }\n variant {\n ...ProductVariant\n }\n }\n\n query CustomerOrder(\n $country: CountryCode\n $language: LanguageCode\n $orderId: ID!\n ) @inContext(country: $country, language: $language) {\n node(id: $orderId) {\n ... on Order {\n id\n name\n orderNumber\n processedAt\n fulfillmentStatus\n totalTaxV2 {\n ...Money\n }\n totalPriceV2 {\n ...Money\n }\n subtotalPriceV2 {\n ...Money\n }\n shippingAddress {\n ...AddressFull\n }\n discountApplications(first: 100) {\n nodes {\n ...DiscountApplication\n }\n }\n lineItems(first: 100) {\n nodes {\n ...LineItemFull\n }\n }\n }\n }\n }\n': { - return: CustomerOrderQuery; - variables: CustomerOrderQueryVariables; - }; - '#graphql\n query CustomerDetails(\n $customerAccessToken: String!\n $country: CountryCode\n $language: LanguageCode\n ) @inContext(country: $country, language: $language) {\n customer(customerAccessToken: $customerAccessToken) {\n ...CustomerDetails\n }\n }\n\n fragment AddressPartial on MailingAddress {\n id\n formatted\n firstName\n lastName\n company\n address1\n address2\n country\n province\n city\n zip\n phone\n }\n\n fragment CustomerDetails on Customer {\n firstName\n lastName\n phone\n email\n defaultAddress {\n ...AddressPartial\n }\n addresses(first: 6) {\n edges {\n node {\n ...AddressPartial\n }\n }\n }\n orders(first: 250, sortKey: PROCESSED_AT, reverse: true) {\n edges {\n node {\n ...OrderCard\n }\n }\n }\n }\n\n #graphql\n fragment OrderCard on Order {\n id\n orderNumber\n processedAt\n financialStatus\n fulfillmentStatus\n currentTotalPrice {\n amount\n currencyCode\n }\n lineItems(first: 2) {\n edges {\n node {\n variant {\n image {\n url\n altText\n height\n width\n }\n }\n title\n }\n }\n }\n }\n\n': { - return: CustomerDetailsQuery; - variables: CustomerDetailsQueryVariables; - }; '#graphql\n query ApiAllProducts(\n $query: String\n $count: Int\n $reverse: Boolean\n $country: CountryCode\n $language: LanguageCode\n $sortKey: ProductSortKeys\n ) @inContext(country: $country, language: $language) {\n products(first: $count, sortKey: $sortKey, reverse: $reverse, query: $query) {\n nodes {\n ...ProductCard\n }\n }\n }\n #graphql\n fragment ProductCard on Product {\n id\n title\n publishedAt\n handle\n vendor\n variants(first: 1) {\n nodes {\n id\n availableForSale\n image {\n url\n altText\n width\n height\n }\n price {\n amount\n currencyCode\n }\n compareAtPrice {\n amount\n currencyCode\n }\n selectedOptions {\n name\n value\n }\n product {\n handle\n title\n }\n }\n }\n }\n\n': { return: ApiAllProductsQuery; variables: ApiAllProductsQueryVariables; @@ -1782,48 +1269,7 @@ interface GeneratedQueryTypes { }; } -interface GeneratedMutationTypes { - '#graphql\n mutation customerActivate($id: ID!, $input: CustomerActivateInput!) {\n customerActivate(id: $id, input: $input) {\n customerAccessToken {\n accessToken\n expiresAt\n }\n customerUserErrors {\n code\n field\n message\n }\n }\n }\n': { - return: CustomerActivateMutation; - variables: CustomerActivateMutationVariables; - }; - '#graphql\n mutation customerAddressUpdate(\n $address: MailingAddressInput!\n $customerAccessToken: String!\n $id: ID!\n ) {\n customerAddressUpdate(\n address: $address\n customerAccessToken: $customerAccessToken\n id: $id\n ) {\n customerUserErrors {\n code\n field\n message\n }\n }\n }\n': { - return: CustomerAddressUpdateMutation; - variables: CustomerAddressUpdateMutationVariables; - }; - '#graphql\n mutation customerAddressDelete($customerAccessToken: String!, $id: ID!) {\n customerAddressDelete(customerAccessToken: $customerAccessToken, id: $id) {\n customerUserErrors {\n code\n field\n message\n }\n deletedCustomerAddressId\n }\n }\n': { - return: CustomerAddressDeleteMutation; - variables: CustomerAddressDeleteMutationVariables; - }; - '#graphql\n mutation customerDefaultAddressUpdate(\n $addressId: ID!\n $customerAccessToken: String!\n ) {\n customerDefaultAddressUpdate(\n addressId: $addressId\n customerAccessToken: $customerAccessToken\n ) {\n customerUserErrors {\n code\n field\n message\n }\n }\n }\n': { - return: CustomerDefaultAddressUpdateMutation; - variables: CustomerDefaultAddressUpdateMutationVariables; - }; - '#graphql\n mutation customerAddressCreate(\n $address: MailingAddressInput!\n $customerAccessToken: String!\n ) {\n customerAddressCreate(\n address: $address\n customerAccessToken: $customerAccessToken\n ) {\n customerAddress {\n id\n }\n customerUserErrors {\n code\n field\n message\n }\n }\n }\n': { - return: CustomerAddressCreateMutation; - variables: CustomerAddressCreateMutationVariables; - }; - '#graphql\n mutation customerUpdate($customerAccessToken: String!, $customer: CustomerUpdateInput!) {\n customerUpdate(customerAccessToken: $customerAccessToken, customer: $customer) {\n customerUserErrors {\n code\n field\n message\n }\n }\n }\n ': { - return: CustomerUpdateMutation; - variables: CustomerUpdateMutationVariables; - }; - '#graphql\n mutation customerAccessTokenCreate($input: CustomerAccessTokenCreateInput!) {\n customerAccessTokenCreate(input: $input) {\n customerUserErrors {\n code\n field\n message\n }\n customerAccessToken {\n accessToken\n expiresAt\n }\n }\n }\n': { - return: CustomerAccessTokenCreateMutation; - variables: CustomerAccessTokenCreateMutationVariables; - }; - '#graphql\n mutation customerRecover($email: String!) {\n customerRecover(email: $email) {\n customerUserErrors {\n code\n field\n message\n }\n }\n }\n': { - return: CustomerRecoverMutation; - variables: CustomerRecoverMutationVariables; - }; - '#graphql\n mutation customerCreate($input: CustomerCreateInput!) {\n customerCreate(input: $input) {\n customer {\n id\n }\n customerUserErrors {\n code\n field\n message\n }\n }\n }\n': { - return: CustomerCreateMutation; - variables: CustomerCreateMutationVariables; - }; - '#graphql\n mutation customerReset($id: ID!, $input: CustomerResetInput!) {\n customerReset(id: $id, input: $input) {\n customerAccessToken {\n accessToken\n expiresAt\n }\n customerUserErrors {\n code\n field\n message\n }\n }\n }\n': { - return: CustomerResetMutation; - variables: CustomerResetMutationVariables; - }; -} +interface GeneratedMutationTypes {} declare module '@shopify/hydrogen' { interface StorefrontQueries extends GeneratedQueryTypes {} diff --git a/templates/skeleton/.graphqlrc.yml b/templates/skeleton/.graphqlrc.yml index f1509f16cf..eee81eddc0 100644 --- a/templates/skeleton/.graphqlrc.yml +++ b/templates/skeleton/.graphqlrc.yml @@ -2,6 +2,11 @@ projects: default: schema: 'node_modules/@shopify/hydrogen/storefront.schema.json' documents: - - '!*.d.ts' - - '*.{ts,tsx,js,jsx}' - - 'app/**/*.{ts,tsx,js,jsx}' \ No newline at end of file + - '!*.d.ts' + - '*.{ts,tsx,js,jsx}' + - 'app/**/*.{ts,tsx,js,jsx}' + - '!app/graphql/**/*.{ts,tsx,js,jsx}' + customer-account: + schema: 'node_modules/@shopify/hydrogen/customer-account.schema.json' + documents: + - 'app/graphql/customer-account/**/*.{ts,tsx,js,jsx}' diff --git a/templates/skeleton/README.md b/templates/skeleton/README.md index dd590133a1..ccca8d2f75 100644 --- a/templates/skeleton/README.md +++ b/templates/skeleton/README.md @@ -38,3 +38,26 @@ npm run build ```bash npm run dev ``` + +## Setup for using Customer Account API (`/account` section) + +### Setup public domain using ngrok + +1. Setup a [ngrok](https://ngrok.com/) account and add a permanent domain (ie. `https://.app`). +1. Install the [ngrok CLI](https://ngrok.com/download) to use in terminal +1. Start ngrok using `ngrok http --domain=.app 3000` + +### Include public domain in Customer Account API settings + +1. Go to your Shopify admin => `Hydrogen` or `Headless` app/channel => Customer Account API => Application setup +1. Edit `Callback URI(s)` to include `https://.app/account/authorize` +1. Edit `Javascript origin(s)` to include your public domain `https://.app` or keep it blank +1. Edit `Logout URI` to include your public domain `https://.app` or keep it blank + +### Prepare Environment variables + +Run [`npx shopify hydrogen link`](https://shopify.dev/docs/custom-storefronts/hydrogen/cli#link) or [`npx shopify hydrogen env pull`](https://shopify.dev/docs/custom-storefronts/hydrogen/cli#env-pull) to link this app to your own test shop. + +Alternatly, the values of the required environment varaibles "PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID" and "PUBLIC_CUSTOMER_ACCOUNT_API_URL" can be found in customer account api settings in the Hydrogen admin channel. + +🗒️ Note that mock.shop doesn't supply these variables automatically. diff --git a/templates/skeleton/app/components/Header.tsx b/templates/skeleton/app/components/Header.tsx index efdc723892..888e534532 100644 --- a/templates/skeleton/app/components/Header.tsx +++ b/templates/skeleton/app/components/Header.tsx @@ -93,7 +93,11 @@ function HeaderCtas({