From 8280903341d52a7082173e49d7a2d0de18c6fcee Mon Sep 17 00:00:00 2001 From: Kara Daviduik Date: Thu, 25 Sep 2025 19:30:58 -0400 Subject: [PATCH] multipass: example -> recipe --- cookbook/llms/multipass.prompt.md | 3321 +++++++++++++++++ .../components/MultipassCheckoutButton.tsx | 0 .../skeleton}/app/lib/multipass/multipass.ts | 12 +- .../app/lib/multipass/multipassify.server.ts | 30 +- .../skeleton}/app/lib/multipass/types.ts | 0 ...account_.activate.$id.$activationToken.tsx | 17 +- .../app/routes/account_.login.multipass.tsx | 55 +- .../skeleton}/app/routes/account_.recover.tsx | 15 +- .../app/routes/account_.register.tsx | 19 +- .../routes/account_.reset.$id.$resetToken.tsx | 14 +- .../patches/CartSummary.tsx.eb8134.patch | 25 + .../multipass/patches/README.md.47d06f.patch | 132 + .../patches/account.$.tsx.2df983.patch | 19 + .../patches/account._index.tsx.bf7dac.patch | 10 + .../account.addresses.tsx.34e472.patch | 531 +++ .../account.orders.$id.tsx.fa0356.patch | 355 ++ .../account.orders._index.tsx.2b0f8a.patch | 219 ++ .../patches/account.tsx.ca55f3.patch | 193 + .../patches/account_.login.tsx.1d534b.patch | 142 + .../patches/account_.logout.tsx.d6592d.patch | 35 + .../multipass/patches/cart.tsx.362410.patch | 28 + .../multipass/patches/env.d.ts.899ef3.patch | 13 + .../patches/package.json.cfee98.patch | 19 + .../multipass/patches/root.tsx.0ad938.patch | 61 + .../patches/vite.config.ts.f0a2c4.patch | 12 + cookbook/recipes/multipass/recipe.yaml | 244 ++ examples/multipass/README.md | 88 - examples/multipass/app/components/Cart.tsx | 371 -- examples/multipass/app/components/Header.tsx | 234 -- .../multipass/app/components/PageLayout.tsx | 90 - examples/multipass/app/root.tsx | 241 -- examples/multipass/app/routes.ts | 3 - examples/multipass/app/routes/account.$.tsx | 8 - .../app/routes/account.addresses.tsx | 561 --- .../app/routes/account.orders.$id.tsx | 314 -- .../app/routes/account.orders._index.tsx | 197 - .../multipass/app/routes/account.profile.tsx | 262 -- examples/multipass/app/routes/account.tsx | 205 - .../multipass/app/routes/account_.login.tsx | 141 - .../multipass/app/routes/account_.logout.tsx | 29 - examples/multipass/app/routes/cart.tsx | 129 - examples/multipass/env.d.ts | 56 - examples/multipass/eslint.config.js | 1 - examples/multipass/package.json | 20 - examples/multipass/tsconfig.json | 11 - examples/multipass/vite.config.ts | 29 - 46 files changed, 5448 insertions(+), 3063 deletions(-) create mode 100644 cookbook/llms/multipass.prompt.md rename {examples/multipass => cookbook/recipes/multipass/ingredients/templates/skeleton}/app/components/MultipassCheckoutButton.tsx (100%) rename {examples/multipass => cookbook/recipes/multipass/ingredients/templates/skeleton}/app/lib/multipass/multipass.ts (84%) rename {examples/multipass => cookbook/recipes/multipass/ingredients/templates/skeleton}/app/lib/multipass/multipassify.server.ts (88%) rename {examples/multipass => cookbook/recipes/multipass/ingredients/templates/skeleton}/app/lib/multipass/types.ts (100%) rename {examples/multipass => cookbook/recipes/multipass/ingredients/templates/skeleton}/app/routes/account_.activate.$id.$activationToken.tsx (91%) rename {examples/multipass => cookbook/recipes/multipass/ingredients/templates/skeleton}/app/routes/account_.login.multipass.tsx (82%) rename {examples/multipass => cookbook/recipes/multipass/ingredients/templates/skeleton}/app/routes/account_.recover.tsx (92%) rename {examples/multipass => cookbook/recipes/multipass/ingredients/templates/skeleton}/app/routes/account_.register.tsx (93%) rename {examples/multipass => cookbook/recipes/multipass/ingredients/templates/skeleton}/app/routes/account_.reset.$id.$resetToken.tsx (93%) create mode 100644 cookbook/recipes/multipass/patches/CartSummary.tsx.eb8134.patch create mode 100644 cookbook/recipes/multipass/patches/README.md.47d06f.patch create mode 100644 cookbook/recipes/multipass/patches/account.$.tsx.2df983.patch create mode 100644 cookbook/recipes/multipass/patches/account._index.tsx.bf7dac.patch create mode 100644 cookbook/recipes/multipass/patches/account.addresses.tsx.34e472.patch create mode 100644 cookbook/recipes/multipass/patches/account.orders.$id.tsx.fa0356.patch create mode 100644 cookbook/recipes/multipass/patches/account.orders._index.tsx.2b0f8a.patch create mode 100644 cookbook/recipes/multipass/patches/account.tsx.ca55f3.patch create mode 100644 cookbook/recipes/multipass/patches/account_.login.tsx.1d534b.patch create mode 100644 cookbook/recipes/multipass/patches/account_.logout.tsx.d6592d.patch create mode 100644 cookbook/recipes/multipass/patches/cart.tsx.362410.patch create mode 100644 cookbook/recipes/multipass/patches/env.d.ts.899ef3.patch create mode 100644 cookbook/recipes/multipass/patches/package.json.cfee98.patch create mode 100644 cookbook/recipes/multipass/patches/root.tsx.0ad938.patch create mode 100644 cookbook/recipes/multipass/patches/vite.config.ts.f0a2c4.patch create mode 100644 cookbook/recipes/multipass/recipe.yaml delete mode 100644 examples/multipass/README.md delete mode 100644 examples/multipass/app/components/Cart.tsx delete mode 100644 examples/multipass/app/components/Header.tsx delete mode 100644 examples/multipass/app/components/PageLayout.tsx delete mode 100644 examples/multipass/app/root.tsx delete mode 100644 examples/multipass/app/routes.ts delete mode 100644 examples/multipass/app/routes/account.$.tsx delete mode 100644 examples/multipass/app/routes/account.addresses.tsx delete mode 100644 examples/multipass/app/routes/account.orders.$id.tsx delete mode 100644 examples/multipass/app/routes/account.orders._index.tsx delete mode 100644 examples/multipass/app/routes/account.profile.tsx delete mode 100644 examples/multipass/app/routes/account.tsx delete mode 100644 examples/multipass/app/routes/account_.login.tsx delete mode 100644 examples/multipass/app/routes/account_.logout.tsx delete mode 100644 examples/multipass/app/routes/cart.tsx delete mode 100644 examples/multipass/env.d.ts delete mode 100644 examples/multipass/eslint.config.js delete mode 100644 examples/multipass/package.json delete mode 100644 examples/multipass/tsconfig.json delete mode 100644 examples/multipass/vite.config.ts diff --git a/cookbook/llms/multipass.prompt.md b/cookbook/llms/multipass.prompt.md new file mode 100644 index 0000000000..fe66a48992 --- /dev/null +++ b/cookbook/llms/multipass.prompt.md @@ -0,0 +1,3321 @@ +# Overview + +This prompt describes how to implement "Multipass Authentication with Storefront API" in a Hydrogen storefront. Below is a "recipe" that contains the steps to apply to a basic Hydrogen skeleton template to achieve the desired outcome. +The same logic can be applied to any other Hydrogen storefront project, adapting the implementation details to the specific needs/structure/conventions of the project, but it's up to the developer to do so. +If there are any prerequisites, the recipe below will explain them; if the user is trying to implement the feature described in this recipe, make sure to prominently mention the prerequisites and any other preliminary instructions, as well as followups. +If the user is asking on how to implement the feature from scratch, please first describe the feature in a general way before jumping into the implementation details. +Please note that the recipe steps below are not necessarily ordered in the way they should be executed, as it depends on the user's needs and the specific details of the project. The recipe steps descriptions should allow you to understand what is required to be done in a certain order and what is not. Remember that file names in the recipe are related to the Hydrogen skeleton template, not the user's project, so make sure to adapt the file names to the user's project. + +# AI model verification steps + +- Never edit generated files (ending with .d.ts) directly; instead, run the `npm run codegen` command to update them (if the command is available). + +# Summary + +Enable Shopify Plus Multipass authentication using Storefront API for seamless customer login and checkout + +# User Intent Recognition + + +- How do I set up Multipass authentication in my Hydrogen store? +- How can I use Storefront API for customer authentication instead of Customer Account API? +- How do I implement session-based authentication in Hydrogen? +- How can I maintain customer login state across checkout? +- How do I integrate external authentication with Shopify Plus? + + +# Troubleshooting + + +- **Issue**: ReferenceError: require is not defined (snakecase-keys error) + **Solution**: The recipe includes a custom ESM-compatible snake_case implementation. Ensure you're using the updated multipassify.server.ts file that doesn't import snakecase-keys +- **Issue**: PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET is undefined + **Solution**: Add the Multipass secret to your environment variables. You can find this in your Shopify Plus admin under Settings > Checkout > Multipass +- **Issue**: TypeScript error: Property 'PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET' does not exist on type 'Env' + **Solution**: The recipe adds this type definition to env.d.ts. Run 'npm run typecheck' after applying all patches +- **Issue**: Customer login redirects to Customer Account API login page + **Solution**: Ensure all account routes have been properly converted to use Storefront API. Check that account_.login.tsx uses the form-based login, not customerAccount.login() +- **Issue**: Multipass checkout button not appearing + **Solution**: Verify that CartSummary.tsx imports and uses MultipassCheckoutButton component, and that the cart.tsx route has been patched + + +# Recipe Implementation + +Here's the multipass recipe for the base Hydrogen skeleton template: + + + +## Description + +This recipe implements Shopify Plus Multipass authentication using the Storefront API instead of the Customer Account API. +It provides session-based authentication with customer access tokens, enabling customers to maintain their logged-in +state across the storefront and checkout process. This is particularly useful for Shopify Plus stores that need to +integrate with external authentication systems or maintain customer sessions across different platforms. + +Key features: +- Converts all customer account routes from Customer Account API to Storefront API +- Implements session-based authentication with customer access tokens +- Adds Multipass checkout button for seamless checkout experience +- Provides token validation and automatic token refresh +- Includes complete authentication flow (login, logout, register, recover, reset) + +## Notes + +> [!NOTE] +> This recipe requires Shopify Plus as Multipass is a Plus-only feature + +> [!NOTE] +> The recipe replaces the snakecase-keys npm package with a custom ESM-compatible implementation to work in Worker environments + +> [!NOTE] +> All customer authentication is handled through Storefront API mutations instead of Customer Account API + +> [!NOTE] +> Session tokens are validated on each request and automatically cleared if expired + +## Requirements + +- Shopify Plus subscription for Multipass functionality +- PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET environment variable must be set +- React Router 7.8.x or higher + +## New files added to the template by this recipe + +- app/components/MultipassCheckoutButton.tsx +- app/lib/multipass/multipass.ts +- app/lib/multipass/multipassify.server.ts +- app/lib/multipass/types.ts +- app/routes/account_.activate.$id.$activationToken.tsx +- app/routes/account_.login.multipass.tsx +- app/routes/account_.recover.tsx +- app/routes/account_.register.tsx +- app/routes/account_.reset.$id.$resetToken.tsx + +## Steps + +### Step 1: README.md + + + +#### File: /README.md + +```diff +@@ -1,13 +1,15 @@ +-# Hydrogen template: Skeleton ++# Hydrogen template: Skeleton with Multipass + +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. ++Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify's full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, enhanced with **Multipass authentication** for seamless checkout experiences. + + [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) + [Get familiar with Remix](https://remix.run/docs/en/v1) ++[Learn about Multipass](https://shopify.dev/docs/api/multipass) + + ## What's included + +-- Remix ++### Core Hydrogen Stack ++- Remix (React Router 7.8.x) + - Hydrogen + - Oxygen + - Vite +@@ -18,11 +20,18 @@ Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dov + - TypeScript and JavaScript flavors + - Minimal setup of components and routes + ++### Multipass Authentication (Shopify Plus) ++- Customer session persistence through checkout ++- Storefront API-based authentication (not Customer Account API) ++- Custom login, registration, and account management ++- Automatic fallback for non-Plus stores ++ + ## Getting started + + **Requirements:** + + - Node.js version 18.0.0 or higher ++- Shopify store (Shopify Plus for Multipass features) + + ```bash + npm create @shopify/hydrogen@latest +@@ -40,6 +49,88 @@ npm run build + npm run dev + ``` + +-## Setup for using Customer Account API (`/account` section) ++## Multipass Setup (Shopify Plus only) + +-Follow step 1 and 2 of ++### Requirements ++ ++- Multipass is available on [Shopify Plus](https://www.shopify.com/plus) plans ++- A Shopify Multipass secret token from [**Settings > Customer accounts**](https://www.shopify.com/admin/settings/customer_accounts) ++- Ensure you have `Classic customer account` options selected to use Multipass ++ ++### Configuration ++ ++1. **Set environment variables** in your `.env` file: ++ ++```env ++# Required for Multipass (Shopify Plus only) ++PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET=your_multipass_secret_here ++ ++# Already configured by Hydrogen ++SESSION_SECRET=your_session_secret ++``` ++ ++2. **Dependencies** (already included): ++- `crypto-js@^4.2.0` - JavaScript library of crypto standards ++- `snakecase-keys@^9.0.2` - Convert object keys to snake case ++- `@types/crypto-js@^4.2.1` - TypeScript types for crypto-js ++ ++### Key Multipass Files ++ ++| File | Description | ++|------|------------| ++| [`app/components/MultipassCheckoutButton.tsx`](app/components/MultipassCheckoutButton.tsx) | Checkout button that passes customer session to checkout | ++| [`app/lib/multipass/multipass.ts`](app/lib/multipass/multipass.ts) | Client-side utility for multipass URL and token handling | ++| [`app/lib/multipass/multipassify.server.ts`](app/lib/multipass/multipassify.server.ts) | Server-side multipass token generation and parsing | ++| [`app/lib/multipass/types.ts`](app/lib/multipass/types.ts) | TypeScript types for multipass | ++| [`app/routes/account_.login.multipass.tsx`](app/routes/account_.login.multipass.tsx) | API route for multipass token generation | ++ ++## Authentication System ++ ++This template uses the **Storefront API** for customer authentication instead of the Customer Account API: ++ ++### Account Routes ++- `/account/login` - Customer login ++- `/account/register` - New customer registration ++- `/account/logout` - Logout ++- `/account/recover` - Password recovery ++- `/account/reset/:id/:token` - Password reset ++- `/account/activate/:id/:token` - Account activation ++- `/account` - Account dashboard ++- `/account/profile` - Edit profile ++- `/account/addresses` - Manage addresses ++- `/account/orders` - Order history ++ ++### How Multipass Works ++ ++1. Customer logs in using email/password ++2. Session stores `customerAccessToken` ++3. When checking out, multipass generates encrypted token ++4. Customer is automatically logged in at Shopify checkout ++ ++### Fallback Behavior ++ ++For non-Plus stores or when multipass isn't configured: ++- Checkout button works normally ++- Console shows: "Bypassing multipass checkout" ++- Customers use standard Shopify checkout flow ++ ++## Troubleshooting ++ ++### Multipass Issues ++ ++**500 Error on checkout:** ++- Ensure `PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET` is set correctly ++- Verify you have a Shopify Plus plan ++- Check that Classic customer accounts are enabled ++ ++**Customer not logged in at checkout:** ++- Verify customer is logged in first at `/account/login` ++- Check that multipass token generation is working ++- Ensure multipass secret hasn't expired ++ ++## Learn More ++ ++- [Hydrogen Documentation](https://shopify.dev/custom-storefronts/hydrogen) ++- [Multipass Documentation](https://shopify.dev/docs/api/multipass) ++- [Storefront API Authentication](https://shopify.dev/docs/api/storefront/authentication) ++- [Remix Documentation](https://remix.run/docs) +``` + +### Step 1: app/components/MultipassCheckoutButton.tsx + + + +#### File: [MultipassCheckoutButton.tsx](https://github.com/Shopify/hydrogen/blob/6681f92e84d42b5a6aca153fb49e31dcd8af84f6/cookbook/recipes/multipass/ingredients/templates/skeleton/app/components/MultipassCheckoutButton.tsx) + +```tsx +import React, {useCallback} from 'react'; +import {multipass} from '~/lib/multipass/multipass'; + +type MultipassCheckoutButtonProps = { + checkoutUrl: string; + children: React.ReactNode; + onClick?: () => void; + redirect?: boolean; +}; + +/* + This component attempts to persist the customer session + state in the checkout by using multipass. + Note: multipass checkout is a Shopify Plus+ feature only. +*/ +export function MultipassCheckoutButton(props: MultipassCheckoutButtonProps) { + const {children, onClick, checkoutUrl, redirect = true} = props; + + const checkoutHandler = useCallback( + async (event: React.MouseEvent) => { + event.preventDefault(); + if (!checkoutUrl) return; + + if (typeof onClick === 'function') { + onClick(); + } + + /* + * If they user is logged in we persist it in the checkout, + * otherwise we log them out of the checkout too. + */ + await multipass({return_to: checkoutUrl, redirect}); + }, + [redirect, checkoutUrl, onClick], + ); + + return ; +} +``` + +### Step 2: app/components/CartSummary.tsx + + + +#### File: /app/components/CartSummary.tsx + +```diff +@@ -12,6 +12,8 @@ import {useFetcher} from 'react-router'; + import type {FetcherWithComponents} from 'react-router'; + import {CartWarnings} from '~/components/CartWarnings'; + import {CartUserErrors} from '~/components/CartUserErrors'; ++// @description Import MultipassCheckoutButton for Shopify Plus multipass checkout ++import {MultipassCheckoutButton} from '~/components/MultipassCheckoutButton'; + + type CartSummaryProps = { + cart: OptimisticCart; +@@ -58,9 +60,10 @@ function CartCheckoutActions({checkoutUrl}: {checkoutUrl?: string}) { + + return ( + + ); +``` + +### Step 2: app/lib/multipass/multipass.ts + + + +#### File: [multipass.ts](https://github.com/Shopify/hydrogen/blob/6681f92e84d42b5a6aca153fb49e31dcd8af84f6/cookbook/recipes/multipass/ingredients/templates/skeleton/app/lib/multipass/multipass.ts) + +```ts +import type { + MultipassResponse, + MultipassOptions, + MultipassTokenResponseType, +} from './types'; + +/* + A utility that makes a POST request to the local `/account/login/multipass` endpoint + to retrieve a multipass `url` and `token` for a given url/customer combination. + + Usage example: + - Checkout button `onClick` handler. + - Login button `onClick` handler. (with email required at minimum) + - Social login buttons `onClick` handler. +*/ +export async function multipass( + options: MultipassOptions, +): Promise { + const {redirect, return_to: returnTo} = options; + + try { + // Generate multipass token POST `/account/login/multipass` + const response = await fetch('/account/login/multipass', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({return_to: returnTo}), + }); + + if (!response.ok) { + const message = `${response.status} /multipass response not ok. ${response.statusText}`; + throw new Error(message); + } + + // Extract multipass token and url + const {data, error} = (await response.json()) as MultipassTokenResponseType; + + if (error) { + throw new Error(error); + } + + if (!data?.url) { + throw new Error('Missing multipass url'); + } + + // return the url and token + if (!redirect) { + return data; + } + + // redirect to the multipass url + window.location.href = data.url; + return data; + } catch (error) { + //@ts-expect-error error might not have message property + console.log('⚠️ Bypassing multipass checkout due to', error.message); + + const message = error instanceof Error ? error.message : 'Unknown error'; + if (!redirect) { + return { + url: null, + token: null, + error: message, + }; + } + + if (returnTo) { + window.location.href = returnTo; + } + + return {url: null, token: null, error: message}; + } +} +``` + +### Step 3: app/root.tsx + + + +#### File: /app/root.tsx + +```diff +@@ -1,4 +1,15 @@ +-import {Analytics, getShopAnalytics, useNonce} from '@shopify/hydrogen'; ++import { ++ Analytics, ++ getShopAnalytics, ++ useNonce, ++ type HydrogenSession, ++} from '@shopify/hydrogen'; ++ ++// @description Define CustomerAccessToken type for multipass ++type CustomerAccessToken = { ++ accessToken: string; ++ expiresAt: string; ++}; + import { + Outlet, + useRouteError, +@@ -110,7 +121,14 @@ async function loadCriticalData({context}: Route.LoaderArgs) { + // Add other queries here, so that they are loaded in parallel + ]); + +- return {header}; ++ // @description Validate customer authentication for multipass ++ const customerAccessToken = await context.session.get('customerAccessToken'); ++ const isLoggedIn = await validateCustomerAccessToken( ++ context.session, ++ customerAccessToken, ++ ); ++ ++ return {header, isLoggedIn: Promise.resolve(isLoggedIn)}; + } + + /** +@@ -202,3 +220,24 @@ export function ErrorBoundary() { + + ); + } ++ ++// @description Validate customer access token for multipass authentication ++export async function validateCustomerAccessToken( ++ session: HydrogenSession, ++ customerAccessToken?: CustomerAccessToken, ++) { ++ if (!customerAccessToken?.accessToken || !customerAccessToken?.expiresAt) { ++ return false; ++ } ++ ++ const expiresAt = new Date(customerAccessToken.expiresAt).getTime(); ++ const dateNow = Date.now(); ++ const customerAccessTokenExpired = expiresAt < dateNow; ++ ++ if (customerAccessTokenExpired) { ++ session.unset('customerAccessToken'); ++ return false; ++ } ++ ++ return true; ++} +``` + +### Step 3: app/lib/multipass/multipassify.server.ts + + + +#### File: [multipassify.server.ts](https://github.com/Shopify/hydrogen/blob/6681f92e84d42b5a6aca153fb49e31dcd8af84f6/cookbook/recipes/multipass/ingredients/templates/skeleton/app/lib/multipass/multipassify.server.ts) + +```ts +import CryptoJS from 'crypto-js'; +import type {MultipassCustomer} from './types'; + +// Simple snake_case converter for ESM/Worker runtime +function toSnakeCase(str: string): string { + return str.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, ''); +} + +function snakecaseKeys(obj: any): any { + if (typeof obj !== 'object' || obj === null) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(snakecaseKeys); + } + + const result: any = {}; + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + const snakeKey = toSnakeCase(key); + result[snakeKey] = snakecaseKeys(obj[key]); + } + } + return result; +} + +/* + Shopify multipassify implementation for node and v8/worker runtime + based on crypto-js. This library is used to generate and parse multipass tokens. + ------------------------------------------------------------ + @see: https://shopify.dev/api/multipass — Shopify multipass + @see: https://github.com/beaucoo/multipassify — Previous art for Node-only runtime +*/ +export class Multipassify { + private readonly BLOCK_SIZE: number; + private encryptionKey: CryptoJS.lib.WordArray; + private signingKey: CryptoJS.lib.WordArray; + + public constructor(secret: string) { + if (!(typeof secret == 'string' && secret.length > 0)) { + throw new Error('Invalid Secret'); + } + + this.BLOCK_SIZE = 16; + + // Hash the secret + const digest = CryptoJS.SHA256(secret); + + // create the encryption and signing keys + this.encryptionKey = CryptoJS.lib.WordArray.create( + digest.words.slice(0, this.BLOCK_SIZE / 4), + ); + this.signingKey = CryptoJS.lib.WordArray.create( + digest.words.slice(this.BLOCK_SIZE / 4, this.BLOCK_SIZE / 2), + ); + + return this; + } + + // Generates an auth `token` and `url` for a customer based + // on the `return_to` url property found in the customer object + public generate( + customer: MultipassCustomer, + shopifyDomain: string, + request: Request, + ) { + if (!shopifyDomain) { + throw new Error('domain is required'); + } + if (!customer?.email) { + throw new Error('customer email is required'); + } + + // Generate a token + const token = this.generateToken(snakecaseKeys(customer)); + + // Get the origin of the request + const toOrigin = new URL(request.url).origin; + + const redirectToCheckout = customer.return_to + ? customer.return_to.includes('cart/c') || + customer.return_to.includes('checkout') + : false; + + // if the target url is the checkout, we use the shopify domain liquid auth + const toUrl = redirectToCheckout + ? `https://${shopifyDomain}/account/login/multipass/${token}` // uses liquid multipass auth + : `${toOrigin}/account/login/multipass/${token}`; // uses local multipass verification. @see: api route + + return {url: toUrl, token}; + } + + // Generates a token + public generateToken(customer: MultipassCustomer): string { + // Store the current time in ISO8601 format. + // The token will only be valid for a small time-frame around this timestamp. + customer.created_at = new Date().toISOString(); + + const encrypted = this.encrypt(JSON.stringify(customer)); + const signature = this.sign(encrypted); + + const token = encrypted.concat(signature); + let token64 = token.toString(CryptoJS.enc.Base64); + + token64 = token64 + .replace(/\+/g, '-') // Replace + with - + .replace(/\//g, '_'); // Replace / with _ + + return token64; + } + + // encrypt the customer data + private encrypt(customerText: string) { + // Use a random IV + const iv = CryptoJS.lib.WordArray.random(this.BLOCK_SIZE); + + const cipher = CryptoJS.AES.encrypt(customerText, this.encryptionKey, { + iv, + mode: CryptoJS.mode.CBC, + padding: CryptoJS.pad.Pkcs7, + }); + + // Append iv as first block of the encryption + return iv.concat(cipher.ciphertext); + } + + // signs the encrypted customer data + private sign(encrypted: any) { + return CryptoJS.HmacSHA256(encrypted, this.signingKey); + } + + // Decrypts the customer data from a multipass token + public parseToken(token: string): MultipassCustomer { + // reverse char replaces + const token64 = token.replace(/-/g, '+').replace(/_/g, '/'); + const tokenBytes = CryptoJS.enc.Base64.parse(token64); + + const encryptLength = tokenBytes.words.length - 8; // all minus 8 words of the signature + const _encrypted = CryptoJS.lib.WordArray.create( + tokenBytes.words.slice(0, encryptLength), + ); + + const iv = CryptoJS.lib.WordArray.create( + _encrypted.words.slice(0, this.BLOCK_SIZE / 4), + ); + + const encrypted = CryptoJS.lib.WordArray.create( + _encrypted.words.slice(iv.words.length, _encrypted.words.length), + ); + + const encryptedCustomer = CryptoJS.enc.Base64.stringify(encrypted); + + const decryptedCustomer = CryptoJS.AES.decrypt( + encryptedCustomer, + this.encryptionKey, + { + iv, + mode: CryptoJS.mode.CBC, + padding: CryptoJS.pad.Pkcs7, + }, + ); + + const customerText = decryptedCustomer.toString(CryptoJS.enc.Utf8); + const customer = JSON.parse(customerText) as MultipassCustomer; + + // Check if the token is still valid + const now = new Date().toISOString(); + if (customer.created_at > now) { + throw new Error('Token expired'); + } + + return customer; + } +} +``` + +### Step 4: app/routes/account.$.tsx + + + +#### File: /app/routes/account.$.tsx + +```diff +@@ -1,9 +1,9 @@ + import {redirect} from 'react-router'; + import type {Route} from './+types/account.$'; + +-// fallback wild card for all unauthenticated routes in account section + export async function loader({context}: Route.LoaderArgs) { +- context.customerAccount.handleAuthStatus(); +- +- return redirect('/account'); +-} ++ if (await context.session.get('customerAccessToken')) { ++ return redirect('/account'); ++ } ++ return redirect('/account/login'); ++} +\ No newline at end of file +``` + +### Step 4: app/lib/multipass/types.ts + + + +#### File: [types.ts](https://github.com/Shopify/hydrogen/blob/6681f92e84d42b5a6aca153fb49e31dcd8af84f6/cookbook/recipes/multipass/ingredients/templates/skeleton/app/lib/multipass/types.ts) + +```ts +/* + multipass and multipassify types +*/ +export interface MultipassResponse { + /* the multipass-authenticated targetUrl */ + url: string | null; + /* the multipass-authenticated token */ + token: string | null; + /* Errors that occurred while authenticating via multipass. Includes any errors return from /multipass api route */ + error?: string | null; +} + +export interface MultipassCustomer { + /* The customer email of the customer used during authentication */ + email: string; + /* The `targetUrl` passed in for authentication */ + return_to: string; + /* additional customer properties such as `acceptsMarketing`, addresses etc. */ + [key: string]: string | boolean | object | object[]; +} + +export interface MultipassCustomerData { + customer?: MultipassCustomer; +} + +export interface NotAuthResponseType { + url: string | null; + error: string | null; +} + +export type MultipassOptions = { + redirect: boolean; + return_to: string; +}; + +/* + api handlers +*/ +export interface QueryError { + message: string; + code: string; + field: string; +} + +export interface CustomerInfoType { + email: string; + return_to: string; + [key: string]: string | boolean | object | object[]; +} + +export type MultipassRequestBody = MultipassOptions; + +export interface CustomerDataResponseType { + data: MultipassRequestBody; + errors: string | null; +} + +export interface NotLoggedInResponseType { + url: string | null; + error: string | null; +} + +export interface MultipassTokenResponseType { + data: { + url: string; + token: string; + }; + error: string | null; +} +``` + +### Step 5: app/routes/account._index.tsx + + + +#### File: /app/routes/account._index.tsx + +```diff +@@ -2,4 +2,4 @@ import {redirect} from 'react-router'; + + export async function loader() { + return redirect('/account/orders'); +-} ++} +\ No newline at end of file +``` + +### Step 5: app/routes/account_.activate.$id.$activationToken.tsx + + + +#### File: [account_.activate.$id.$activationToken.tsx](https://github.com/Shopify/hydrogen/blob/6681f92e84d42b5a6aca153fb49e31dcd8af84f6/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.activate.$id.$activationToken.tsx) + +```tsx +import {Form, useActionData, data, redirect} from 'react-router'; +import type {Route} from './+types/account_.activate.$id.$activationToken'; + +type ActionResponse = { + error: string | null; +}; + +export const meta: Route.MetaFunction = () => { + return [{title: 'Activate Account'}]; +}; + +export async function loader({context}: Route.LoaderArgs) { + if (await context.session.get('customerAccessToken')) { + return redirect('/account'); + } + return {}; +} + +export async function action({request, context, params}: Route.ActionArgs) { + const {session, storefront} = context; + const {id, activationToken} = params; + + if (request.method !== 'POST') { + return data({error: 'Method not allowed'}, {status: 405}); + } + + try { + if (!id || !activationToken) { + throw new Error('Missing token. The link you followed might be wrong.'); + } + + const form = await request.formData(); + const password = form.has('password') ? String(form.get('password')) : null; + const passwordConfirm = form.has('passwordConfirm') + ? String(form.get('passwordConfirm')) + : null; + + const validPasswords = + password && passwordConfirm && password === passwordConfirm; + + if (!validPasswords) { + throw new Error('Passwords do not match'); + } + + const {customerActivate} = await storefront.mutate( + CUSTOMER_ACTIVATE_MUTATION, + { + variables: { + id: `gid://shopify/Customer/${id}`, + input: { + password, + activationToken, + }, + }, + }, + ); + + if (customerActivate?.customerUserErrors?.length) { + throw new Error(customerActivate.customerUserErrors[0].message); + } + + const {customerAccessToken} = customerActivate ?? {}; + if (!customerAccessToken) { + throw new Error('Could not activate account.'); + } + session.set('customerAccessToken', customerAccessToken); + + return redirect('/account'); + } catch (error: unknown) { + if (error instanceof Error) { + return data({error: error.message}, {status: 400}); + } + return data({error}, {status: 400}); + } +} + +export default function Activate() { + const action = useActionData(); + const error = action?.error ?? null; + + return ( +
+

Activate Account.

+

Create your password to activate your account.

+
+
+ + + + +
+ {error ? ( +

+ + {error} + +

+ ) : ( +
+ )} + +
+
+ ); +} + +// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customeractivate +const CUSTOMER_ACTIVATE_MUTATION = `#graphql + mutation customerActivate( + $id: ID!, + $input: CustomerActivateInput!, + $country: CountryCode, + $language: LanguageCode + ) @inContext(country: $country, language: $language) { + customerActivate(id: $id, input: $input) { + customerAccessToken { + accessToken + expiresAt + } + customerUserErrors { + code + field + message + } + } + } +` as const; +``` + +### Step 6: app/routes/account.addresses.tsx + + + +#### File: /app/routes/account.addresses.tsx + +```diff +@@ -1,22 +1,14 @@ +-import type {CustomerAddressInput} from '@shopify/hydrogen/customer-account-api-types'; +-import type { +- AddressFragment, +- CustomerFragment, +-} from 'customer-accountapi.generated'; ++import type {MailingAddressInput} from '@shopify/hydrogen/storefront-api-types'; ++import type {AddressFragment, CustomerFragment} from 'storefrontapi.generated'; + import { +- data, + Form, + useActionData, + useNavigation, + useOutletContext, +- type Fetcher, ++ data, ++ redirect, + } from 'react-router'; + import type {Route} from './+types/account.addresses'; +-import { +- UPDATE_ADDRESS_MUTATION, +- DELETE_ADDRESS_MUTATION, +- CREATE_ADDRESS_MUTATION, +-} from '~/graphql/customer-account/CustomerAddressMutations'; + + export type ActionResponse = { + addressId?: string | null; +@@ -32,13 +24,16 @@ export const meta: Route.MetaFunction = () => { + }; + + export async function loader({context}: Route.LoaderArgs) { +- context.customerAccount.handleAuthStatus(); +- ++ const {session} = context; ++ const customerAccessToken = await session.get('customerAccessToken'); ++ if (!customerAccessToken) { ++ return redirect('/account/login'); ++ } + return {}; + } + + export async function action({request, context}: Route.ActionArgs) { +- const {customerAccount} = context; ++ const {storefront, session} = context; + + try { + const form = await request.formData(); +@@ -50,31 +45,26 @@ export async function action({request, context}: Route.ActionArgs) { + throw new Error('You must provide an address id.'); + } + +- // this will ensure redirecting to login never happen for mutatation +- const isLoggedIn = await customerAccount.isLoggedIn(); +- if (!isLoggedIn) { +- return data( +- {error: {[addressId]: 'Unauthorized'}}, +- { +- status: 401, +- }, +- ); ++ const customerAccessToken = await session.get('customerAccessToken'); ++ if (!customerAccessToken) { ++ return data({error: {[addressId]: 'Unauthorized'}}, {status: 401}); + } ++ const {accessToken} = customerAccessToken; + + const defaultAddress = form.has('defaultAddress') + ? String(form.get('defaultAddress')) === 'on' +- : false; +- const address: CustomerAddressInput = {}; +- const keys: (keyof CustomerAddressInput)[] = [ ++ : null; ++ const address: MailingAddressInput = {}; ++ const keys: (keyof MailingAddressInput)[] = [ + 'address1', + 'address2', + 'city', + 'company', +- 'territoryCode', ++ 'country', + 'firstName', + 'lastName', +- 'phoneNumber', +- 'zoneCode', ++ 'phone', ++ 'province', + 'zip', + ]; + +@@ -89,143 +79,119 @@ export async function action({request, context}: Route.ActionArgs) { + case 'POST': { + // handle new address creation + try { +- const {data, errors} = await customerAccount.mutate( ++ const {customerAddressCreate} = await storefront.mutate( + CREATE_ADDRESS_MUTATION, + { +- variables: { +- address, +- defaultAddress, +- language: customerAccount.i18n.language, +- }, ++ variables: {customerAccessToken: accessToken, address}, + }, + ); + +- if (errors?.length) { +- throw new Error(errors[0].message); ++ if (customerAddressCreate?.customerUserErrors?.length) { ++ const error = customerAddressCreate.customerUserErrors[0]; ++ throw new Error(error.message); + } + +- if (data?.customerAddressCreate?.userErrors?.length) { +- throw new Error(data?.customerAddressCreate?.userErrors[0].message); +- } +- +- if (!data?.customerAddressCreate?.customerAddress) { +- throw new Error('Customer address create failed.'); +- } +- +- return { +- error: null, +- createdAddress: data?.customerAddressCreate?.customerAddress, +- defaultAddress, +- }; +- } catch (error: unknown) { +- if (error instanceof Error) { +- return data( +- {error: {[addressId]: error.message}}, +- { +- status: 400, +- }, ++ const createdAddress = customerAddressCreate?.customerAddress; ++ if (!createdAddress?.id) { ++ throw new Error( ++ 'Expected customer address to be created, but the id is missing', + ); + } +- return data( +- {error: {[addressId]: error}}, +- { +- status: 400, +- }, +- ); ++ ++ if (defaultAddress) { ++ const createdAddressId = decodeURIComponent(createdAddress.id); ++ const {customerDefaultAddressUpdate} = await storefront.mutate( ++ UPDATE_DEFAULT_ADDRESS_MUTATION, ++ { ++ variables: { ++ customerAccessToken: accessToken, ++ addressId: createdAddressId, ++ }, ++ }, ++ ); ++ ++ if (customerDefaultAddressUpdate?.customerUserErrors?.length) { ++ const error = customerDefaultAddressUpdate.customerUserErrors[0]; ++ throw new Error(error.message); ++ } ++ } ++ ++ return {error: null, createdAddress, defaultAddress}; ++ } catch (error: unknown) { ++ if (error instanceof Error) { ++ return data({error: {[addressId]: error.message}}, {status: 400}); ++ } ++ return data({error: {[addressId]: error}}, {status: 400}); + } + } + + case 'PUT': { + // handle address updates + try { +- const {data, errors} = await customerAccount.mutate( ++ const {customerAddressUpdate} = await storefront.mutate( + UPDATE_ADDRESS_MUTATION, + { + variables: { + address, +- addressId: decodeURIComponent(addressId), +- defaultAddress, +- language: customerAccount.i18n.language, ++ customerAccessToken: accessToken, ++ id: decodeURIComponent(addressId), + }, + }, + ); + +- if (errors?.length) { +- throw new Error(errors[0].message); ++ const updatedAddress = customerAddressUpdate?.customerAddress; ++ ++ if (customerAddressUpdate?.customerUserErrors?.length) { ++ const error = customerAddressUpdate.customerUserErrors[0]; ++ throw new Error(error.message); + } + +- if (data?.customerAddressUpdate?.userErrors?.length) { +- throw new Error(data?.customerAddressUpdate?.userErrors[0].message); +- } +- +- if (!data?.customerAddressUpdate?.customerAddress) { +- throw new Error('Customer address update failed.'); +- } +- +- return { +- error: null, +- updatedAddress: address, +- defaultAddress, +- }; +- } catch (error: unknown) { +- if (error instanceof Error) { +- return data( +- {error: {[addressId]: error.message}}, ++ if (defaultAddress) { ++ const {customerDefaultAddressUpdate} = await storefront.mutate( ++ UPDATE_DEFAULT_ADDRESS_MUTATION, + { +- status: 400, ++ variables: { ++ customerAccessToken: accessToken, ++ addressId: decodeURIComponent(addressId), ++ }, + }, + ); ++ ++ if (customerDefaultAddressUpdate?.customerUserErrors?.length) { ++ const error = customerDefaultAddressUpdate.customerUserErrors[0]; ++ throw new Error(error.message); ++ } + } +- return data( +- {error: {[addressId]: error}}, +- { +- status: 400, +- }, +- ); ++ ++ return {error: null, updatedAddress, defaultAddress}; ++ } catch (error: unknown) { ++ if (error instanceof Error) { ++ return data({error: {[addressId]: error.message}}, {status: 400}); ++ } ++ return data({error: {[addressId]: error}}, {status: 400}); + } + } + + case 'DELETE': { + // handles address deletion + try { +- const {data, errors} = await customerAccount.mutate( ++ const {customerAddressDelete} = await storefront.mutate( + DELETE_ADDRESS_MUTATION, + { +- variables: { +- addressId: decodeURIComponent(addressId), +- language: customerAccount.i18n.language, +- }, ++ variables: {customerAccessToken: accessToken, id: addressId}, + }, + ); + +- if (errors?.length) { +- throw new Error(errors[0].message); ++ if (customerAddressDelete?.customerUserErrors?.length) { ++ const error = customerAddressDelete.customerUserErrors[0]; ++ throw new Error(error.message); + } +- +- if (data?.customerAddressDelete?.userErrors?.length) { +- throw new Error(data?.customerAddressDelete?.userErrors[0].message); +- } +- +- if (!data?.customerAddressDelete?.deletedAddressId) { +- throw new Error('Customer address delete failed.'); +- } +- + return {error: null, deletedAddress: addressId}; + } catch (error: unknown) { + if (error instanceof Error) { +- return data( +- {error: {[addressId]: error.message}}, +- { +- status: 400, +- }, +- ); ++ return data({error: {[addressId]: error.message}}, {status: 400}); + } +- return data( +- {error: {[addressId]: error}}, +- { +- status: 400, +- }, +- ); ++ return data({error: {[addressId]: error}}, {status: 400}); + } + } + +@@ -291,21 +257,17 @@ function NewAddressForm() { + address2: '', + city: '', + company: '', +- territoryCode: '', ++ country: '', + firstName: '', + id: 'new', + lastName: '', +- phoneNumber: '', +- zoneCode: '', ++ phone: '', ++ province: '', + zip: '', +- } as CustomerAddressInput; ++ } as AddressFragment; + + return ( +- ++ + {({stateForMethod}) => ( +
+
+
+

+- ++ + View Order Status → + +

+@@ -195,27 +172,145 @@ export default function OrderRoute() { + + function OrderLineRow({lineItem}: {lineItem: OrderLineItemFullFragment}) { + return ( +- ++ + +
+- {lineItem?.image && ( +-
+- +-
+- )} ++ ++ {lineItem?.variant?.image && ( ++
++ ++
++ )} ++ +
+

{lineItem.title}

+- {lineItem.variantTitle} ++ {lineItem.variant!.title} +
+
+ + +- ++ + + {lineItem.quantity} + +- ++ + + + ); + } ++ ++// NOTE: https://shopify.dev/docs/api/storefront/latest/objects/Order ++const CUSTOMER_ORDER_QUERY = `#graphql ++ fragment OrderMoney 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 { ++ ...OrderMoney ++ } ++ ... on PricingPercentageValue { ++ percentage ++ } ++ } ++ } ++ fragment OrderLineProductVariant on ProductVariant { ++ id ++ image { ++ altText ++ height ++ url ++ id ++ width ++ } ++ price { ++ ...OrderMoney ++ } ++ product { ++ handle ++ } ++ sku ++ title ++ } ++ fragment OrderLineItemFull on OrderLineItem { ++ title ++ quantity ++ discountAllocations { ++ allocatedAmount { ++ ...OrderMoney ++ } ++ discountApplication { ++ ...DiscountApplication ++ } ++ } ++ originalTotalPrice { ++ ...OrderMoney ++ } ++ discountedTotalPrice { ++ ...OrderMoney ++ } ++ variant { ++ ...OrderLineProductVariant ++ } ++ } ++ fragment Order on Order { ++ id ++ name ++ orderNumber ++ statusUrl ++ processedAt ++ fulfillmentStatus ++ totalTaxV2 { ++ ...OrderMoney ++ } ++ totalPriceV2 { ++ ...OrderMoney ++ } ++ subtotalPriceV2 { ++ ...OrderMoney ++ } ++ shippingAddress { ++ ...AddressFull ++ } ++ discountApplications(first: 100) { ++ nodes { ++ ...DiscountApplication ++ } ++ } ++ lineItems(first: 100) { ++ nodes { ++ ...OrderLineItemFull ++ } ++ } ++ } ++ query Order( ++ $country: CountryCode ++ $language: LanguageCode ++ $orderId: ID! ++ ) @inContext(country: $country, language: $language) { ++ order: node(id: $orderId) { ++ ... on Order { ++ ...Order ++ } ++ } ++ } ++` as const; +\ No newline at end of file +``` + +### Step 7: app/routes/account_.recover.tsx + + + +#### File: [account_.recover.tsx](https://github.com/Shopify/hydrogen/blob/6681f92e84d42b5a6aca153fb49e31dcd8af84f6/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.recover.tsx) + +```tsx +import {Form, Link, useActionData, data, redirect} from 'react-router'; +import type {Route} from './+types/account_.recover'; + +type ActionResponse = { + error?: string; + resetRequested?: boolean; +}; + +export async function loader({context}: Route.LoaderArgs) { + const customerAccessToken = await context.session.get('customerAccessToken'); + if (customerAccessToken) { + return redirect('/account'); + } + + return {}; +} + +export async function action({request, context}: Route.ActionArgs) { + const {storefront} = context; + const form = await request.formData(); + const email = form.has('email') ? String(form.get('email')) : null; + + if (request.method !== 'POST') { + return data({error: 'Method not allowed'}, {status: 405}); + } + + try { + if (!email) { + throw new Error('Please provide an email.'); + } + await storefront.mutate(CUSTOMER_RECOVER_MUTATION, { + variables: {email}, + }); + + return {resetRequested: true}; + } catch (error: unknown) { + const resetRequested = false; + if (error instanceof Error) { + return data({error: error.message, resetRequested}, {status: 400}); + } + return data({error, resetRequested}, {status: 400}); + } +} + +export default function Recover() { + const action = useActionData(); + + return ( +
+
+ {action?.resetRequested ? ( + <> +

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. +

+
+ Return to Login + + ) : ( + <> +

Forgot Password.

+

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

+
+ +
+ + +
+ {action?.error ? ( +

+ + {action.error} + +

+ ) : ( +
+ )} + + +
+
+

+ Login → +

+
+ + )} +
+
+ ); +} + +// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customerrecover +const CUSTOMER_RECOVER_MUTATION = `#graphql + mutation customerRecover( + $email: String!, + $country: CountryCode, + $language: LanguageCode + ) @inContext(country: $country, language: $language) { + customerRecover(email: $email) { + customerUserErrors { + code + field + message + } + } + } +` as const; +``` + +### Step 8: app/routes/account.orders._index.tsx + + + +#### File: /app/routes/account.orders._index.tsx + +```diff +@@ -1,52 +1,60 @@ +-import { +- Link, +- useLoaderData, +-} from 'react-router'; ++import {Link, useLoaderData, data, redirect} from 'react-router'; + import type {Route} from './+types/account.orders._index'; +-import { +- Money, +- getPaginationVariables, +- flattenConnection, +-} from '@shopify/hydrogen'; +-import {CUSTOMER_ORDERS_QUERY} from '~/graphql/customer-account/CustomerOrdersQuery'; ++import {Money, Pagination, getPaginationVariables} from '@shopify/hydrogen'; + import type { + CustomerOrdersFragment, + OrderItemFragment, +-} from 'customer-accountapi.generated'; +-import {PaginatedResourceSection} from '~/components/PaginatedResourceSection'; ++} from 'storefrontapi.generated'; + + export const meta: Route.MetaFunction = () => { + return [{title: 'Orders'}]; + }; + + export async function loader({request, context}: Route.LoaderArgs) { +- const {customerAccount} = context; +- const paginationVariables = getPaginationVariables(request, { +- pageBy: 20, +- }); ++ const {session, storefront} = context; + +- const {data, errors} = await customerAccount.query( +- CUSTOMER_ORDERS_QUERY, +- { +- variables: { +- ...paginationVariables, +- language: customerAccount.i18n.language, +- }, +- }, +- ); +- +- if (errors?.length || !data?.customer) { +- throw Error('Customer orders not found'); ++ const customerAccessToken = await session.get('customerAccessToken'); ++ if (!customerAccessToken?.accessToken) { ++ return redirect('/account/login'); + } + +- return {customer: data.customer}; ++ try { ++ const paginationVariables = getPaginationVariables(request, { ++ pageBy: 20, ++ }); ++ ++ const {customer} = await storefront.query(CUSTOMER_ORDERS_QUERY, { ++ variables: { ++ customerAccessToken: customerAccessToken.accessToken, ++ country: storefront.i18n.country, ++ language: storefront.i18n.language, ++ ...paginationVariables, ++ }, ++ cache: storefront.CacheNone(), ++ }); ++ ++ if (!customer) { ++ throw new Error('Customer not found'); ++ } ++ ++ return {customer}; ++ } catch (error: unknown) { ++ if (error instanceof Error) { ++ return data({error: error.message}, {status: 400}); ++ } ++ return data({error}, {status: 400}); ++ } + } + + export default function Orders() { + const {customer} = useLoaderData<{customer: CustomerOrdersFragment}>(); +- const {orders} = customer; ++ const {orders, numberOfOrders} = customer; + return ( +
++

++ Orders ({numberOfOrders}) ++

++
+ {orders.nodes.length ? : } +
+ ); +@@ -56,9 +64,23 @@ function OrdersTable({orders}: Pick) { + return ( +
+ {orders?.nodes.length ? ( +- +- {({node: order}) => } +- ++ ++ {({nodes, isLoading, PreviousLink, NextLink}) => { ++ return ( ++ <> ++ ++ {isLoading ? 'Loading...' : ↑ Load previous} ++ ++ {nodes.map((order) => { ++ return ; ++ })} ++ ++ {isLoading ? 'Loading...' : Load more ↓} ++ ++ ++ ); ++ }} ++ + ) : ( + + )} +@@ -79,20 +101,91 @@ function EmptyOrders() { + } + + function OrderItem({order}: {order: OrderItemFragment}) { +- const fulfillmentStatus = flattenConnection(order.fulfillments)[0]?.status; + return ( + <> +
+- +- #{order.number} ++ ++ #{order.orderNumber} + +

{new Date(order.processedAt).toDateString()}

+

{order.financialStatus}

+- {fulfillmentStatus &&

{fulfillmentStatus}

} +- ++

{order.fulfillmentStatus}

++ + View Order → +
+
+ + ); + } ++ ++const ORDER_ITEM_FRAGMENT = `#graphql ++ fragment OrderItem on Order { ++ currentTotalPrice { ++ amount ++ currencyCode ++ } ++ financialStatus ++ fulfillmentStatus ++ id ++ lineItems(first: 10) { ++ nodes { ++ title ++ variant { ++ image { ++ url ++ altText ++ height ++ width ++ } ++ } ++ } ++ } ++ orderNumber ++ customerUrl ++ statusUrl ++ processedAt ++ } ++` as const; ++ ++export const CUSTOMER_FRAGMENT = `#graphql ++ fragment CustomerOrders on Customer { ++ numberOfOrders ++ orders( ++ sortKey: PROCESSED_AT, ++ reverse: true, ++ first: $first, ++ last: $last, ++ before: $startCursor, ++ after: $endCursor ++ ) { ++ nodes { ++ ...OrderItem ++ } ++ pageInfo { ++ hasPreviousPage ++ hasNextPage ++ endCursor ++ startCursor ++ } ++ } ++ } ++ ${ORDER_ITEM_FRAGMENT} ++` as const; ++ ++// NOTE: https://shopify.dev/docs/api/storefront/latest/queries/customer ++const CUSTOMER_ORDERS_QUERY = `#graphql ++ ${CUSTOMER_FRAGMENT} ++ query CustomerOrders( ++ $country: CountryCode ++ $customerAccessToken: String! ++ $endCursor: String ++ $first: Int ++ $language: LanguageCode ++ $last: Int ++ $startCursor: String ++ ) @inContext(country: $country, language: $language) { ++ customer(customerAccessToken: $customerAccessToken) { ++ ...CustomerOrders ++ } ++ } ++` as const; +\ No newline at end of file +``` + +### Step 8: app/routes/account_.register.tsx + + + +#### File: [account_.register.tsx](https://github.com/Shopify/hydrogen/blob/6681f92e84d42b5a6aca153fb49e31dcd8af84f6/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.register.tsx) + +```tsx +import {Form, Link, useActionData, data, redirect} from 'react-router'; +import type {Route} from './+types/account_.register'; +import type {CustomerCreateMutation} from 'storefrontapi.generated'; + +type ActionResponse = { + error: string | null; + newCustomer: + | NonNullable['customer'] + | null; +}; + +export const headers: Route.HeadersFunction = ({actionHeaders}) => actionHeaders; + +export async function loader({context}: Route.LoaderArgs) { + const customerAccessToken = await context.session.get('customerAccessToken'); + if (customerAccessToken) { + return redirect('/account'); + } + + return {}; +} + +export async function action({request, context}: Route.ActionArgs) { + if (request.method !== 'POST') { + return data({error: 'Method not allowed'}, {status: 405}); + } + + const {storefront, session} = context; + const form = await request.formData(); + const email = String(form.has('email') ? form.get('email') : ''); + const password = form.has('password') ? String(form.get('password')) : null; + const passwordConfirm = form.has('passwordConfirm') + ? String(form.get('passwordConfirm')) + : null; + + const validPasswords = + password && passwordConfirm && password === passwordConfirm; + + const validInputs = Boolean(email && password); + try { + if (!validPasswords) { + throw new Error('Passwords do not match'); + } + + if (!validInputs) { + throw new Error('Please provide both an email and a password.'); + } + + const {customerCreate} = await storefront.mutate(CUSTOMER_CREATE_MUTATION, { + variables: { + input: {email, password}, + }, + }); + + if (customerCreate?.customerUserErrors?.length) { + throw new Error(customerCreate?.customerUserErrors[0].message); + } + + const newCustomer = customerCreate?.customer; + if (!newCustomer?.id) { + throw new Error('Could not create customer'); + } + + // get an access token for the new customer + const {customerAccessTokenCreate} = await storefront.mutate( + REGISTER_LOGIN_MUTATION, + { + variables: { + input: { + email, + password, + }, + }, + }, + ); + + if (!customerAccessTokenCreate?.customerAccessToken?.accessToken) { + throw new Error('Missing access token'); + } + session.set( + 'customerAccessToken', + customerAccessTokenCreate?.customerAccessToken, + ); + + return data( + {error: null, newCustomer}, + { + status: 302, + headers: { + Location: '/account', + }, + }, + ); + } catch (error: unknown) { + if (error instanceof Error) { + return data({error: error.message}, {status: 400}); + } + return data({error}, {status: 400}); + } +} + +export default function Register() { + const data = useActionData(); + const error = data?.error || null; + return ( +
+

Register.

+
+
+ + + + + + +
+ {error ? ( +

+ + {error} + +

+ ) : ( +
+ )} + +
+
+

+ Login → +

+
+ ); +} + +// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customerCreate +const CUSTOMER_CREATE_MUTATION = `#graphql + mutation customerCreate( + $input: CustomerCreateInput!, + $country: CountryCode, + $language: LanguageCode + ) @inContext(country: $country, language: $language) { + customerCreate(input: $input) { + customer { + id + } + customerUserErrors { + code + field + message + } + } + } +` as const; + +// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customeraccesstokencreate +const REGISTER_LOGIN_MUTATION = `#graphql + mutation registerLogin( + $input: CustomerAccessTokenCreateInput!, + $country: CountryCode, + $language: LanguageCode + ) @inContext(country: $country, language: $language) { + customerAccessTokenCreate(input: $input) { + customerUserErrors { + code + field + message + } + customerAccessToken { + accessToken + expiresAt + } + } + } +` as const; +``` + +### Step 9: app/routes/account_.reset.$id.$resetToken.tsx + + + +#### File: [account_.reset.$id.$resetToken.tsx](https://github.com/Shopify/hydrogen/blob/6681f92e84d42b5a6aca153fb49e31dcd8af84f6/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.reset.$id.$resetToken.tsx) + +```tsx +import {data, Form, redirect, useActionData} from 'react-router'; +import type {Route} from './+types/account_.reset.$id.$resetToken'; + +type ActionResponse = { + error: string | null; +}; + +export const meta: Route.MetaFunction = () => { + return [{title: 'Reset Password'}]; +}; + +export async function action({request, context, params}: Route.ActionArgs) { + if (request.method !== 'POST') { + return data({error: 'Method not allowed'}, {status: 405}); + } + const {id, resetToken} = params; + const {session, storefront} = context; + + try { + if (!id || !resetToken) { + throw new Error('customer token or id not found'); + } + + const form = await request.formData(); + const password = form.has('password') ? String(form.get('password')) : ''; + const passwordConfirm = form.has('passwordConfirm') + ? String(form.get('passwordConfirm')) + : ''; + const validInputs = Boolean(password && passwordConfirm); + if (validInputs && password !== passwordConfirm) { + throw new Error('Please provide matching passwords'); + } + + const {customerReset} = await storefront.mutate(CUSTOMER_RESET_MUTATION, { + variables: { + id: `gid://shopify/Customer/${id}`, + input: {password, resetToken}, + }, + }); + + if (customerReset?.customerUserErrors?.length) { + throw new Error(customerReset?.customerUserErrors[0].message); + } + + if (!customerReset?.customerAccessToken) { + throw new Error('Access token not found. Please try again.'); + } + session.set('customerAccessToken', customerReset.customerAccessToken); + + return redirect('/account'); + } catch (error: unknown) { + if (error instanceof Error) { + return data({error: error.message}, {status: 400}); + } + return data({error}, {status: 400}); + } +} + +export default function Reset() { + const action = useActionData(); + + return ( +
+

Reset Password.

+

Enter a new password for your account.

+
+
+ + + + +
+ {action?.error ? ( +

+ + {action.error} + +

+ ) : ( +
+ )} + +
+
+

+ Back to login → +

+
+ ); +} + +// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customerreset +const CUSTOMER_RESET_MUTATION = `#graphql + mutation customerReset( + $id: ID!, + $input: CustomerResetInput! + $country: CountryCode + $language: LanguageCode + ) @inContext(country: $country, language: $language) { + customerReset(id: $id, input: $input) { + customerAccessToken { + accessToken + expiresAt + } + customerUserErrors { + code + field + message + } + } + } +` as const; +``` + +### Step 10: app/routes/account.tsx + + + +#### File: /app/routes/account.tsx + +```diff +@@ -1,44 +1,104 @@ + import { +- data as remixData, ++ data, + Form, + NavLink, + Outlet, ++ redirect, + useLoaderData, + } from 'react-router'; + import type {Route} from './+types/account'; +-import {CUSTOMER_DETAILS_QUERY} from '~/graphql/customer-account/CustomerDetailsQuery'; ++import type {CustomerFragment} from 'storefrontapi.generated'; + + export function shouldRevalidate() { + return true; + } + +-export async function loader({context}: Route.LoaderArgs) { +- const {customerAccount} = context; +- const {data, errors} = await customerAccount.query( +- CUSTOMER_DETAILS_QUERY, +- { +- variables: { +- language: customerAccount.i18n.language, +- }, +- }, +- ); ++export const headers: Route.HeadersFunction = ({loaderHeaders}) => loaderHeaders; + +- if (errors?.length || !data?.customer) { +- throw new Error('Customer not found'); ++export async function loader({request, context}: Route.LoaderArgs) { ++ const {session, storefront} = context; ++ const {pathname} = new URL(request.url); ++ const customerAccessToken = await session.get('customerAccessToken'); ++ const isLoggedIn = !!customerAccessToken?.accessToken; ++ const isAccountHome = pathname === '/account' || pathname === '/account/'; ++ const isPrivateRoute = ++ /^\/account\/(orders|orders\/.*|profile|addresses|addresses\/.*)$/.test( ++ pathname, ++ ); ++ ++ if (!isLoggedIn) { ++ if (isPrivateRoute || isAccountHome) { ++ session.unset('customerAccessToken'); ++ return redirect('/account/login'); ++ } else { ++ // public subroute such as /account/login... ++ return { ++ isLoggedIn: false, ++ isAccountHome, ++ isPrivateRoute, ++ customer: null, ++ }; ++ } ++ } else { ++ // loggedIn, default redirect to the orders page ++ if (isAccountHome) { ++ return redirect('/account/orders'); ++ } + } + +- return remixData( +- {customer: data.customer}, +- { +- headers: { +- 'Cache-Control': 'no-cache, no-store, must-revalidate', ++ try { ++ const {customer} = await storefront.query(CUSTOMER_QUERY, { ++ variables: { ++ customerAccessToken: customerAccessToken.accessToken, ++ country: storefront.i18n.country, ++ language: storefront.i18n.language, + }, +- }, ++ cache: storefront.CacheNone(), ++ }); ++ ++ if (!customer) { ++ throw new Error('Customer not found'); ++ } ++ ++ return data( ++ {isLoggedIn, isPrivateRoute, isAccountHome, customer}, ++ { ++ headers: { ++ 'Cache-Control': 'no-cache, no-store, must-revalidate', ++ }, ++ }, ++ ); ++ } catch (error) { ++ console.error('There was a problem loading account', error); ++ session.unset('customerAccessToken'); ++ return redirect('/account/login'); ++ } ++} ++ ++export default function Account() { ++ const {customer, isPrivateRoute, isAccountHome} = ++ useLoaderData(); ++ ++ if (!isPrivateRoute && !isAccountHome) { ++ return ; ++ } ++ ++ return ( ++ ++
++
++ ++
+ ); + } + +-export default function AccountLayout() { +- const {customer} = useLoaderData(); ++function AccountLayout({ ++ customer, ++ children, ++}: { ++ customer: CustomerFragment; ++ children: React.ReactNode; ++}) { + + const heading = customer + ? customer.firstName +@@ -51,9 +111,7 @@ export default function AccountLayout() { +

{heading}

+
+ +-
+-
+- ++ {children} +
+ ); + } +@@ -98,3 +156,50 @@ function Logout() { + + ); + } ++ ++export const CUSTOMER_FRAGMENT = `#graphql ++ fragment Customer on Customer { ++ acceptsMarketing ++ addresses(first: 6) { ++ nodes { ++ ...Address ++ } ++ } ++ defaultAddress { ++ ...Address ++ } ++ email ++ firstName ++ lastName ++ numberOfOrders ++ phone ++ } ++ fragment Address on MailingAddress { ++ id ++ formatted ++ firstName ++ lastName ++ company ++ address1 ++ address2 ++ country ++ province ++ city ++ zip ++ phone ++ } ++` as const; ++ ++// NOTE: https://shopify.dev/docs/api/storefront/latest/queries/customer ++const CUSTOMER_QUERY = `#graphql ++ query Customer( ++ $customerAccessToken: String! ++ $country: CountryCode ++ $language: LanguageCode ++ ) @inContext(country: $country, language: $language) { ++ customer(customerAccessToken: $customerAccessToken) { ++ ...Customer ++ } ++ } ++ ${CUSTOMER_FRAGMENT} ++` as const; +\ No newline at end of file +``` + +### Step 12: app/routes/account_.login.tsx + + + +#### File: /app/routes/account_.login.tsx + +```diff +@@ -1,7 +1,133 @@ ++import {Form, Link, useActionData, data, redirect} from 'react-router'; + import type {Route} from './+types/account_.login'; + +-export async function loader({request, context}: Route.LoaderArgs) { +- return context.customerAccount.login({ +- countryCode: context.storefront.i18n.country, +- }); ++type ActionResponse = { ++ error: string | null; ++}; ++ ++export const meta: Route.MetaFunction = () => { ++ return [{title: 'Login'}]; ++}; ++ ++export async function loader({context}: Route.LoaderArgs) { ++ if (await context.session.get('customerAccessToken')) { ++ return redirect('/account'); ++ } ++ return {}; + } ++ ++export async function action({request, context}: Route.ActionArgs) { ++ const {session, storefront} = context; ++ ++ if (request.method !== 'POST') { ++ return data({error: 'Method not allowed'}, {status: 405}); ++ } ++ ++ try { ++ const form = await request.formData(); ++ const email = String(form.has('email') ? form.get('email') : ''); ++ const password = String(form.has('password') ? form.get('password') : ''); ++ const validInputs = Boolean(email && password); ++ ++ if (!validInputs) { ++ throw new Error('Please provide both an email and a password.'); ++ } ++ ++ const {customerAccessTokenCreate} = await storefront.mutate( ++ LOGIN_MUTATION, ++ { ++ variables: { ++ input: {email, password}, ++ }, ++ }, ++ ); ++ ++ if (!customerAccessTokenCreate?.customerAccessToken?.accessToken) { ++ throw new Error(customerAccessTokenCreate?.customerUserErrors[0].message); ++ } ++ ++ const {customerAccessToken} = customerAccessTokenCreate; ++ session.set('customerAccessToken', customerAccessToken); ++ ++ return redirect('/account'); ++ } catch (error: unknown) { ++ if (error instanceof Error) { ++ return data({error: error.message}, {status: 400}); ++ } ++ return data({error}, {status: 400}); ++ } ++} ++ ++export default function Login() { ++ const data = useActionData(); ++ const error = data?.error || null; ++ ++ return ( ++
++

Sign in.

++
++
++ ++ ++ ++ ++
++ {error ? ( ++

++ ++ {error} ++ ++

++ ) : ( ++
++ )} ++ ++
++
++
++

++ Forgot password → ++

++

++ Register → ++

++
++
++ ); ++} ++ ++// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customeraccesstokencreate ++const LOGIN_MUTATION = `#graphql ++ mutation login($input: CustomerAccessTokenCreateInput!) { ++ customerAccessTokenCreate(input: $input) { ++ customerUserErrors { ++ code ++ field ++ message ++ } ++ customerAccessToken { ++ accessToken ++ expiresAt ++ } ++ } ++ } ++` as const; +\ No newline at end of file +``` + +### Step 13: app/routes/account_.logout.tsx + + + +#### File: /app/routes/account_.logout.tsx + +```diff +@@ -1,11 +1,25 @@ +-import {redirect} from 'react-router'; ++import {data, redirect} from 'react-router'; + import type {Route} from './+types/account_.logout'; + +-// if we don't implement this, /account/logout will get caught by account.$.tsx to do login ++export const meta: Route.MetaFunction = () => { ++ return [{title: 'Logout'}]; ++}; ++ + export async function loader() { ++ return redirect('/account/login'); ++} ++ ++export async function action({request, context}: Route.ActionArgs) { ++ const {session} = context; ++ session.unset('customerAccessToken'); ++ ++ if (request.method !== 'POST') { ++ return data({error: 'Method not allowed'}, {status: 405}); ++ } ++ + return redirect('/'); + } + +-export async function action({context}: Route.ActionArgs) { +- return context.customerAccount.logout(); +-} ++export default function Logout() { ++ return null; ++} +\ No newline at end of file +``` + +### Step 13: env.d.ts + + + +#### File: /env.d.ts + +```diff +@@ -5,3 +5,9 @@ + + // Enhance TypeScript's built-in typings. + import '@total-typescript/ts-reset'; ++ ++declare global { ++ interface Env { ++ PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET?: string; ++ } ++} +``` + +### Step 14: app/routes/cart.tsx + + + +#### File: /app/routes/cart.tsx + +```diff +@@ -15,9 +15,13 @@ export const meta: Route.MetaFunction = () => { + export const headers: HeadersFunction = ({actionHeaders}) => actionHeaders; + + export async function action({request, context}: Route.ActionArgs) { +- const {cart} = context; ++ // @description Get session for multipass customer token persistence ++ const {session, cart} = context; + +- const formData = await request.formData(); ++ const [formData, customerAccessToken] = await Promise.all([ ++ request.formData(), ++ session.get('customerAccessToken'), ++ ]); + + const {action, inputs} = CartForm.getFormInput(formData); + +@@ -69,6 +73,8 @@ export async function action({request, context}: Route.ActionArgs) { + case CartForm.ACTIONS.BuyerIdentityUpdate: { + result = await cart.updateBuyerIdentity({ + ...inputs.buyerIdentity, ++ // @description Add customer access token for multipass checkout ++ customerAccessToken: customerAccessToken?.accessToken, + }); + break; + } +``` + +### Step 15: package.json + + + +#### File: /package.json + +```diff +@@ -15,6 +15,7 @@ + "prettier": "@shopify/prettier-config", + "dependencies": { + "@shopify/hydrogen": "2025.5.0", ++ "crypto-js": "^4.2.0", + "graphql": "^16.10.0", + "graphql-tag": "^2.12.6", + "isbot": "^5.1.22", +@@ -36,6 +37,7 @@ + "@shopify/oxygen-workers-types": "^4.1.6", + "@shopify/prettier-config": "^1.1.2", + "@total-typescript/ts-reset": "^0.6.1", ++ "@types/crypto-js": "^4.2.2", + "@types/eslint": "^9.6.1", + "@types/react": "^18.2.22", + "@types/react-dom": "^18.2.7", +``` + +### Step 16: vite.config.ts + + + +#### File: /vite.config.ts + +```diff +@@ -26,7 +26,7 @@ export default defineConfig({ + * Include 'example-dep' in the array below. + * @see https://vitejs.dev/config/dep-optimization-options + */ +- include: ['set-cookie-parser', 'cookie', 'react-router'], ++ include: ['set-cookie-parser', 'cookie', 'react-router', 'crypto-js'], + }, + }, + }); +``` + +
\ No newline at end of file diff --git a/examples/multipass/app/components/MultipassCheckoutButton.tsx b/cookbook/recipes/multipass/ingredients/templates/skeleton/app/components/MultipassCheckoutButton.tsx similarity index 100% rename from examples/multipass/app/components/MultipassCheckoutButton.tsx rename to cookbook/recipes/multipass/ingredients/templates/skeleton/app/components/MultipassCheckoutButton.tsx diff --git a/examples/multipass/app/lib/multipass/multipass.ts b/cookbook/recipes/multipass/ingredients/templates/skeleton/app/lib/multipass/multipass.ts similarity index 84% rename from examples/multipass/app/lib/multipass/multipass.ts rename to cookbook/recipes/multipass/ingredients/templates/skeleton/app/lib/multipass/multipass.ts index 16cd2e91bd..66c116783f 100644 --- a/examples/multipass/app/lib/multipass/multipass.ts +++ b/cookbook/recipes/multipass/ingredients/templates/skeleton/app/lib/multipass/multipass.ts @@ -16,8 +16,7 @@ import type { export async function multipass( options: MultipassOptions, ): Promise { - // eslint-disable-next-line @typescript-eslint/naming-convention - const {redirect, return_to} = options; + const {redirect, return_to: returnTo} = options; try { // Generate multipass token POST `/account/login/multipass` @@ -26,7 +25,7 @@ export async function multipass( headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({return_to}), + body: JSON.stringify({return_to: returnTo}), }); if (!response.ok) { @@ -54,8 +53,7 @@ export async function multipass( window.location.href = data.url; return data; } catch (error) { - // @ts-expect-error - error may not have message property - // eslint-disable-next-line no-console + //@ts-expect-error error might not have message property console.log('⚠️ Bypassing multipass checkout due to', error.message); const message = error instanceof Error ? error.message : 'Unknown error'; @@ -67,8 +65,8 @@ export async function multipass( }; } - if (return_to) { - window.location.href = return_to; + if (returnTo) { + window.location.href = returnTo; } return {url: null, token: null, error: message}; diff --git a/examples/multipass/app/lib/multipass/multipassify.server.ts b/cookbook/recipes/multipass/ingredients/templates/skeleton/app/lib/multipass/multipassify.server.ts similarity index 88% rename from examples/multipass/app/lib/multipass/multipassify.server.ts rename to cookbook/recipes/multipass/ingredients/templates/skeleton/app/lib/multipass/multipassify.server.ts index 69d5695a59..402aa64334 100644 --- a/examples/multipass/app/lib/multipass/multipassify.server.ts +++ b/cookbook/recipes/multipass/ingredients/templates/skeleton/app/lib/multipass/multipassify.server.ts @@ -1,7 +1,33 @@ -import snakecaseKeys from 'snakecase-keys'; import CryptoJS from 'crypto-js'; import type {MultipassCustomer} from './types'; +// Simple snake_case converter for ESM/Worker runtime +function toSnakeCase(str: string): string { + return str + .replace(/([A-Z])/g, '_$1') + .toLowerCase() + .replace(/^_/, ''); +} + +function snakecaseKeys(obj: any): any { + if (typeof obj !== 'object' || obj === null) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(snakecaseKeys); + } + + const result: any = {}; + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + const snakeKey = toSnakeCase(key); + result[snakeKey] = snakecaseKeys(obj[key]); + } + } + return result; +} + /* Shopify multipassify implementation for node and v8/worker runtime based on crypto-js. This library is used to generate and parse multipass tokens. @@ -103,7 +129,7 @@ export class Multipassify { } // signs the encrypted customer data - private sign(encrypted: CryptoJS.lib.WordArray) { + private sign(encrypted: any) { return CryptoJS.HmacSHA256(encrypted, this.signingKey); } diff --git a/examples/multipass/app/lib/multipass/types.ts b/cookbook/recipes/multipass/ingredients/templates/skeleton/app/lib/multipass/types.ts similarity index 100% rename from examples/multipass/app/lib/multipass/types.ts rename to cookbook/recipes/multipass/ingredients/templates/skeleton/app/lib/multipass/types.ts diff --git a/examples/multipass/app/routes/account_.activate.$id.$activationToken.tsx b/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.activate.$id.$activationToken.tsx similarity index 91% rename from examples/multipass/app/routes/account_.activate.$id.$activationToken.tsx rename to cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.activate.$id.$activationToken.tsx index a53e817799..7a6c93e264 100644 --- a/examples/multipass/app/routes/account_.activate.$id.$activationToken.tsx +++ b/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.activate.$id.$activationToken.tsx @@ -1,29 +1,22 @@ -import { - Form, - useActionData, - data, - redirect, - type ActionFunctionArgs, - type LoaderFunctionArgs, - type MetaFunction, -} from 'react-router'; +import {Form, useActionData, data, redirect} from 'react-router'; +import type {Route} from './+types/account_.activate.$id.$activationToken'; type ActionResponse = { error: string | null; }; -export const meta: MetaFunction = () => { +export const meta: Route.MetaFunction = () => { return [{title: 'Activate Account'}]; }; -export async function loader({context}: LoaderFunctionArgs) { +export async function loader({context}: Route.LoaderArgs) { if (await context.session.get('customerAccessToken')) { return redirect('/account'); } return {}; } -export async function action({request, context, params}: ActionFunctionArgs) { +export async function action({request, context, params}: Route.ActionArgs) { const {session, storefront} = context; const {id, activationToken} = params; diff --git a/examples/multipass/app/routes/account_.login.multipass.tsx b/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.login.multipass.tsx similarity index 82% rename from examples/multipass/app/routes/account_.login.multipass.tsx rename to cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.login.multipass.tsx index 60a862b55a..8273daa5b5 100644 --- a/examples/multipass/app/routes/account_.login.multipass.tsx +++ b/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.login.multipass.tsx @@ -1,10 +1,5 @@ -import { - data as remixData, - redirect, - type ActionFunctionArgs, - type LoaderFunctionArgs, - type HeadersFunction, -} from 'react-router'; +import {data as remixData, redirect} from 'react-router'; +import type {Route} from './+types/account_.login.multipass'; import {Multipassify} from '~/lib/multipass/multipassify.server'; import type { CustomerInfoType, @@ -12,18 +7,19 @@ import type { NotLoggedInResponseType, } from '~/lib/multipass/types'; -export const headers: HeadersFunction = ({actionHeaders}) => actionHeaders; +export const headers: Route.HeadersFunction = ({actionHeaders}) => + actionHeaders; /* Redirect document GET requests to the login page (housekeeping) */ -export async function loader({params, context}: LoaderFunctionArgs) { +export async function loader({params, context}: Route.LoaderArgs) { const customerAccessToken = context.session.get('customerAccessToken'); if (customerAccessToken) { - return redirect(params.lang ? `${params.lang}/account` : '/account'); + return redirect('/account'); } - return redirect(params.lang ? `${params.lang}/account` : '/account/login'); + return redirect('/account/login'); } /* @@ -31,7 +27,7 @@ export async function loader({params, context}: LoaderFunctionArgs) { Handles POST requests to `/account/login/multipass` expects body: { return_to?: string, customer } */ -export async function action({request, context}: ActionFunctionArgs) { +export async function action({request, context}: Route.ActionArgs) { const {session, storefront, env} = context; const origin = request.headers.get('Origin') || ''; const isOptionsReq = request.method === 'OPTIONS'; @@ -65,7 +61,7 @@ export async function action({request, context}: ActionFunctionArgs) { if (!customerAccessToken) { return handleLoggedOutResponse({ return_to: body?.return_to ?? null, - checkoutDomain: env.SHOPIFY_CHECKOUT_DOMAIN, + checkoutDomain: env.PUBLIC_CHECKOUT_DOMAIN, }); } @@ -101,6 +97,22 @@ export async function action({request, context}: ActionFunctionArgs) { } try { + // @description Check for multipass secret before using + if (!env.PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET) { + console.error('Multipass secret not configured'); + return remixData( + { + error: + 'Multipass is not configured. Please set PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET.', + }, + { + status: 500, + }, + ); + } + + console.log('Generating multipass token for customer:', customer?.email); + // generate a multipass url and token const multipassify = new Multipassify( env.PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET, @@ -135,11 +147,19 @@ export async function action({request, context}: ActionFunctionArgs) { }, ); } catch (error) { + // eslint-disable-next-line no-console + console.log(JSON.stringify(error, null, 2)); let message = 'unknown error'; if (error instanceof Error) { message = error.message; + console.error( + 'Multipass generation error:', + error.message, + error.stack, + ); } else { message = JSON.stringify(error); + console.error('Multipass generation error (non-Error):', error); } return notLoggedInResponse({ @@ -193,13 +213,12 @@ async function handleLoggedOutResponse(options: { return_to: string | null; checkoutDomain: string | undefined; }) { - // eslint-disable-next-line @typescript-eslint/naming-convention - const {return_to, checkoutDomain} = options; + const {return_to: returnTo, checkoutDomain} = options; // Match checkout urls such as: // https://checkout.example.com/cart/c/c1-dd274dd3e6dca2f6a6ea899e8fe9b90f?key=6900d0a8b227761f88cf2e523ae2e662 - const isCheckoutReq = /[\w-]{32}\?key/g.test(return_to || ''); + const isCheckoutReq = /[\w-]{32}\?key/g.test(returnTo || ''); - if (!return_to || !isCheckoutReq) { + if (!returnTo || !isCheckoutReq) { return notLoggedInResponse({ url: null, error: 'NOT_AUTHORIZED', @@ -207,7 +226,7 @@ async function handleLoggedOutResponse(options: { } // Force logging off the user in the checkout - const encodedCheckoutUrl = encodeURIComponent(return_to); + const encodedCheckoutUrl = encodeURIComponent(returnTo); // For example, checkoutDomain `checkout.hydrogen.shop` or `shop.example.com` or `{shop}.myshopify.com`. const logOutUrl = `https://${checkoutDomain}/account/logout?return_url=${encodedCheckoutUrl}&step=contact_information`; diff --git a/examples/multipass/app/routes/account_.recover.tsx b/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.recover.tsx similarity index 92% rename from examples/multipass/app/routes/account_.recover.tsx rename to cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.recover.tsx index 0deb5b790d..642ec69a2a 100644 --- a/examples/multipass/app/routes/account_.recover.tsx +++ b/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.recover.tsx @@ -1,19 +1,12 @@ -import { - Form, - Link, - useActionData, - data, - redirect, - type LoaderFunctionArgs, - type ActionFunctionArgs, -} from 'react-router'; +import {Form, Link, useActionData, data, redirect} from 'react-router'; +import type {Route} from './+types/account_.recover'; type ActionResponse = { error?: string; resetRequested?: boolean; }; -export async function loader({context}: LoaderFunctionArgs) { +export async function loader({context}: Route.LoaderArgs) { const customerAccessToken = await context.session.get('customerAccessToken'); if (customerAccessToken) { return redirect('/account'); @@ -22,7 +15,7 @@ export async function loader({context}: LoaderFunctionArgs) { return {}; } -export async function action({request, context}: ActionFunctionArgs) { +export async function action({request, context}: Route.ActionArgs) { const {storefront} = context; const form = await request.formData(); const email = form.has('email') ? String(form.get('email')) : null; diff --git a/examples/multipass/app/routes/account_.register.tsx b/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.register.tsx similarity index 93% rename from examples/multipass/app/routes/account_.register.tsx rename to cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.register.tsx index 80c1ada26b..a05edee3cf 100644 --- a/examples/multipass/app/routes/account_.register.tsx +++ b/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.register.tsx @@ -1,13 +1,5 @@ -import { - Form, - Link, - useActionData, - data, - HeadersFunction, - redirect, - type ActionFunctionArgs, - type LoaderFunctionArgs, -} from 'react-router'; +import {Form, Link, useActionData, data, redirect} from 'react-router'; +import type {Route} from './+types/account_.register'; import type {CustomerCreateMutation} from 'storefrontapi.generated'; type ActionResponse = { @@ -17,9 +9,10 @@ type ActionResponse = { | null; }; -export const headers: HeadersFunction = ({actionHeaders}) => actionHeaders; +export const headers: Route.HeadersFunction = ({actionHeaders}) => + actionHeaders; -export async function loader({context}: LoaderFunctionArgs) { +export async function loader({context}: Route.LoaderArgs) { const customerAccessToken = await context.session.get('customerAccessToken'); if (customerAccessToken) { return redirect('/account'); @@ -28,7 +21,7 @@ export async function loader({context}: LoaderFunctionArgs) { return {}; } -export async function action({request, context}: ActionFunctionArgs) { +export async function action({request, context}: Route.ActionArgs) { if (request.method !== 'POST') { return data({error: 'Method not allowed'}, {status: 405}); } diff --git a/examples/multipass/app/routes/account_.reset.$id.$resetToken.tsx b/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.reset.$id.$resetToken.tsx similarity index 93% rename from examples/multipass/app/routes/account_.reset.$id.$resetToken.tsx rename to cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.reset.$id.$resetToken.tsx index d9fc2f85b3..96031e11a6 100644 --- a/examples/multipass/app/routes/account_.reset.$id.$resetToken.tsx +++ b/cookbook/recipes/multipass/ingredients/templates/skeleton/app/routes/account_.reset.$id.$resetToken.tsx @@ -1,21 +1,15 @@ -import { - data, - Form, - redirect, - useActionData, - type ActionFunctionArgs, - type MetaFunction, -} from 'react-router'; +import {data, Form, redirect, useActionData} from 'react-router'; +import type {Route} from './+types/account_.reset.$id.$resetToken'; type ActionResponse = { error: string | null; }; -export const meta: MetaFunction = () => { +export const meta: Route.MetaFunction = () => { return [{title: 'Reset Password'}]; }; -export async function action({request, context, params}: ActionFunctionArgs) { +export async function action({request, context, params}: Route.ActionArgs) { if (request.method !== 'POST') { return data({error: 'Method not allowed'}, {status: 405}); } diff --git a/cookbook/recipes/multipass/patches/CartSummary.tsx.eb8134.patch b/cookbook/recipes/multipass/patches/CartSummary.tsx.eb8134.patch new file mode 100644 index 0000000000..f4c00abcb8 --- /dev/null +++ b/cookbook/recipes/multipass/patches/CartSummary.tsx.eb8134.patch @@ -0,0 +1,25 @@ +index 3eae48c76..3e3f5f56e 100644 +--- a/templates/skeleton/app/components/CartSummary.tsx ++++ b/templates/skeleton/app/components/CartSummary.tsx +@@ -12,6 +12,8 @@ import {useFetcher} from 'react-router'; + import type {FetcherWithComponents} from 'react-router'; + import {CartWarnings} from '~/components/CartWarnings'; + import {CartUserErrors} from '~/components/CartUserErrors'; ++// @description Import MultipassCheckoutButton for Shopify Plus multipass checkout ++import {MultipassCheckoutButton} from '~/components/MultipassCheckoutButton'; + + type CartSummaryProps = { + cart: OptimisticCart; +@@ -58,9 +60,10 @@ function CartCheckoutActions({checkoutUrl}: {checkoutUrl?: string}) { + + return ( +
+- ++ {/* @description Use MultipassCheckoutButton for Shopify Plus stores to persist customer session */} ++ +

Continue to Checkout →

+-
++ +
+
+ ); diff --git a/cookbook/recipes/multipass/patches/README.md.47d06f.patch b/cookbook/recipes/multipass/patches/README.md.47d06f.patch new file mode 100644 index 0000000000..63e58e6d29 --- /dev/null +++ b/cookbook/recipes/multipass/patches/README.md.47d06f.patch @@ -0,0 +1,132 @@ +index c584e5370..e1c0562dd 100644 +--- a/templates/skeleton/README.md ++++ b/templates/skeleton/README.md +@@ -1,13 +1,15 @@ +-# Hydrogen template: Skeleton ++# Hydrogen template: Skeleton with Multipass + +-Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. ++Hydrogen is Shopify's stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify's full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen, enhanced with **Multipass authentication** for seamless checkout experiences. + + [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) + [Get familiar with Remix](https://remix.run/docs/en/v1) ++[Learn about Multipass](https://shopify.dev/docs/api/multipass) + + ## What's included + +-- Remix ++### Core Hydrogen Stack ++- Remix (React Router 7.8.x) + - Hydrogen + - Oxygen + - Vite +@@ -18,11 +20,18 @@ Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dov + - TypeScript and JavaScript flavors + - Minimal setup of components and routes + ++### Multipass Authentication (Shopify Plus) ++- Customer session persistence through checkout ++- Storefront API-based authentication (not Customer Account API) ++- Custom login, registration, and account management ++- Automatic fallback for non-Plus stores ++ + ## Getting started + + **Requirements:** + + - Node.js version 18.0.0 or higher ++- Shopify store (Shopify Plus for Multipass features) + + ```bash + npm create @shopify/hydrogen@latest +@@ -40,6 +49,88 @@ npm run build + npm run dev + ``` + +-## Setup for using Customer Account API (`/account` section) ++## Multipass Setup (Shopify Plus only) + +-Follow step 1 and 2 of ++### Requirements ++ ++- Multipass is available on [Shopify Plus](https://www.shopify.com/plus) plans ++- A Shopify Multipass secret token from [**Settings > Customer accounts**](https://www.shopify.com/admin/settings/customer_accounts) ++- Ensure you have `Classic customer account` options selected to use Multipass ++ ++### Configuration ++ ++1. **Set environment variables** in your `.env` file: ++ ++```env ++# Required for Multipass (Shopify Plus only) ++PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET=your_multipass_secret_here ++ ++# Already configured by Hydrogen ++SESSION_SECRET=your_session_secret ++``` ++ ++2. **Dependencies** (already included): ++- `crypto-js@^4.2.0` - JavaScript library of crypto standards ++- `snakecase-keys@^9.0.2` - Convert object keys to snake case ++- `@types/crypto-js@^4.2.1` - TypeScript types for crypto-js ++ ++### Key Multipass Files ++ ++| File | Description | ++|------|------------| ++| [`app/components/MultipassCheckoutButton.tsx`](app/components/MultipassCheckoutButton.tsx) | Checkout button that passes customer session to checkout | ++| [`app/lib/multipass/multipass.ts`](app/lib/multipass/multipass.ts) | Client-side utility for multipass URL and token handling | ++| [`app/lib/multipass/multipassify.server.ts`](app/lib/multipass/multipassify.server.ts) | Server-side multipass token generation and parsing | ++| [`app/lib/multipass/types.ts`](app/lib/multipass/types.ts) | TypeScript types for multipass | ++| [`app/routes/account_.login.multipass.tsx`](app/routes/account_.login.multipass.tsx) | API route for multipass token generation | ++ ++## Authentication System ++ ++This template uses the **Storefront API** for customer authentication instead of the Customer Account API: ++ ++### Account Routes ++- `/account/login` - Customer login ++- `/account/register` - New customer registration ++- `/account/logout` - Logout ++- `/account/recover` - Password recovery ++- `/account/reset/:id/:token` - Password reset ++- `/account/activate/:id/:token` - Account activation ++- `/account` - Account dashboard ++- `/account/profile` - Edit profile ++- `/account/addresses` - Manage addresses ++- `/account/orders` - Order history ++ ++### How Multipass Works ++ ++1. Customer logs in using email/password ++2. Session stores `customerAccessToken` ++3. When checking out, multipass generates encrypted token ++4. Customer is automatically logged in at Shopify checkout ++ ++### Fallback Behavior ++ ++For non-Plus stores or when multipass isn't configured: ++- Checkout button works normally ++- Console shows: "Bypassing multipass checkout" ++- Customers use standard Shopify checkout flow ++ ++## Troubleshooting ++ ++### Multipass Issues ++ ++**500 Error on checkout:** ++- Ensure `PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET` is set correctly ++- Verify you have a Shopify Plus plan ++- Check that Classic customer accounts are enabled ++ ++**Customer not logged in at checkout:** ++- Verify customer is logged in first at `/account/login` ++- Check that multipass token generation is working ++- Ensure multipass secret hasn't expired ++ ++## Learn More ++ ++- [Hydrogen Documentation](https://shopify.dev/custom-storefronts/hydrogen) ++- [Multipass Documentation](https://shopify.dev/docs/api/multipass) ++- [Storefront API Authentication](https://shopify.dev/docs/api/storefront/authentication) ++- [Remix Documentation](https://remix.run/docs) diff --git a/cookbook/recipes/multipass/patches/account.$.tsx.2df983.patch b/cookbook/recipes/multipass/patches/account.$.tsx.2df983.patch new file mode 100644 index 0000000000..f53eaaa99d --- /dev/null +++ b/cookbook/recipes/multipass/patches/account.$.tsx.2df983.patch @@ -0,0 +1,19 @@ +index 074def2a4..8ed5a1545 100644 +--- a/templates/skeleton/app/routes/account.$.tsx ++++ b/templates/skeleton/app/routes/account.$.tsx +@@ -1,9 +1,9 @@ + import {redirect} from 'react-router'; + import type {Route} from './+types/account.$'; + +-// fallback wild card for all unauthenticated routes in account section + export async function loader({context}: Route.LoaderArgs) { +- context.customerAccount.handleAuthStatus(); +- +- return redirect('/account'); +-} ++ if (await context.session.get('customerAccessToken')) { ++ return redirect('/account'); ++ } ++ return redirect('/account/login'); ++} +\ No newline at end of file diff --git a/cookbook/recipes/multipass/patches/account._index.tsx.bf7dac.patch b/cookbook/recipes/multipass/patches/account._index.tsx.bf7dac.patch new file mode 100644 index 0000000000..c12c93e082 --- /dev/null +++ b/cookbook/recipes/multipass/patches/account._index.tsx.bf7dac.patch @@ -0,0 +1,10 @@ +index 677a584c0..b3486c5e5 100644 +--- a/templates/skeleton/app/routes/account._index.tsx ++++ b/templates/skeleton/app/routes/account._index.tsx +@@ -2,4 +2,4 @@ import {redirect} from 'react-router'; + + export async function loader() { + return redirect('/account/orders'); +-} ++} +\ No newline at end of file diff --git a/cookbook/recipes/multipass/patches/account.addresses.tsx.34e472.patch b/cookbook/recipes/multipass/patches/account.addresses.tsx.34e472.patch new file mode 100644 index 0000000000..586dcb2d68 --- /dev/null +++ b/cookbook/recipes/multipass/patches/account.addresses.tsx.34e472.patch @@ -0,0 +1,531 @@ +index ddfa18f3f..9e104eed2 100644 +--- a/templates/skeleton/app/routes/account.addresses.tsx ++++ b/templates/skeleton/app/routes/account.addresses.tsx +@@ -1,22 +1,14 @@ +-import type {CustomerAddressInput} from '@shopify/hydrogen/customer-account-api-types'; +-import type { +- AddressFragment, +- CustomerFragment, +-} from 'customer-accountapi.generated'; ++import type {MailingAddressInput} from '@shopify/hydrogen/storefront-api-types'; ++import type {AddressFragment, CustomerFragment} from 'storefrontapi.generated'; + import { +- data, + Form, + useActionData, + useNavigation, + useOutletContext, +- type Fetcher, ++ data, ++ redirect, + } from 'react-router'; + import type {Route} from './+types/account.addresses'; +-import { +- UPDATE_ADDRESS_MUTATION, +- DELETE_ADDRESS_MUTATION, +- CREATE_ADDRESS_MUTATION, +-} from '~/graphql/customer-account/CustomerAddressMutations'; + + export type ActionResponse = { + addressId?: string | null; +@@ -32,13 +24,16 @@ export const meta: Route.MetaFunction = () => { + }; + + export async function loader({context}: Route.LoaderArgs) { +- context.customerAccount.handleAuthStatus(); +- ++ const {session} = context; ++ const customerAccessToken = await session.get('customerAccessToken'); ++ if (!customerAccessToken) { ++ return redirect('/account/login'); ++ } + return {}; + } + + export async function action({request, context}: Route.ActionArgs) { +- const {customerAccount} = context; ++ const {storefront, session} = context; + + try { + const form = await request.formData(); +@@ -50,31 +45,26 @@ export async function action({request, context}: Route.ActionArgs) { + throw new Error('You must provide an address id.'); + } + +- // this will ensure redirecting to login never happen for mutatation +- const isLoggedIn = await customerAccount.isLoggedIn(); +- if (!isLoggedIn) { +- return data( +- {error: {[addressId]: 'Unauthorized'}}, +- { +- status: 401, +- }, +- ); ++ const customerAccessToken = await session.get('customerAccessToken'); ++ if (!customerAccessToken) { ++ return data({error: {[addressId]: 'Unauthorized'}}, {status: 401}); + } ++ const {accessToken} = customerAccessToken; + + const defaultAddress = form.has('defaultAddress') + ? String(form.get('defaultAddress')) === 'on' +- : false; +- const address: CustomerAddressInput = {}; +- const keys: (keyof CustomerAddressInput)[] = [ ++ : null; ++ const address: MailingAddressInput = {}; ++ const keys: (keyof MailingAddressInput)[] = [ + 'address1', + 'address2', + 'city', + 'company', +- 'territoryCode', ++ 'country', + 'firstName', + 'lastName', +- 'phoneNumber', +- 'zoneCode', ++ 'phone', ++ 'province', + 'zip', + ]; + +@@ -89,143 +79,119 @@ export async function action({request, context}: Route.ActionArgs) { + case 'POST': { + // handle new address creation + try { +- const {data, errors} = await customerAccount.mutate( ++ const {customerAddressCreate} = await storefront.mutate( + CREATE_ADDRESS_MUTATION, + { +- variables: { +- address, +- defaultAddress, +- language: customerAccount.i18n.language, +- }, ++ variables: {customerAccessToken: accessToken, address}, + }, + ); + +- if (errors?.length) { +- throw new Error(errors[0].message); ++ if (customerAddressCreate?.customerUserErrors?.length) { ++ const error = customerAddressCreate.customerUserErrors[0]; ++ throw new Error(error.message); + } + +- if (data?.customerAddressCreate?.userErrors?.length) { +- throw new Error(data?.customerAddressCreate?.userErrors[0].message); +- } +- +- if (!data?.customerAddressCreate?.customerAddress) { +- throw new Error('Customer address create failed.'); +- } +- +- return { +- error: null, +- createdAddress: data?.customerAddressCreate?.customerAddress, +- defaultAddress, +- }; +- } catch (error: unknown) { +- if (error instanceof Error) { +- return data( +- {error: {[addressId]: error.message}}, +- { +- status: 400, +- }, ++ const createdAddress = customerAddressCreate?.customerAddress; ++ if (!createdAddress?.id) { ++ throw new Error( ++ 'Expected customer address to be created, but the id is missing', + ); + } +- return data( +- {error: {[addressId]: error}}, +- { +- status: 400, +- }, +- ); ++ ++ if (defaultAddress) { ++ const createdAddressId = decodeURIComponent(createdAddress.id); ++ const {customerDefaultAddressUpdate} = await storefront.mutate( ++ UPDATE_DEFAULT_ADDRESS_MUTATION, ++ { ++ variables: { ++ customerAccessToken: accessToken, ++ addressId: createdAddressId, ++ }, ++ }, ++ ); ++ ++ if (customerDefaultAddressUpdate?.customerUserErrors?.length) { ++ const error = customerDefaultAddressUpdate.customerUserErrors[0]; ++ throw new Error(error.message); ++ } ++ } ++ ++ return {error: null, createdAddress, defaultAddress}; ++ } catch (error: unknown) { ++ if (error instanceof Error) { ++ return data({error: {[addressId]: error.message}}, {status: 400}); ++ } ++ return data({error: {[addressId]: error}}, {status: 400}); + } + } + + case 'PUT': { + // handle address updates + try { +- const {data, errors} = await customerAccount.mutate( ++ const {customerAddressUpdate} = await storefront.mutate( + UPDATE_ADDRESS_MUTATION, + { + variables: { + address, +- addressId: decodeURIComponent(addressId), +- defaultAddress, +- language: customerAccount.i18n.language, ++ customerAccessToken: accessToken, ++ id: decodeURIComponent(addressId), + }, + }, + ); + +- if (errors?.length) { +- throw new Error(errors[0].message); ++ const updatedAddress = customerAddressUpdate?.customerAddress; ++ ++ if (customerAddressUpdate?.customerUserErrors?.length) { ++ const error = customerAddressUpdate.customerUserErrors[0]; ++ throw new Error(error.message); + } + +- if (data?.customerAddressUpdate?.userErrors?.length) { +- throw new Error(data?.customerAddressUpdate?.userErrors[0].message); +- } +- +- if (!data?.customerAddressUpdate?.customerAddress) { +- throw new Error('Customer address update failed.'); +- } +- +- return { +- error: null, +- updatedAddress: address, +- defaultAddress, +- }; +- } catch (error: unknown) { +- if (error instanceof Error) { +- return data( +- {error: {[addressId]: error.message}}, ++ if (defaultAddress) { ++ const {customerDefaultAddressUpdate} = await storefront.mutate( ++ UPDATE_DEFAULT_ADDRESS_MUTATION, + { +- status: 400, ++ variables: { ++ customerAccessToken: accessToken, ++ addressId: decodeURIComponent(addressId), ++ }, + }, + ); ++ ++ if (customerDefaultAddressUpdate?.customerUserErrors?.length) { ++ const error = customerDefaultAddressUpdate.customerUserErrors[0]; ++ throw new Error(error.message); ++ } + } +- return data( +- {error: {[addressId]: error}}, +- { +- status: 400, +- }, +- ); ++ ++ return {error: null, updatedAddress, defaultAddress}; ++ } catch (error: unknown) { ++ if (error instanceof Error) { ++ return data({error: {[addressId]: error.message}}, {status: 400}); ++ } ++ return data({error: {[addressId]: error}}, {status: 400}); + } + } + + case 'DELETE': { + // handles address deletion + try { +- const {data, errors} = await customerAccount.mutate( ++ const {customerAddressDelete} = await storefront.mutate( + DELETE_ADDRESS_MUTATION, + { +- variables: { +- addressId: decodeURIComponent(addressId), +- language: customerAccount.i18n.language, +- }, ++ variables: {customerAccessToken: accessToken, id: addressId}, + }, + ); + +- if (errors?.length) { +- throw new Error(errors[0].message); ++ if (customerAddressDelete?.customerUserErrors?.length) { ++ const error = customerAddressDelete.customerUserErrors[0]; ++ throw new Error(error.message); + } +- +- if (data?.customerAddressDelete?.userErrors?.length) { +- throw new Error(data?.customerAddressDelete?.userErrors[0].message); +- } +- +- if (!data?.customerAddressDelete?.deletedAddressId) { +- throw new Error('Customer address delete failed.'); +- } +- + return {error: null, deletedAddress: addressId}; + } catch (error: unknown) { + if (error instanceof Error) { +- return data( +- {error: {[addressId]: error.message}}, +- { +- status: 400, +- }, +- ); ++ return data({error: {[addressId]: error.message}}, {status: 400}); + } +- return data( +- {error: {[addressId]: error}}, +- { +- status: 400, +- }, +- ); ++ return data({error: {[addressId]: error}}, {status: 400}); + } + } + +@@ -291,21 +257,17 @@ function NewAddressForm() { + address2: '', + city: '', + company: '', +- territoryCode: '', ++ country: '', + firstName: '', + id: 'new', + lastName: '', +- phoneNumber: '', +- zoneCode: '', ++ phone: '', ++ province: '', + zip: '', +- } as CustomerAddressInput; ++ } as AddressFragment; + + return ( +- ++ + {({stateForMethod}) => ( +
+
+
+

+- ++ + View Order Status → + +

+@@ -195,27 +172,145 @@ export default function OrderRoute() { + + function OrderLineRow({lineItem}: {lineItem: OrderLineItemFullFragment}) { + return ( +- ++ + +
+- {lineItem?.image && ( +-
+- +-
+- )} ++ ++ {lineItem?.variant?.image && ( ++
++ ++
++ )} ++ +
+

{lineItem.title}

+- {lineItem.variantTitle} ++ {lineItem.variant!.title} +
+
+ + +- ++ + + {lineItem.quantity} + +- ++ + + + ); + } ++ ++// NOTE: https://shopify.dev/docs/api/storefront/latest/objects/Order ++const CUSTOMER_ORDER_QUERY = `#graphql ++ fragment OrderMoney 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 { ++ ...OrderMoney ++ } ++ ... on PricingPercentageValue { ++ percentage ++ } ++ } ++ } ++ fragment OrderLineProductVariant on ProductVariant { ++ id ++ image { ++ altText ++ height ++ url ++ id ++ width ++ } ++ price { ++ ...OrderMoney ++ } ++ product { ++ handle ++ } ++ sku ++ title ++ } ++ fragment OrderLineItemFull on OrderLineItem { ++ title ++ quantity ++ discountAllocations { ++ allocatedAmount { ++ ...OrderMoney ++ } ++ discountApplication { ++ ...DiscountApplication ++ } ++ } ++ originalTotalPrice { ++ ...OrderMoney ++ } ++ discountedTotalPrice { ++ ...OrderMoney ++ } ++ variant { ++ ...OrderLineProductVariant ++ } ++ } ++ fragment Order on Order { ++ id ++ name ++ orderNumber ++ statusUrl ++ processedAt ++ fulfillmentStatus ++ totalTaxV2 { ++ ...OrderMoney ++ } ++ totalPriceV2 { ++ ...OrderMoney ++ } ++ subtotalPriceV2 { ++ ...OrderMoney ++ } ++ shippingAddress { ++ ...AddressFull ++ } ++ discountApplications(first: 100) { ++ nodes { ++ ...DiscountApplication ++ } ++ } ++ lineItems(first: 100) { ++ nodes { ++ ...OrderLineItemFull ++ } ++ } ++ } ++ query Order( ++ $country: CountryCode ++ $language: LanguageCode ++ $orderId: ID! ++ ) @inContext(country: $country, language: $language) { ++ order: node(id: $orderId) { ++ ... on Order { ++ ...Order ++ } ++ } ++ } ++` as const; +\ No newline at end of file diff --git a/cookbook/recipes/multipass/patches/account.orders._index.tsx.2b0f8a.patch b/cookbook/recipes/multipass/patches/account.orders._index.tsx.2b0f8a.patch new file mode 100644 index 0000000000..785bae3060 --- /dev/null +++ b/cookbook/recipes/multipass/patches/account.orders._index.tsx.2b0f8a.patch @@ -0,0 +1,219 @@ +index 35fc7ca58..4cde38e00 100644 +--- a/templates/skeleton/app/routes/account.orders._index.tsx ++++ b/templates/skeleton/app/routes/account.orders._index.tsx +@@ -1,52 +1,60 @@ +-import { +- Link, +- useLoaderData, +-} from 'react-router'; ++import {Link, useLoaderData, data, redirect} from 'react-router'; + import type {Route} from './+types/account.orders._index'; +-import { +- Money, +- getPaginationVariables, +- flattenConnection, +-} from '@shopify/hydrogen'; +-import {CUSTOMER_ORDERS_QUERY} from '~/graphql/customer-account/CustomerOrdersQuery'; ++import {Money, Pagination, getPaginationVariables} from '@shopify/hydrogen'; + import type { + CustomerOrdersFragment, + OrderItemFragment, +-} from 'customer-accountapi.generated'; +-import {PaginatedResourceSection} from '~/components/PaginatedResourceSection'; ++} from 'storefrontapi.generated'; + + export const meta: Route.MetaFunction = () => { + return [{title: 'Orders'}]; + }; + + export async function loader({request, context}: Route.LoaderArgs) { +- const {customerAccount} = context; +- const paginationVariables = getPaginationVariables(request, { +- pageBy: 20, +- }); ++ const {session, storefront} = context; + +- const {data, errors} = await customerAccount.query( +- CUSTOMER_ORDERS_QUERY, +- { +- variables: { +- ...paginationVariables, +- language: customerAccount.i18n.language, +- }, +- }, +- ); +- +- if (errors?.length || !data?.customer) { +- throw Error('Customer orders not found'); ++ const customerAccessToken = await session.get('customerAccessToken'); ++ if (!customerAccessToken?.accessToken) { ++ return redirect('/account/login'); + } + +- return {customer: data.customer}; ++ try { ++ const paginationVariables = getPaginationVariables(request, { ++ pageBy: 20, ++ }); ++ ++ const {customer} = await storefront.query(CUSTOMER_ORDERS_QUERY, { ++ variables: { ++ customerAccessToken: customerAccessToken.accessToken, ++ country: storefront.i18n.country, ++ language: storefront.i18n.language, ++ ...paginationVariables, ++ }, ++ cache: storefront.CacheNone(), ++ }); ++ ++ if (!customer) { ++ throw new Error('Customer not found'); ++ } ++ ++ return {customer}; ++ } catch (error: unknown) { ++ if (error instanceof Error) { ++ return data({error: error.message}, {status: 400}); ++ } ++ return data({error}, {status: 400}); ++ } + } + + export default function Orders() { + const {customer} = useLoaderData<{customer: CustomerOrdersFragment}>(); +- const {orders} = customer; ++ const {orders, numberOfOrders} = customer; + return ( +
++

++ Orders ({numberOfOrders}) ++

++
+ {orders.nodes.length ? : } +
+ ); +@@ -56,9 +64,23 @@ function OrdersTable({orders}: Pick) { + return ( +
+ {orders?.nodes.length ? ( +- +- {({node: order}) => } +- ++ ++ {({nodes, isLoading, PreviousLink, NextLink}) => { ++ return ( ++ <> ++ ++ {isLoading ? 'Loading...' : ↑ Load previous} ++ ++ {nodes.map((order) => { ++ return ; ++ })} ++ ++ {isLoading ? 'Loading...' : Load more ↓} ++ ++ ++ ); ++ }} ++ + ) : ( + + )} +@@ -79,20 +101,91 @@ function EmptyOrders() { + } + + function OrderItem({order}: {order: OrderItemFragment}) { +- const fulfillmentStatus = flattenConnection(order.fulfillments)[0]?.status; + return ( + <> +
+- +- #{order.number} ++ ++ #{order.orderNumber} + +

{new Date(order.processedAt).toDateString()}

+

{order.financialStatus}

+- {fulfillmentStatus &&

{fulfillmentStatus}

} +- ++

{order.fulfillmentStatus}

++ + View Order → +
+
+ + ); + } ++ ++const ORDER_ITEM_FRAGMENT = `#graphql ++ fragment OrderItem on Order { ++ currentTotalPrice { ++ amount ++ currencyCode ++ } ++ financialStatus ++ fulfillmentStatus ++ id ++ lineItems(first: 10) { ++ nodes { ++ title ++ variant { ++ image { ++ url ++ altText ++ height ++ width ++ } ++ } ++ } ++ } ++ orderNumber ++ customerUrl ++ statusUrl ++ processedAt ++ } ++` as const; ++ ++export const CUSTOMER_FRAGMENT = `#graphql ++ fragment CustomerOrders on Customer { ++ numberOfOrders ++ orders( ++ sortKey: PROCESSED_AT, ++ reverse: true, ++ first: $first, ++ last: $last, ++ before: $startCursor, ++ after: $endCursor ++ ) { ++ nodes { ++ ...OrderItem ++ } ++ pageInfo { ++ hasPreviousPage ++ hasNextPage ++ endCursor ++ startCursor ++ } ++ } ++ } ++ ${ORDER_ITEM_FRAGMENT} ++` as const; ++ ++// NOTE: https://shopify.dev/docs/api/storefront/latest/queries/customer ++const CUSTOMER_ORDERS_QUERY = `#graphql ++ ${CUSTOMER_FRAGMENT} ++ query CustomerOrders( ++ $country: CountryCode ++ $customerAccessToken: String! ++ $endCursor: String ++ $first: Int ++ $language: LanguageCode ++ $last: Int ++ $startCursor: String ++ ) @inContext(country: $country, language: $language) { ++ customer(customerAccessToken: $customerAccessToken) { ++ ...CustomerOrders ++ } ++ } ++` as const; +\ No newline at end of file diff --git a/cookbook/recipes/multipass/patches/account.tsx.ca55f3.patch b/cookbook/recipes/multipass/patches/account.tsx.ca55f3.patch new file mode 100644 index 0000000000..953923ceff --- /dev/null +++ b/cookbook/recipes/multipass/patches/account.tsx.ca55f3.patch @@ -0,0 +1,193 @@ +index 46272bbd3..64494bda5 100644 +--- a/templates/skeleton/app/routes/account.tsx ++++ b/templates/skeleton/app/routes/account.tsx +@@ -1,44 +1,104 @@ + import { +- data as remixData, ++ data, + Form, + NavLink, + Outlet, ++ redirect, + useLoaderData, + } from 'react-router'; + import type {Route} from './+types/account'; +-import {CUSTOMER_DETAILS_QUERY} from '~/graphql/customer-account/CustomerDetailsQuery'; ++import type {CustomerFragment} from 'storefrontapi.generated'; + + export function shouldRevalidate() { + return true; + } + +-export async function loader({context}: Route.LoaderArgs) { +- const {customerAccount} = context; +- const {data, errors} = await customerAccount.query( +- CUSTOMER_DETAILS_QUERY, +- { +- variables: { +- language: customerAccount.i18n.language, +- }, +- }, +- ); ++export const headers: Route.HeadersFunction = ({loaderHeaders}) => loaderHeaders; + +- if (errors?.length || !data?.customer) { +- throw new Error('Customer not found'); ++export async function loader({request, context}: Route.LoaderArgs) { ++ const {session, storefront} = context; ++ const {pathname} = new URL(request.url); ++ const customerAccessToken = await session.get('customerAccessToken'); ++ const isLoggedIn = !!customerAccessToken?.accessToken; ++ const isAccountHome = pathname === '/account' || pathname === '/account/'; ++ const isPrivateRoute = ++ /^\/account\/(orders|orders\/.*|profile|addresses|addresses\/.*)$/.test( ++ pathname, ++ ); ++ ++ if (!isLoggedIn) { ++ if (isPrivateRoute || isAccountHome) { ++ session.unset('customerAccessToken'); ++ return redirect('/account/login'); ++ } else { ++ // public subroute such as /account/login... ++ return { ++ isLoggedIn: false, ++ isAccountHome, ++ isPrivateRoute, ++ customer: null, ++ }; ++ } ++ } else { ++ // loggedIn, default redirect to the orders page ++ if (isAccountHome) { ++ return redirect('/account/orders'); ++ } + } + +- return remixData( +- {customer: data.customer}, +- { +- headers: { +- 'Cache-Control': 'no-cache, no-store, must-revalidate', ++ try { ++ const {customer} = await storefront.query(CUSTOMER_QUERY, { ++ variables: { ++ customerAccessToken: customerAccessToken.accessToken, ++ country: storefront.i18n.country, ++ language: storefront.i18n.language, + }, +- }, ++ cache: storefront.CacheNone(), ++ }); ++ ++ if (!customer) { ++ throw new Error('Customer not found'); ++ } ++ ++ return data( ++ {isLoggedIn, isPrivateRoute, isAccountHome, customer}, ++ { ++ headers: { ++ 'Cache-Control': 'no-cache, no-store, must-revalidate', ++ }, ++ }, ++ ); ++ } catch (error) { ++ console.error('There was a problem loading account', error); ++ session.unset('customerAccessToken'); ++ return redirect('/account/login'); ++ } ++} ++ ++export default function Account() { ++ const {customer, isPrivateRoute, isAccountHome} = ++ useLoaderData(); ++ ++ if (!isPrivateRoute && !isAccountHome) { ++ return ; ++ } ++ ++ return ( ++ ++
++
++ ++
+ ); + } + +-export default function AccountLayout() { +- const {customer} = useLoaderData(); ++function AccountLayout({ ++ customer, ++ children, ++}: { ++ customer: CustomerFragment; ++ children: React.ReactNode; ++}) { + + const heading = customer + ? customer.firstName +@@ -51,9 +111,7 @@ export default function AccountLayout() { +

{heading}

+
+ +-
+-
+- ++ {children} +
+ ); + } +@@ -98,3 +156,50 @@ function Logout() { + + ); + } ++ ++export const CUSTOMER_FRAGMENT = `#graphql ++ fragment Customer on Customer { ++ acceptsMarketing ++ addresses(first: 6) { ++ nodes { ++ ...Address ++ } ++ } ++ defaultAddress { ++ ...Address ++ } ++ email ++ firstName ++ lastName ++ numberOfOrders ++ phone ++ } ++ fragment Address on MailingAddress { ++ id ++ formatted ++ firstName ++ lastName ++ company ++ address1 ++ address2 ++ country ++ province ++ city ++ zip ++ phone ++ } ++` as const; ++ ++// NOTE: https://shopify.dev/docs/api/storefront/latest/queries/customer ++const CUSTOMER_QUERY = `#graphql ++ query Customer( ++ $customerAccessToken: String! ++ $country: CountryCode ++ $language: LanguageCode ++ ) @inContext(country: $country, language: $language) { ++ customer(customerAccessToken: $customerAccessToken) { ++ ...Customer ++ } ++ } ++ ${CUSTOMER_FRAGMENT} ++` as const; +\ No newline at end of file diff --git a/cookbook/recipes/multipass/patches/account_.login.tsx.1d534b.patch b/cookbook/recipes/multipass/patches/account_.login.tsx.1d534b.patch new file mode 100644 index 0000000000..e2a1bd3dcd --- /dev/null +++ b/cookbook/recipes/multipass/patches/account_.login.tsx.1d534b.patch @@ -0,0 +1,142 @@ +index 825648a14..eae3d7f39 100644 +--- a/templates/skeleton/app/routes/account_.login.tsx ++++ b/templates/skeleton/app/routes/account_.login.tsx +@@ -1,7 +1,133 @@ ++import {Form, Link, useActionData, data, redirect} from 'react-router'; + import type {Route} from './+types/account_.login'; + +-export async function loader({request, context}: Route.LoaderArgs) { +- return context.customerAccount.login({ +- countryCode: context.storefront.i18n.country, +- }); ++type ActionResponse = { ++ error: string | null; ++}; ++ ++export const meta: Route.MetaFunction = () => { ++ return [{title: 'Login'}]; ++}; ++ ++export async function loader({context}: Route.LoaderArgs) { ++ if (await context.session.get('customerAccessToken')) { ++ return redirect('/account'); ++ } ++ return {}; + } ++ ++export async function action({request, context}: Route.ActionArgs) { ++ const {session, storefront} = context; ++ ++ if (request.method !== 'POST') { ++ return data({error: 'Method not allowed'}, {status: 405}); ++ } ++ ++ try { ++ const form = await request.formData(); ++ const email = String(form.has('email') ? form.get('email') : ''); ++ const password = String(form.has('password') ? form.get('password') : ''); ++ const validInputs = Boolean(email && password); ++ ++ if (!validInputs) { ++ throw new Error('Please provide both an email and a password.'); ++ } ++ ++ const {customerAccessTokenCreate} = await storefront.mutate( ++ LOGIN_MUTATION, ++ { ++ variables: { ++ input: {email, password}, ++ }, ++ }, ++ ); ++ ++ if (!customerAccessTokenCreate?.customerAccessToken?.accessToken) { ++ throw new Error(customerAccessTokenCreate?.customerUserErrors[0].message); ++ } ++ ++ const {customerAccessToken} = customerAccessTokenCreate; ++ session.set('customerAccessToken', customerAccessToken); ++ ++ return redirect('/account'); ++ } catch (error: unknown) { ++ if (error instanceof Error) { ++ return data({error: error.message}, {status: 400}); ++ } ++ return data({error}, {status: 400}); ++ } ++} ++ ++export default function Login() { ++ const data = useActionData(); ++ const error = data?.error || null; ++ ++ return ( ++
++

Sign in.

++
++
++ ++ ++ ++ ++
++ {error ? ( ++

++ ++ {error} ++ ++

++ ) : ( ++
++ )} ++ ++
++
++
++

++ Forgot password → ++

++

++ Register → ++

++
++
++ ); ++} ++ ++// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customeraccesstokencreate ++const LOGIN_MUTATION = `#graphql ++ mutation login($input: CustomerAccessTokenCreateInput!) { ++ customerAccessTokenCreate(input: $input) { ++ customerUserErrors { ++ code ++ field ++ message ++ } ++ customerAccessToken { ++ accessToken ++ expiresAt ++ } ++ } ++ } ++` as const; +\ No newline at end of file diff --git a/cookbook/recipes/multipass/patches/account_.logout.tsx.d6592d.patch b/cookbook/recipes/multipass/patches/account_.logout.tsx.d6592d.patch new file mode 100644 index 0000000000..831643354f --- /dev/null +++ b/cookbook/recipes/multipass/patches/account_.logout.tsx.d6592d.patch @@ -0,0 +1,35 @@ +index 5e67cc857..d88e717d5 100644 +--- a/templates/skeleton/app/routes/account_.logout.tsx ++++ b/templates/skeleton/app/routes/account_.logout.tsx +@@ -1,11 +1,25 @@ +-import {redirect} from 'react-router'; ++import {data, redirect} from 'react-router'; + import type {Route} from './+types/account_.logout'; + +-// if we don't implement this, /account/logout will get caught by account.$.tsx to do login ++export const meta: Route.MetaFunction = () => { ++ return [{title: 'Logout'}]; ++}; ++ + export async function loader() { ++ return redirect('/account/login'); ++} ++ ++export async function action({request, context}: Route.ActionArgs) { ++ const {session} = context; ++ session.unset('customerAccessToken'); ++ ++ if (request.method !== 'POST') { ++ return data({error: 'Method not allowed'}, {status: 405}); ++ } ++ + return redirect('/'); + } + +-export async function action({context}: Route.ActionArgs) { +- return context.customerAccount.logout(); +-} ++export default function Logout() { ++ return null; ++} +\ No newline at end of file diff --git a/cookbook/recipes/multipass/patches/cart.tsx.362410.patch b/cookbook/recipes/multipass/patches/cart.tsx.362410.patch new file mode 100644 index 0000000000..94d7dd0bfc --- /dev/null +++ b/cookbook/recipes/multipass/patches/cart.tsx.362410.patch @@ -0,0 +1,28 @@ +index a9b5b4dd3..ce1bb4f16 100644 +--- a/templates/skeleton/app/routes/cart.tsx ++++ b/templates/skeleton/app/routes/cart.tsx +@@ -15,9 +15,13 @@ export const meta: Route.MetaFunction = () => { + export const headers: HeadersFunction = ({actionHeaders}) => actionHeaders; + + export async function action({request, context}: Route.ActionArgs) { +- const {cart} = context; ++ // @description Get session for multipass customer token persistence ++ const {session, cart} = context; + +- const formData = await request.formData(); ++ const [formData, customerAccessToken] = await Promise.all([ ++ request.formData(), ++ session.get('customerAccessToken'), ++ ]); + + const {action, inputs} = CartForm.getFormInput(formData); + +@@ -69,6 +73,8 @@ export async function action({request, context}: Route.ActionArgs) { + case CartForm.ACTIONS.BuyerIdentityUpdate: { + result = await cart.updateBuyerIdentity({ + ...inputs.buyerIdentity, ++ // @description Add customer access token for multipass checkout ++ customerAccessToken: customerAccessToken?.accessToken, + }); + break; + } diff --git a/cookbook/recipes/multipass/patches/env.d.ts.899ef3.patch b/cookbook/recipes/multipass/patches/env.d.ts.899ef3.patch new file mode 100644 index 0000000000..d465577396 --- /dev/null +++ b/cookbook/recipes/multipass/patches/env.d.ts.899ef3.patch @@ -0,0 +1,13 @@ +index 4b5e361ec..7dcc69f2e 100644 +--- a/templates/skeleton/env.d.ts ++++ b/templates/skeleton/env.d.ts +@@ -5,3 +5,9 @@ + + // Enhance TypeScript's built-in typings. + import '@total-typescript/ts-reset'; ++ ++declare global { ++ interface Env { ++ PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET?: string; ++ } ++} diff --git a/cookbook/recipes/multipass/patches/package.json.cfee98.patch b/cookbook/recipes/multipass/patches/package.json.cfee98.patch new file mode 100644 index 0000000000..d4f3f7f654 --- /dev/null +++ b/cookbook/recipes/multipass/patches/package.json.cfee98.patch @@ -0,0 +1,19 @@ +index 0ee1599a1..bd532b059 100644 +--- a/templates/skeleton/package.json ++++ b/templates/skeleton/package.json +@@ -15,6 +15,7 @@ + "prettier": "@shopify/prettier-config", + "dependencies": { + "@shopify/hydrogen": "2025.5.0", ++ "crypto-js": "^4.2.0", + "graphql": "^16.10.0", + "graphql-tag": "^2.12.6", + "isbot": "^5.1.22", +@@ -36,6 +37,7 @@ + "@shopify/oxygen-workers-types": "^4.1.6", + "@shopify/prettier-config": "^1.1.2", + "@total-typescript/ts-reset": "^0.6.1", ++ "@types/crypto-js": "^4.2.2", + "@types/eslint": "^9.6.1", + "@types/react": "^18.2.22", + "@types/react-dom": "^18.2.7", diff --git a/cookbook/recipes/multipass/patches/root.tsx.0ad938.patch b/cookbook/recipes/multipass/patches/root.tsx.0ad938.patch new file mode 100644 index 0000000000..e72f9995ce --- /dev/null +++ b/cookbook/recipes/multipass/patches/root.tsx.0ad938.patch @@ -0,0 +1,61 @@ +index 375fc7b04..0989489b8 100644 +--- a/templates/skeleton/app/root.tsx ++++ b/templates/skeleton/app/root.tsx +@@ -1,4 +1,15 @@ +-import {Analytics, getShopAnalytics, useNonce} from '@shopify/hydrogen'; ++import { ++ Analytics, ++ getShopAnalytics, ++ useNonce, ++ type HydrogenSession, ++} from '@shopify/hydrogen'; ++ ++// @description Define CustomerAccessToken type for multipass ++type CustomerAccessToken = { ++ accessToken: string; ++ expiresAt: string; ++}; + import { + Outlet, + useRouteError, +@@ -110,7 +121,14 @@ async function loadCriticalData({context}: Route.LoaderArgs) { + // Add other queries here, so that they are loaded in parallel + ]); + +- return {header}; ++ // @description Validate customer authentication for multipass ++ const customerAccessToken = await context.session.get('customerAccessToken'); ++ const isLoggedIn = await validateCustomerAccessToken( ++ context.session, ++ customerAccessToken, ++ ); ++ ++ return {header, isLoggedIn: Promise.resolve(isLoggedIn)}; + } + + /** +@@ -202,3 +220,24 @@ export function ErrorBoundary() { + + ); + } ++ ++// @description Validate customer access token for multipass authentication ++export async function validateCustomerAccessToken( ++ session: HydrogenSession, ++ customerAccessToken?: CustomerAccessToken, ++) { ++ if (!customerAccessToken?.accessToken || !customerAccessToken?.expiresAt) { ++ return false; ++ } ++ ++ const expiresAt = new Date(customerAccessToken.expiresAt).getTime(); ++ const dateNow = Date.now(); ++ const customerAccessTokenExpired = expiresAt < dateNow; ++ ++ if (customerAccessTokenExpired) { ++ session.unset('customerAccessToken'); ++ return false; ++ } ++ ++ return true; ++} diff --git a/cookbook/recipes/multipass/patches/vite.config.ts.f0a2c4.patch b/cookbook/recipes/multipass/patches/vite.config.ts.f0a2c4.patch new file mode 100644 index 0000000000..7b3c1b12e3 --- /dev/null +++ b/cookbook/recipes/multipass/patches/vite.config.ts.f0a2c4.patch @@ -0,0 +1,12 @@ +index a12ad9e2e..34af7ea61 100644 +--- a/templates/skeleton/vite.config.ts ++++ b/templates/skeleton/vite.config.ts +@@ -26,7 +26,7 @@ export default defineConfig({ + * Include 'example-dep' in the array below. + * @see https://vitejs.dev/config/dep-optimization-options + */ +- include: ['set-cookie-parser', 'cookie', 'react-router'], ++ include: ['set-cookie-parser', 'cookie', 'react-router', 'crypto-js'], + }, + }, + }); diff --git a/cookbook/recipes/multipass/recipe.yaml b/cookbook/recipes/multipass/recipe.yaml new file mode 100644 index 0000000000..47dc890a5d --- /dev/null +++ b/cookbook/recipes/multipass/recipe.yaml @@ -0,0 +1,244 @@ +# yaml-language-server: $schema=../../recipe.schema.json + +gid: d230565c-84f4-4555-b7b8-8c574d54df80 +title: Multipass Authentication with Storefront API +summary: Enable Shopify Plus Multipass authentication using Storefront API for + seamless customer login and checkout +description: | + This recipe implements Shopify Plus Multipass authentication using the Storefront API instead of the Customer Account API. + It provides session-based authentication with customer access tokens, enabling customers to maintain their logged-in + state across the storefront and checkout process. This is particularly useful for Shopify Plus stores that need to + integrate with external authentication systems or maintain customer sessions across different platforms. + + Key features: + - Converts all customer account routes from Customer Account API to Storefront API + - Implements session-based authentication with customer access tokens + - Adds Multipass checkout button for seamless checkout experience + - Provides token validation and automatic token refresh + - Includes complete authentication flow (login, logout, register, recover, reset) +notes: + - This recipe requires Shopify Plus as Multipass is a Plus-only feature + - The recipe replaces the snakecase-keys npm package with a custom + ESM-compatible implementation to work in Worker environments + - All customer authentication is handled through Storefront API mutations + instead of Customer Account API + - Session tokens are validated on each request and automatically cleared if + expired +requirements: | + - Shopify Plus subscription for Multipass functionality + - PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET environment variable must be set + - React Router 7.8.x or higher +ingredients: + - path: templates/skeleton/app/components/MultipassCheckoutButton.tsx + description: null + - path: templates/skeleton/app/lib/multipass/multipass.ts + description: null + - path: templates/skeleton/app/lib/multipass/multipassify.server.ts + description: null + - path: templates/skeleton/app/lib/multipass/types.ts + description: null + - path: templates/skeleton/app/routes/account_.activate.$id.$activationToken.tsx + description: null + - path: templates/skeleton/app/routes/account_.login.multipass.tsx + description: null + - path: templates/skeleton/app/routes/account_.recover.tsx + description: null + - path: templates/skeleton/app/routes/account_.register.tsx + description: null + - path: templates/skeleton/app/routes/account_.reset.$id.$resetToken.tsx + description: null +deletedFiles: [] +steps: + - type: PATCH + step: "1" + name: README.md + description: null + diffs: + - file: README.md + patchFile: README.md.47d06f.patch + - type: NEW_FILE + step: "1" + name: app/components/MultipassCheckoutButton.tsx + description: null + ingredients: + - path: templates/skeleton/app/components/MultipassCheckoutButton.tsx + - type: PATCH + step: "2" + name: app/components/CartSummary.tsx + description: null + diffs: + - file: app/components/CartSummary.tsx + patchFile: CartSummary.tsx.eb8134.patch + - type: NEW_FILE + step: "2" + name: app/lib/multipass/multipass.ts + description: null + ingredients: + - path: templates/skeleton/app/lib/multipass/multipass.ts + - type: PATCH + step: "3" + name: app/root.tsx + description: null + diffs: + - file: app/root.tsx + patchFile: root.tsx.0ad938.patch + - type: NEW_FILE + step: "3" + name: app/lib/multipass/multipassify.server.ts + description: null + ingredients: + - path: templates/skeleton/app/lib/multipass/multipassify.server.ts + - type: PATCH + step: "4" + name: app/routes/account.$.tsx + description: null + diffs: + - file: app/routes/account.$.tsx + patchFile: account.$.tsx.2df983.patch + - type: NEW_FILE + step: "4" + name: app/lib/multipass/types.ts + description: null + ingredients: + - path: templates/skeleton/app/lib/multipass/types.ts + - type: PATCH + step: "5" + name: app/routes/account._index.tsx + description: null + diffs: + - file: app/routes/account._index.tsx + patchFile: account._index.tsx.bf7dac.patch + - type: NEW_FILE + step: "5" + name: app/routes/account_.activate.$id.$activationToken.tsx + description: null + ingredients: + - path: templates/skeleton/app/routes/account_.activate.$id.$activationToken.tsx + - type: PATCH + step: "6" + name: app/routes/account.addresses.tsx + description: null + diffs: + - file: app/routes/account.addresses.tsx + patchFile: account.addresses.tsx.34e472.patch + - type: NEW_FILE + step: "6" + name: app/routes/account_.login.multipass.tsx + description: null + ingredients: + - path: templates/skeleton/app/routes/account_.login.multipass.tsx + - type: PATCH + step: "7" + name: app/routes/account.orders.$id.tsx + description: null + diffs: + - file: app/routes/account.orders.$id.tsx + patchFile: account.orders.$id.tsx.fa0356.patch + - type: NEW_FILE + step: "7" + name: app/routes/account_.recover.tsx + description: null + ingredients: + - path: templates/skeleton/app/routes/account_.recover.tsx + - type: PATCH + step: "8" + name: app/routes/account.orders._index.tsx + description: null + diffs: + - file: app/routes/account.orders._index.tsx + patchFile: account.orders._index.tsx.2b0f8a.patch + - type: NEW_FILE + step: "8" + name: app/routes/account_.register.tsx + description: null + ingredients: + - path: templates/skeleton/app/routes/account_.register.tsx + - type: NEW_FILE + step: "9" + name: app/routes/account_.reset.$id.$resetToken.tsx + description: null + ingredients: + - path: templates/skeleton/app/routes/account_.reset.$id.$resetToken.tsx + - type: PATCH + step: "10" + name: app/routes/account.tsx + description: null + diffs: + - file: app/routes/account.tsx + patchFile: account.tsx.ca55f3.patch + - type: PATCH + step: "12" + name: app/routes/account_.login.tsx + description: null + diffs: + - file: app/routes/account_.login.tsx + patchFile: account_.login.tsx.1d534b.patch + - type: PATCH + step: "13" + name: app/routes/account_.logout.tsx + description: null + diffs: + - file: app/routes/account_.logout.tsx + patchFile: account_.logout.tsx.d6592d.patch + - type: PATCH + step: "13" + name: env.d.ts + description: null + diffs: + - file: env.d.ts + patchFile: env.d.ts.899ef3.patch + - type: PATCH + step: "14" + name: app/routes/cart.tsx + description: null + diffs: + - file: app/routes/cart.tsx + patchFile: cart.tsx.362410.patch + - type: PATCH + step: "15" + name: package.json + description: null + diffs: + - file: package.json + patchFile: package.json.cfee98.patch + - type: PATCH + step: "16" + name: vite.config.ts + description: null + diffs: + - file: vite.config.ts + patchFile: vite.config.ts.f0a2c4.patch +nextSteps: | + - Set up your PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET in your environment variables + - Test the login flow by visiting /account/login + - Verify the Multipass checkout button appears in the cart + - Configure external authentication systems to generate Multipass tokens + - Customize the authentication forms and error messages as needed +llms: + userQueries: + - How do I set up Multipass authentication in my Hydrogen store? + - How can I use Storefront API for customer authentication instead of + Customer Account API? + - How do I implement session-based authentication in Hydrogen? + - How can I maintain customer login state across checkout? + - How do I integrate external authentication with Shopify Plus? + troubleshooting: + - issue: "ReferenceError: require is not defined (snakecase-keys error)" + solution: The recipe includes a custom ESM-compatible snake_case implementation. + Ensure you're using the updated multipassify.server.ts file that doesn't + import snakecase-keys + - issue: PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET is undefined + solution: Add the Multipass secret to your environment variables. You can find + this in your Shopify Plus admin under Settings > Checkout > Multipass + - issue: "TypeScript error: Property 'PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET' does + not exist on type 'Env'" + solution: The recipe adds this type definition to env.d.ts. Run 'npm run + typecheck' after applying all patches + - issue: Customer login redirects to Customer Account API login page + solution: Ensure all account routes have been properly converted to use + Storefront API. Check that account_.login.tsx uses the form-based login, + not customerAccount.login() + - issue: Multipass checkout button not appearing + solution: Verify that CartSummary.tsx imports and uses MultipassCheckoutButton + component, and that the cart.tsx route has been patched +commit: 6681f92e84d42b5a6aca153fb49e31dcd8af84f6 diff --git a/examples/multipass/README.md b/examples/multipass/README.md deleted file mode 100644 index a07d39088d..0000000000 --- a/examples/multipass/README.md +++ /dev/null @@ -1,88 +0,0 @@ -# Hydrogen example: Multipass - -This folder contains an example implementation of [Multipass](https://shopify.dev/docs/api/multipass) for Hydrogen. It shows how to persist -the user session from a Hydrogen storefront through to checkout. - -## Requirements - -- Multipass is available on [Shopify Plus](https://www.shopify.com/plus) plans. -- A Shopify Multipass secret token. Go to [**Settings > Customer accounts**](https://www.shopify.com/admin/settings/customer_accounts) to create one. Ensure you have the `Classic customer account` options selected to use Multipass - -## Install - -Setup a new project with this example: - -```bash -npm create @shopify/hydrogen@latest -- --template multipass -``` - -## Key files - -This folder contains the minimal set of files needed to showcase the implementation. -Files that aren’t included by default with Hydrogen and that you’ll need to -create are labeled with 🆕. - -| File | Description | -| --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -| 🆕 [`app/components/MultipassCheckoutButton.tsx`](app/components/MultipassCheckoutButton.tsx) | Checkout button component that passes the customer session to checkout. | -| 🆕 [`app/lib/multipass/multipass.ts`](app/lib/multipass/multipass.ts) | Utility function that handles getting a multipass URL and token. | -| 🆕 [`app/lib/multipass/multipassify.server.ts`](app/lib/multipass/multipassify.server.ts) | Utility that handles creating and parse multipass tokens. | -| 🆕 [`app/lib/multipass/types.ts`](app/lib/multipass/types.ts) | Types for multipass utilities. | -| 🆕 [`app/routes/account_.login.multipass.tsx`](app/routes/account_.login.multipass.tsx) | API route that returns generated multipass tokens. | -| [`app/components/Cart.tsx`](app/components/Cart.tsx) | Hydrogen cart component, which gets updated to add the `` component. | - -## Dependencies - -| Module | Description | -| ----------------------------------------------------------------------- | --------------------------------------- | -| 🆕 [`snakecase-keys`](https://www.npmjs.com/package/snakecase-keys) | Convert an object's keys to snake case | -| 🆕 [`crypto-js`](https://www.npmjs.com/package/crypto-js) | JavaScript library of crypto standards. | -| 🆕 [`@types/crypto-js`](https://www.npmjs.com/package/@types/crypto-js) | crypto-js TypeScript types | - -## Instructions - -### 1. Install required dependencies - -```bash -# JavaScript -npm i @snakecase-keys crypto-js - -# TypeScript -npm i @snakecase-keys crypto-js -npm i --save-dev @types/crypto-js -``` - -### 2. Copy over the new files - -- In your Hydrogen app, create the new files from the file list above, copying in the code as you go. -- If you already have a `.env` file, copy over these key-value pairs: - - `PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET` - - `SHOPIFY_CHECKOUT_DOMAIN` - -### 3. Edit the Cart component file - -Import `MultipassCheckoutButton` and update the `CartCheckoutActions()` function. Wrap the standard ` - - - ); -} - -// ... -``` - -[View the complete component file](app/components/Cart.tsx) to see these updates in context. diff --git a/examples/multipass/app/components/Cart.tsx b/examples/multipass/app/components/Cart.tsx deleted file mode 100644 index 9ad589b37e..0000000000 --- a/examples/multipass/app/components/Cart.tsx +++ /dev/null @@ -1,371 +0,0 @@ -import { - CartForm, - Image, - Money, - OptimisticCartLine, - useOptimisticCart, - type OptimisticCart, -} from '@shopify/hydrogen'; -import type {CartLineUpdateInput} from '@shopify/hydrogen/storefront-api-types'; -import {Link} from 'react-router'; -import type {CartApiQueryFragment} from 'storefrontapi.generated'; -import {useVariantUrl} from '~/lib/variants'; -/***********************************************/ -/********** EXAMPLE UPDATE STARTS ************/ -import {MultipassCheckoutButton} from './MultipassCheckoutButton'; -/********** EXAMPLE UPDATE END ************/ -/***********************************************/ - -type CartLine = OptimisticCartLine; - -type CartMainProps = { - cart: CartApiQueryFragment | null; - layout: 'page' | 'aside'; -}; - -export function CartMain({layout, cart: originalCart}: CartMainProps) { - const cart = useOptimisticCart(originalCart); - - const linesCount = Boolean(cart?.lines?.nodes?.length || 0); - const withDiscount = - cart && - Boolean(cart?.discountCodes?.filter((code) => code.applicable)?.length); - const className = `cart-main ${withDiscount ? 'with-discount' : ''}`; - - return ( -
-
- ); -} - -function CartDetails({ - layout, - cart, -}: { - cart: OptimisticCart; - layout: 'page' | 'aside'; -}) { - const cartHasItems = cart?.totalQuantity && cart?.totalQuantity > 0; - - return ( -
- - {cartHasItems && ( - - - - - )} -
- ); -} - -function CartLines({ - lines, - layout, -}: { - layout: CartMainProps['layout']; - lines: CartLine[]; -}) { - if (!lines) return null; - - return ( -
-
    - {lines.map((line) => ( - - ))} -
-
- ); -} - -function CartLineItem({ - layout, - line, -}: { - layout: CartMainProps['layout']; - line: CartLine; -}) { - const {id, merchandise} = line; - const {product, title, image, selectedOptions} = merchandise; - const lineItemUrl = useVariantUrl(product.handle, selectedOptions); - - return ( -
  • - {image && ( - {title} - )} - -
    - { - if (layout === 'aside') { - // close the drawer - window.location.href = lineItemUrl; - } - }} - > -

    - {product.title} -

    - - -
      - {selectedOptions.map((option) => ( -
    • - - {option.name}: {option.value} - -
    • - ))} -
    - -
    -
  • - ); -} - -function CartCheckoutActions({checkoutUrl}: {checkoutUrl?: string}) { - if (!checkoutUrl) return null; - - /***********************************************/ - /********** EXAMPLE UPDATE STARTS ************/ - return ( - -

    Continue to Checkout →

    -
    - ); - /********** EXAMPLE UPDATE END ************/ - /***********************************************/ -} - -export function CartSummary({ - cost, - layout, - children = null, -}: { - children?: React.ReactNode; - cost?: OptimisticCart['cost']; - layout: CartMainProps['layout']; -}) { - const className = - layout === 'page' ? 'cart-summary-page' : 'cart-summary-aside'; - - return ( -
    -

    Totals

    -
    -
    Subtotal
    -
    - {cost?.subtotalAmount?.amount ? ( - - ) : ( - '-' - )} -
    -
    - {children} -
    - ); -} - -function CartLineRemoveButton({ - lineIds, - disabled, -}: { - lineIds: string[]; - disabled: boolean; -}) { - return ( - - - - ); -} - -function CartLineQuantity({line}: {line: CartLine}) { - if (!line || typeof line?.quantity === 'undefined') return null; - const {id: lineId, quantity, isOptimistic} = line; - const prevQuantity = Number(Math.max(0, quantity - 1).toFixed(0)); - const nextQuantity = Number((quantity + 1).toFixed(0)); - - return ( -
    - Quantity: {quantity}    - - - -   - - - -   - -
    - ); -} - -function CartLinePrice({ - line, - priceType = 'regular', - ...passthroughProps -}: { - line: CartLine; - priceType?: 'regular' | 'compareAt'; - [key: string]: any; -}) { - if (!line?.cost?.amountPerQuantity || !line?.cost?.totalAmount) - return
     
    ; - - const moneyV2 = - priceType === 'regular' - ? line.cost.totalAmount - : line.cost.compareAtAmountPerQuantity; - - if (moneyV2 == null) { - return
     
    ; - } - - return ( -
    - -
    - ); -} - -export function CartEmpty({ - hidden = false, - layout = 'aside', -}: { - hidden: boolean; - layout?: CartMainProps['layout']; -}) { - return ( - - ); -} - -function CartDiscounts({ - discountCodes, -}: { - discountCodes?: CartApiQueryFragment['discountCodes']; -}) { - const codes: string[] = - discountCodes - ?.filter((discount) => discount.applicable) - ?.map(({code}) => code) || []; - - return ( -
    - {/* Have existing discount, display it with a remove option */} - - - {/* Show an input to apply a discount */} - -
    - -   - -
    -
    -
    - ); -} - -function UpdateDiscountForm({ - discountCodes, - children, -}: { - discountCodes?: string[]; - children: React.ReactNode; -}) { - return ( - - {children} - - ); -} - -function CartLineUpdateButton({ - children, - lines, -}: { - children: React.ReactNode; - lines: CartLineUpdateInput[]; -}) { - return ( - - {children} - - ); -} diff --git a/examples/multipass/app/components/Header.tsx b/examples/multipass/app/components/Header.tsx deleted file mode 100644 index d9cedfb5de..0000000000 --- a/examples/multipass/app/components/Header.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import {Suspense} from 'react'; -import {Await, NavLink} from 'react-router'; -import {type CartViewPayload, useAnalytics} from '@shopify/hydrogen'; -import type {HeaderQuery, CartApiQueryFragment} from 'storefrontapi.generated'; -import {useAside} from '~/components/Aside'; - -interface HeaderProps { - header: HeaderQuery; - cart: Promise; - /***********************************************/ - /********** EXAMPLE UPDATE STARTS ************/ - isLoggedIn: boolean; - /********** EXAMPLE UPDATE END ************/ - /***********************************************/ - publicStoreDomain: string; -} - -type Viewport = 'desktop' | 'mobile'; - -export function Header({ - header, - isLoggedIn, - cart, - publicStoreDomain, -}: HeaderProps) { - const {shop, menu} = header; - return ( -
    - - {shop.name} - - - -
    - ); -} - -export function HeaderMenu({ - menu, - primaryDomainUrl, - viewport, - publicStoreDomain, -}: { - menu: HeaderProps['header']['menu']; - primaryDomainUrl: HeaderProps['header']['shop']['primaryDomain']['url']; - viewport: Viewport; - publicStoreDomain: HeaderProps['publicStoreDomain']; -}) { - const className = `header-menu-${viewport}`; - - function closeAside(event: React.MouseEvent) { - if (viewport === 'mobile') { - event.preventDefault(); - window.location.href = event.currentTarget.href; - } - } - - return ( - - ); -} - -function HeaderCtas({ - isLoggedIn, - cart, -}: Pick) { - return ( - - ); -} - -function HeaderMenuMobileToggle() { - const {open} = useAside(); - return ( - - ); -} - -function SearchToggle() { - const {open} = useAside(); - return ( - - ); -} - -function CartBadge({count}: {count: number}) { - const {open} = useAside(); - const {publish, shop, cart, prevCart} = useAnalytics(); - - return ( - { - e.preventDefault(); - open('cart'); - publish('cart_viewed', { - cart, - prevCart, - shop, - url: window.location.href || '', - } as CartViewPayload); - }} - > - Cart {count} - - ); -} - -function CartToggle({cart}: Pick) { - return ( - }> - - {(cart) => { - if (!cart) return ; - return ; - }} - - - ); -} - -const FALLBACK_HEADER_MENU = { - id: 'gid://shopify/Menu/199655587896', - items: [ - { - id: 'gid://shopify/MenuItem/461609500728', - resourceId: null, - tags: [], - title: 'Collections', - type: 'HTTP', - url: '/collections', - items: [], - }, - { - id: 'gid://shopify/MenuItem/461609533496', - resourceId: null, - tags: [], - title: 'Blog', - type: 'HTTP', - url: '/blogs/journal', - items: [], - }, - { - id: 'gid://shopify/MenuItem/461609566264', - resourceId: null, - tags: [], - title: 'Policies', - type: 'HTTP', - url: '/policies', - items: [], - }, - { - id: 'gid://shopify/MenuItem/461609599032', - resourceId: 'gid://shopify/Page/92591030328', - tags: [], - title: 'About', - type: 'PAGE', - url: '/pages/about', - items: [], - }, - ], -}; - -function activeLinkStyle({ - isActive, - isPending, -}: { - isActive: boolean; - isPending: boolean; -}) { - return { - fontWeight: isActive ? 'bold' : undefined, - color: isPending ? 'grey' : 'black', - }; -} diff --git a/examples/multipass/app/components/PageLayout.tsx b/examples/multipass/app/components/PageLayout.tsx deleted file mode 100644 index 630619560c..0000000000 --- a/examples/multipass/app/components/PageLayout.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import {Await} from 'react-router'; -import {Suspense} from 'react'; -import type { - CartApiQueryFragment, - FooterQuery, - HeaderQuery, -} from 'storefrontapi.generated'; -import {Aside} from '~/components/Aside'; -import {Footer} from '~/components/Footer'; -import {Header, HeaderMenu} from '~/components/Header'; -import {CartMain} from '~/components/Cart'; - -interface PageLayoutProps { - cart: Promise; - footer: Promise; - header: HeaderQuery; - /***********************************************/ - /********** EXAMPLE UPDATE STARTS ************/ - isLoggedIn: boolean; - /********** EXAMPLE UPDATE END ************/ - /***********************************************/ - publicStoreDomain: string; - children?: React.ReactNode; -} - -export function PageLayout({ - cart, - children = null, - footer, - header, - isLoggedIn, - publicStoreDomain, -}: PageLayoutProps) { - return ( - - - - {header && ( -
    - )} -
    {children}
    -