From ba9d00526b2371ff9cee4a7235c560040eee4a32 Mon Sep 17 00:00:00 2001 From: Paul Date: Thu, 22 Feb 2024 18:00:45 +0700 Subject: [PATCH] Update project to use new Customer Account API --- README.md | 18 ++ customer-accountapi.generated.d.ts | 503 +++++++++++++++++++++++++++++ package-lock.json | 114 ++++--- package.json | 13 +- playwright.config.ts | 109 +++++++ remix.config.js | 5 + remix.env.d.ts | 14 +- server.ts | 26 +- tests/cart.test.ts | 84 +++++ tests/utils.ts | 33 ++ 10 files changed, 847 insertions(+), 72 deletions(-) create mode 100644 customer-accountapi.generated.d.ts create mode 100644 playwright.config.ts create mode 100644 tests/cart.test.ts create mode 100644 tests/utils.ts diff --git a/README.md b/README.md index e24cb5e..dac3426 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,24 @@ Weaverse provides a convenient way to customize your theme inside the **Weaverse ![Weaverse Editor](https://cdn.shopify.com/s/files/1/0838/0052/3057/files/playground.jpg?v=1699244445) + +## Setup for using Customer Account API (`/account` section) + +### Setup public domain using Cloudflare Tunnel + +1. Use [untun](https://github.com/unjs/untun) to start a tunnel with your public domain +```bash +npx untun@latest tunnel http://localhost:3456 +``` + +### Include public domain in Customer Account API settings + +1. Go to your Shopify admin => `Hydrogen` or `Headless` app/channel => Customer Account API => Application setup +1. Edit `Callback URI(s)` to include `https://.trycloudflare.com/account/authorize` +1. Edit `Javascript origin(s)` to include your public domain `https://.trycloudflare.com` or keep it blank +1. Edit `Logout URI` to include your public domain `https://.trycloudflare.com` or keep it blank + + ### Local development inspects - Hydrogen app: http://localhost:3456 diff --git a/customer-accountapi.generated.d.ts b/customer-accountapi.generated.d.ts new file mode 100644 index 0000000..60ec504 --- /dev/null +++ b/customer-accountapi.generated.d.ts @@ -0,0 +1,503 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable eslint-comments/no-unlimited-disable */ +/* eslint-disable */ +import * as CustomerAccountAPI from '@shopify/hydrogen/customer-account-api-types'; + +export type CustomerAddressUpdateMutationVariables = CustomerAccountAPI.Exact<{ + address: CustomerAccountAPI.CustomerAddressInput; + addressId: CustomerAccountAPI.Scalars['ID']['input']; + defaultAddress?: CustomerAccountAPI.InputMaybe< + CustomerAccountAPI.Scalars['Boolean']['input'] + >; +}>; + +export type CustomerAddressUpdateMutation = { + customerAddressUpdate?: CustomerAccountAPI.Maybe<{ + userErrors: Array< + Pick< + CustomerAccountAPI.UserErrorsCustomerAddressUserErrors, + 'code' | 'field' | 'message' + > + >; + }>; +}; + +export type CustomerAddressDeleteMutationVariables = CustomerAccountAPI.Exact<{ + addressId: CustomerAccountAPI.Scalars['ID']['input']; +}>; + +export type CustomerAddressDeleteMutation = { + customerAddressDelete?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.CustomerAddressDeletePayload, + 'deletedAddressId' + > & { + userErrors: Array< + Pick< + CustomerAccountAPI.UserErrorsCustomerAddressUserErrors, + 'code' | 'field' | 'message' + > + >; + } + >; +}; + +export type CustomerAddressCreateMutationVariables = CustomerAccountAPI.Exact<{ + address: CustomerAccountAPI.CustomerAddressInput; + defaultAddress?: CustomerAccountAPI.InputMaybe< + CustomerAccountAPI.Scalars['Boolean']['input'] + >; +}>; + +export type CustomerAddressCreateMutation = { + customerAddressCreate?: CustomerAccountAPI.Maybe<{ + customerAddress?: CustomerAccountAPI.Maybe< + Pick + >; + userErrors: Array< + Pick< + CustomerAccountAPI.UserErrorsCustomerAddressUserErrors, + 'code' | 'field' | 'message' + > + >; + }>; +}; + +export type OrderCardFragment = Pick< + CustomerAccountAPI.Order, + 'id' | 'number' | 'processedAt' | 'financialStatus' +> & { + fulfillments: {nodes: Array>}; + totalPrice: Pick; + lineItems: { + edges: Array<{ + node: Pick & { + image?: CustomerAccountAPI.Maybe< + Pick + >; + }; + }>; + }; +}; + +export type AddressPartialFragment = Pick< + CustomerAccountAPI.CustomerAddress, + | 'id' + | 'formatted' + | 'firstName' + | 'lastName' + | 'company' + | 'address1' + | 'address2' + | 'territoryCode' + | 'zoneCode' + | 'city' + | 'zip' + | 'phoneNumber' +>; + +export type CustomerDetailsFragment = Pick< + CustomerAccountAPI.Customer, + 'firstName' | 'lastName' +> & { + phoneNumber?: CustomerAccountAPI.Maybe< + Pick + >; + emailAddress?: CustomerAccountAPI.Maybe< + Pick + >; + defaultAddress?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.CustomerAddress, + | 'id' + | 'formatted' + | 'firstName' + | 'lastName' + | 'company' + | 'address1' + | 'address2' + | 'territoryCode' + | 'zoneCode' + | 'city' + | 'zip' + | 'phoneNumber' + > + >; + addresses: { + edges: Array<{ + node: Pick< + CustomerAccountAPI.CustomerAddress, + | 'id' + | 'formatted' + | 'firstName' + | 'lastName' + | 'company' + | 'address1' + | 'address2' + | 'territoryCode' + | 'zoneCode' + | 'city' + | 'zip' + | 'phoneNumber' + >; + }>; + }; + orders: { + edges: Array<{ + node: Pick< + CustomerAccountAPI.Order, + 'id' | 'number' | 'processedAt' | 'financialStatus' + > & { + fulfillments: { + nodes: Array>; + }; + totalPrice: Pick; + lineItems: { + edges: Array<{ + node: Pick & { + image?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.Image, + 'altText' | 'height' | 'url' | 'width' + > + >; + }; + }>; + }; + }; + }>; + }; +}; + +export type CustomerDetailsQueryVariables = CustomerAccountAPI.Exact<{ + [key: string]: never; +}>; + +export type CustomerDetailsQuery = { + customer: Pick & { + phoneNumber?: CustomerAccountAPI.Maybe< + Pick + >; + emailAddress?: CustomerAccountAPI.Maybe< + Pick + >; + defaultAddress?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.CustomerAddress, + | 'id' + | 'formatted' + | 'firstName' + | 'lastName' + | 'company' + | 'address1' + | 'address2' + | 'territoryCode' + | 'zoneCode' + | 'city' + | 'zip' + | 'phoneNumber' + > + >; + addresses: { + edges: Array<{ + node: Pick< + CustomerAccountAPI.CustomerAddress, + | 'id' + | 'formatted' + | 'firstName' + | 'lastName' + | 'company' + | 'address1' + | 'address2' + | 'territoryCode' + | 'zoneCode' + | 'city' + | 'zip' + | 'phoneNumber' + >; + }>; + }; + orders: { + edges: Array<{ + node: Pick< + CustomerAccountAPI.Order, + 'id' | 'number' | 'processedAt' | 'financialStatus' + > & { + fulfillments: { + nodes: Array>; + }; + totalPrice: Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >; + lineItems: { + edges: Array<{ + node: Pick & { + image?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.Image, + 'altText' | 'height' | 'url' | 'width' + > + >; + }; + }>; + }; + }; + }>; + }; + }; +}; + +export type OrderMoneyFragment = Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' +>; + +export type DiscountApplicationFragment = { + value: + | ({__typename: 'MoneyV2'} & Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >) + | ({__typename: 'PricingPercentageValue'} & Pick< + CustomerAccountAPI.PricingPercentageValue, + 'percentage' + >); +}; + +export type OrderLineItemFullFragment = Pick< + CustomerAccountAPI.LineItem, + 'id' | 'title' | 'quantity' | 'variantTitle' +> & { + price?: CustomerAccountAPI.Maybe< + Pick + >; + discountAllocations: Array<{ + allocatedAmount: Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >; + discountApplication: { + value: + | ({__typename: 'MoneyV2'} & Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >) + | ({__typename: 'PricingPercentageValue'} & Pick< + CustomerAccountAPI.PricingPercentageValue, + 'percentage' + >); + }; + }>; + totalDiscount: Pick; + image?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.Image, + 'altText' | 'height' | 'url' | 'id' | 'width' + > + >; +}; + +export type OrderFragment = Pick< + CustomerAccountAPI.Order, + 'id' | 'name' | 'statusPageUrl' | 'processedAt' +> & { + fulfillments: {nodes: Array>}; + totalTax?: CustomerAccountAPI.Maybe< + Pick + >; + totalPrice: Pick; + subtotal?: CustomerAccountAPI.Maybe< + Pick + >; + shippingAddress?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.CustomerAddress, + 'name' | 'formatted' | 'formattedArea' + > + >; + discountApplications: { + nodes: Array<{ + value: + | ({__typename: 'MoneyV2'} & Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >) + | ({__typename: 'PricingPercentageValue'} & Pick< + CustomerAccountAPI.PricingPercentageValue, + 'percentage' + >); + }>; + }; + lineItems: { + nodes: Array< + Pick< + CustomerAccountAPI.LineItem, + 'id' | 'title' | 'quantity' | 'variantTitle' + > & { + price?: CustomerAccountAPI.Maybe< + Pick + >; + discountAllocations: Array<{ + allocatedAmount: Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >; + discountApplication: { + value: + | ({__typename: 'MoneyV2'} & Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >) + | ({__typename: 'PricingPercentageValue'} & Pick< + CustomerAccountAPI.PricingPercentageValue, + 'percentage' + >); + }; + }>; + totalDiscount: Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >; + image?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.Image, + 'altText' | 'height' | 'url' | 'id' | 'width' + > + >; + } + >; + }; +}; + +export type OrderQueryVariables = CustomerAccountAPI.Exact<{ + orderId: CustomerAccountAPI.Scalars['ID']['input']; +}>; + +export type OrderQuery = { + order?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.Order, + 'id' | 'name' | 'statusPageUrl' | 'processedAt' + > & { + fulfillments: { + nodes: Array>; + }; + totalTax?: CustomerAccountAPI.Maybe< + Pick + >; + totalPrice: Pick; + subtotal?: CustomerAccountAPI.Maybe< + Pick + >; + shippingAddress?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.CustomerAddress, + 'name' | 'formatted' | 'formattedArea' + > + >; + discountApplications: { + nodes: Array<{ + value: + | ({__typename: 'MoneyV2'} & Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >) + | ({__typename: 'PricingPercentageValue'} & Pick< + CustomerAccountAPI.PricingPercentageValue, + 'percentage' + >); + }>; + }; + lineItems: { + nodes: Array< + Pick< + CustomerAccountAPI.LineItem, + 'id' | 'title' | 'quantity' | 'variantTitle' + > & { + price?: CustomerAccountAPI.Maybe< + Pick + >; + discountAllocations: Array<{ + allocatedAmount: Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >; + discountApplication: { + value: + | ({__typename: 'MoneyV2'} & Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >) + | ({__typename: 'PricingPercentageValue'} & Pick< + CustomerAccountAPI.PricingPercentageValue, + 'percentage' + >); + }; + }>; + totalDiscount: Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >; + image?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.Image, + 'altText' | 'height' | 'url' | 'id' | 'width' + > + >; + } + >; + }; + } + >; +}; + +export type CustomerUpdateMutationVariables = CustomerAccountAPI.Exact<{ + customer: CustomerAccountAPI.CustomerUpdateInput; +}>; + +export type CustomerUpdateMutation = { + customerUpdate?: CustomerAccountAPI.Maybe<{ + userErrors: Array< + Pick< + CustomerAccountAPI.UserErrorsCustomerUserErrors, + 'code' | 'field' | 'message' + > + >; + }>; +}; + +interface GeneratedQueryTypes { + '#graphql\n query CustomerDetails {\n customer {\n ...CustomerDetails\n }\n }\n #graphql\n fragment OrderCard on Order {\n id\n number\n processedAt\n financialStatus\n fulfillments(first: 1) {\n nodes {\n status\n }\n }\n totalPrice {\n amount\n currencyCode\n }\n lineItems(first: 2) {\n edges {\n node {\n title\n image {\n altText\n height\n url\n width\n }\n }\n }\n }\n }\n\n fragment AddressPartial on CustomerAddress {\n id\n formatted\n firstName\n lastName\n company\n address1\n address2\n territoryCode\n zoneCode\n city\n zip\n phoneNumber\n }\n\n fragment CustomerDetails on Customer {\n firstName\n lastName\n phoneNumber {\n phoneNumber\n }\n emailAddress {\n emailAddress\n }\n defaultAddress {\n ...AddressPartial\n }\n addresses(first: 6) {\n edges {\n node {\n ...AddressPartial\n }\n }\n }\n orders(first: 250, sortKey: PROCESSED_AT, reverse: true) {\n edges {\n node {\n ...OrderCard\n }\n }\n }\n }\n\n': { + return: CustomerDetailsQuery; + variables: CustomerDetailsQueryVariables; + }; + '#graphql\n fragment OrderMoney on MoneyV2 {\n amount\n currencyCode\n }\n fragment DiscountApplication on DiscountApplication {\n value {\n __typename\n ... on MoneyV2 {\n ...OrderMoney\n }\n ... on PricingPercentageValue {\n percentage\n }\n }\n }\n fragment OrderLineItemFull on LineItem {\n id\n title\n quantity\n price {\n ...OrderMoney\n }\n discountAllocations {\n allocatedAmount {\n ...OrderMoney\n }\n discountApplication {\n ...DiscountApplication\n }\n }\n totalDiscount {\n ...OrderMoney\n }\n image {\n altText\n height\n url\n id\n width\n }\n variantTitle\n }\n fragment Order on Order {\n id\n name\n statusPageUrl\n processedAt\n fulfillments(first: 1) {\n nodes {\n status\n }\n }\n totalTax {\n ...OrderMoney\n }\n totalPrice {\n ...OrderMoney\n }\n subtotal {\n ...OrderMoney\n }\n shippingAddress {\n name\n formatted(withName: true)\n formattedArea\n }\n discountApplications(first: 100) {\n nodes {\n ...DiscountApplication\n }\n }\n lineItems(first: 100) {\n nodes {\n ...OrderLineItemFull\n }\n }\n }\n query Order($orderId: ID!) {\n order(id: $orderId) {\n ... on Order {\n ...Order\n }\n }\n }\n': { + return: OrderQuery; + variables: OrderQueryVariables; + }; +} + +interface GeneratedMutationTypes { + '#graphql\n mutation customerAddressUpdate(\n $address: CustomerAddressInput!\n $addressId: ID!\n $defaultAddress: Boolean\n ) {\n customerAddressUpdate(\n address: $address\n addressId: $addressId\n defaultAddress: $defaultAddress\n ) {\n userErrors {\n code\n field\n message\n }\n }\n }\n': { + return: CustomerAddressUpdateMutation; + variables: CustomerAddressUpdateMutationVariables; + }; + '#graphql\n mutation customerAddressDelete(\n $addressId: ID!,\n ) {\n customerAddressDelete(addressId: $addressId) {\n deletedAddressId\n userErrors {\n code\n field\n message\n }\n }\n }\n': { + return: CustomerAddressDeleteMutation; + variables: CustomerAddressDeleteMutationVariables; + }; + '#graphql\n mutation customerAddressCreate(\n $address: CustomerAddressInput!\n $defaultAddress: Boolean\n ) {\n customerAddressCreate(\n address: $address\n defaultAddress: $defaultAddress\n ) {\n customerAddress {\n id\n }\n userErrors {\n code\n field\n message\n }\n }\n }\n': { + return: CustomerAddressCreateMutation; + variables: CustomerAddressCreateMutationVariables; + }; + '#graphql\nmutation customerUpdate($customer: CustomerUpdateInput!) {\n customerUpdate(input: $customer) {\n userErrors {\n code\n field\n message\n }\n }\n}\n': { + return: CustomerUpdateMutation; + variables: CustomerUpdateMutationVariables; + }; +} + +declare module '@shopify/hydrogen' { + interface CustomerAccountQueries extends GeneratedQueryTypes {} + interface CustomerAccountMutations extends GeneratedMutationTypes {} +} diff --git a/package-lock.json b/package-lock.json index b542ed9..a3c5f74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,8 @@ "dependencies": { "@fontsource/roboto": "^5.0.8", "@headlessui/react": "1.7.18", - "@remix-run/react": "2.4.1", - "@remix-run/server-runtime": "2.4.1", + "@remix-run/react": "2.7.1", + "@remix-run/server-runtime": "2.7.1", "@shopify/cli": "3.53.0", "@shopify/cli-hydrogen": "^7.0.1", "@shopify/hydrogen": "~2024.1.1", @@ -33,8 +33,8 @@ "typographic-base": "1.0.4" }, "devDependencies": { - "@remix-run/dev": "2.4.1", - "@remix-run/eslint-config": "2.4.1", + "@remix-run/dev": "2.7.1", + "@remix-run/eslint-config": "2.7.1", "@shopify/eslint-plugin": "44.0.0", "@shopify/oxygen-workers-types": "^4.0.0", "@shopify/prettier-config": "1.1.2", @@ -59,7 +59,7 @@ "node": ">=18.0.0" }, "optionalDependencies": { - "@esbuild/linux-x64": "^0.20.0" + "@esbuild/linux-x64": "^0.20.1" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -2745,9 +2745,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.0.tgz", - "integrity": "sha512-H9Eu6MGse++204XZcYsse1yFHmRXEWgadk2N58O/xd50P9EvFMLJTQLg+lB4E1cF2xhLZU5luSWtGTb0l9UeSg==", + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.1.tgz", + "integrity": "sha512-5gRPk7pKuaIB+tmH+yKd2aQTRpqlf1E4f/mC+tawIm/CGJemZcHZpp2ic8oD83nKgUPMEd0fNanrnFljiruuyA==", "cpu": [ "x64" ], @@ -5215,9 +5215,9 @@ } }, "node_modules/@remix-run/dev": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@remix-run/dev/-/dev-2.4.1.tgz", - "integrity": "sha512-T8GLCKpZ8AX/NCJ+vyMmcTq328xo9stvDSXG2hSJx7njz4Q9sC25miQLXPRb3Lx/Sdf4YlJhWQpR6uq5pnXCZg==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@remix-run/dev/-/dev-2.7.1.tgz", + "integrity": "sha512-G/jtzIvVE+bHHMLS8Iu99sljKFUO51/HTcYE3GoLRDKGrgbZaJHnLvsHiknMLIzqsBBGam1iM+Wf12qHdOy1Dw==", "devOptional": true, "dependencies": { "@babel/core": "^7.21.8", @@ -5230,9 +5230,9 @@ "@babel/types": "^7.22.5", "@mdx-js/mdx": "^2.3.0", "@npmcli/package-json": "^4.0.1", - "@remix-run/node": "2.4.1", - "@remix-run/router": "1.14.1", - "@remix-run/server-runtime": "2.4.1", + "@remix-run/node": "2.7.1", + "@remix-run/router": "1.15.1", + "@remix-run/server-runtime": "2.7.1", "@types/mdx": "^2.0.5", "@vanilla-extract/integration": "^6.2.0", "arg": "^5.0.1", @@ -5281,9 +5281,10 @@ "node": ">=18.0.0" }, "peerDependencies": { - "@remix-run/serve": "^2.4.1", + "@remix-run/serve": "^2.7.1", "typescript": "^5.1.0", - "vite": "^5.0.0" + "vite": "^5.1.0", + "wrangler": "^3.28.2" }, "peerDependenciesMeta": { "@remix-run/serve": { @@ -5294,6 +5295,9 @@ }, "vite": { "optional": true + }, + "wrangler": { + "optional": true } } }, @@ -5313,9 +5317,9 @@ } }, "node_modules/@remix-run/eslint-config": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@remix-run/eslint-config/-/eslint-config-2.4.1.tgz", - "integrity": "sha512-kzl520uCcIi7OlqItzy/OSb3YVBklxeov8d39fFPbcSAmwO99l0PXmstQcRCQphAkGC7uRNl3LSJ9LpD3Gj2dQ==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@remix-run/eslint-config/-/eslint-config-2.7.1.tgz", + "integrity": "sha512-LYNg75f6i6R0Zmqsb7edt4EorGQnsO0tFvAUS/23wacn3txt6sCsxsVVdFCiIn9ZTf5NPAuoF22mv1am6MvRJA==", "dev": true, "dependencies": { "@babel/core": "^7.21.8", @@ -5350,12 +5354,12 @@ } }, "node_modules/@remix-run/node": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@remix-run/node/-/node-2.4.1.tgz", - "integrity": "sha512-TENt5OiTnjZmoayqpEiU0207JIFF7TbagQ4UT0dFI9oKQrNQJvkDd2JQBEldd8TLDuSYxU8iu7+CXZ/kl3O35w==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@remix-run/node/-/node-2.7.1.tgz", + "integrity": "sha512-eFVTEErotfATcxCJuC/bQqIDz0C8TWm0EcjnmlHdEICBMcDKIuzqup6jpEYptAmGwHlbJUicPnD6cjmu+UP91A==", "devOptional": true, "dependencies": { - "@remix-run/server-runtime": "2.4.1", + "@remix-run/server-runtime": "2.7.1", "@remix-run/web-fetch": "^4.4.2", "@remix-run/web-file": "^3.1.0", "@remix-run/web-stream": "^1.1.0", @@ -5377,14 +5381,14 @@ } }, "node_modules/@remix-run/react": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@remix-run/react/-/react-2.4.1.tgz", - "integrity": "sha512-6qfLpijD96fKd276/MOtarf/SkFmWDKXTXzpMQzYTiRXofUDezRGG3VqbkopD1O+jl4BjTuKQvI+7YfLcfGx8w==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@remix-run/react/-/react-2.7.1.tgz", + "integrity": "sha512-8PfkvSMtdW3JUBo7I0VMcnDZLqRtYKxXCZ9qXNJogJjy0dfaywEoAREfhGyIdwSLAQJ4TFKGz5jyt9L1/EYU3g==", "dependencies": { - "@remix-run/router": "1.14.1", - "@remix-run/server-runtime": "2.4.1", - "react-router": "6.21.1", - "react-router-dom": "6.21.1" + "@remix-run/router": "1.15.1", + "@remix-run/server-runtime": "2.7.1", + "react-router": "6.22.1", + "react-router-dom": "6.22.1" }, "engines": { "node": ">=18.0.0" @@ -5401,22 +5405,22 @@ } }, "node_modules/@remix-run/router": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.14.1.tgz", - "integrity": "sha512-Qg4DMQsfPNAs88rb2xkdk03N3bjK4jgX5fR24eHCTR9q6PrhZQZ4UJBPzCHJkIpTRN1UKxx2DzjZmnC+7Lj0Ow==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.1.tgz", + "integrity": "sha512-zcU0gM3z+3iqj8UX45AmWY810l3oUmXM7uH4dt5xtzvMhRtYVhKGOmgOd1877dOPPepfCjUv57w+syamWIYe7w==", "engines": { "node": ">=14.0.0" } }, "node_modules/@remix-run/server-runtime": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@remix-run/server-runtime/-/server-runtime-2.4.1.tgz", - "integrity": "sha512-aQyBa0U8Db4E9sv2sruMfPDBYB4jlqvZ43YvkaZ1BGjUzi84ssfmaHdWgX/QveB6hi61RABTi6v8DV548kmRQg==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@remix-run/server-runtime/-/server-runtime-2.7.1.tgz", + "integrity": "sha512-idmg8Tf9w5fZ+Dgq2aRjZDKlLaZt+rB0yAw+Ht5yzI6MLeKI4SKHsdnGlEY/EeFcbbrXhArrrSjf+mKLJfJ8qw==", "dependencies": { - "@remix-run/router": "1.14.1", - "@types/cookie": "^0.5.3", + "@remix-run/router": "1.15.1", + "@types/cookie": "^0.6.0", "@web3-storage/multipart-parser": "^1.0.0", - "cookie": "^0.5.0", + "cookie": "^0.6.0", "set-cookie-parser": "^2.4.8", "source-map": "^0.7.3" }, @@ -5432,6 +5436,14 @@ } } }, + "node_modules/@remix-run/server-runtime/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@remix-run/web-blob": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@remix-run/web-blob/-/web-blob-3.1.0.tgz", @@ -7163,9 +7175,9 @@ } }, "node_modules/@types/cookie": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.5.4.tgz", - "integrity": "sha512-7z/eR6O859gyWIAjuvBWFzNURmf2oPBmJlfVWkwehU5nzIyjwBsTh7WMmEEV4JFnHuQ3ex4oyTvfKzcyJVDBNA==" + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" }, "node_modules/@types/debug": { "version": "4.1.12", @@ -22368,11 +22380,11 @@ } }, "node_modules/react-router": { - "version": "6.21.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.21.1.tgz", - "integrity": "sha512-W0l13YlMTm1YrpVIOpjCADJqEUpz1vm+CMo47RuFX4Ftegwm6KOYsL5G3eiE52jnJpKvzm6uB/vTKTPKM8dmkA==", + "version": "6.22.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.1.tgz", + "integrity": "sha512-0pdoRGwLtemnJqn1K0XHUbnKiX0S4X8CgvVVmHGOWmofESj31msHo/1YiqcJWK7Wxfq2a4uvvtS01KAQyWK/CQ==", "dependencies": { - "@remix-run/router": "1.14.1" + "@remix-run/router": "1.15.1" }, "engines": { "node": ">=14.0.0" @@ -22382,12 +22394,12 @@ } }, "node_modules/react-router-dom": { - "version": "6.21.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.21.1.tgz", - "integrity": "sha512-QCNrtjtDPwHDO+AO21MJd7yIcr41UetYt5jzaB9Y1UYaPTCnVuJq6S748g1dE11OQlCFIQg+RtAA1SEZIyiBeA==", + "version": "6.22.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.22.1.tgz", + "integrity": "sha512-iwMyyyrbL7zkKY7MRjOVRy+TMnS/OPusaFVxM2P11x9dzSzGmLsebkCvYirGq0DWB9K9hOspHYYtDz33gE5Duw==", "dependencies": { - "@remix-run/router": "1.14.1", - "react-router": "6.21.1" + "@remix-run/router": "1.15.1", + "react-router": "6.22.1" }, "engines": { "node": ">=14.0.0" diff --git a/package.json b/package.json index be11b08..71ea470 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "dev": "shopify hydrogen dev --codegen --port 3456", "build": "shopify hydrogen build", "preview": "npm run build && shopify hydrogen preview", + "e2e": "npx playwright test", + "e2e:ui": "npx playwright test --ui", "lint": "eslint --no-error-on-unmatched-pattern --ext .js,.ts,.jsx,.tsx .", "lint:fix": "eslint --fix --ext .js,.ts,.jsx,.tsx .", "format": "prettier --write --ignore-unknown .", @@ -21,8 +23,8 @@ "dependencies": { "@fontsource/roboto": "^5.0.8", "@headlessui/react": "1.7.18", - "@remix-run/react": "2.4.1", - "@remix-run/server-runtime": "2.4.1", + "@remix-run/react": "2.7.1", + "@remix-run/server-runtime": "2.7.1", "@shopify/cli": "3.53.0", "@shopify/cli-hydrogen": "^7.0.1", "@shopify/hydrogen": "~2024.1.1", @@ -44,8 +46,9 @@ "typographic-base": "1.0.4" }, "devDependencies": { - "@remix-run/dev": "2.4.1", - "@remix-run/eslint-config": "2.4.1", + "@playwright/test": "^1.40.1", + "@remix-run/dev": "2.7.1", + "@remix-run/eslint-config": "2.7.1", "@shopify/eslint-plugin": "44.0.0", "@shopify/oxygen-workers-types": "^4.0.0", "@shopify/prettier-config": "1.1.2", @@ -67,7 +70,7 @@ "typescript": "5.3.3" }, "optionalDependencies": { - "@esbuild/linux-x64": "^0.20.0" + "@esbuild/linux-x64": "^0.20.1" }, "engines": { "node": ">=18.0.0" diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..0e0255b --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,109 @@ +import type {PlaywrightTestConfig} from '@playwright/test'; +import {devices} from '@playwright/test'; + +declare const process: {env: {CI: boolean}}; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './tests', + /* Maximum time one test can run for. */ + timeout: 30 * 1000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 5000, + }, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + + // { + // name: 'firefox', + // use: { + // ...devices['Desktop Firefox'], + // }, + // }, + + // { + // name: 'webkit', + // use: { + // ...devices['Desktop Safari'], + // }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { + // ...devices['Pixel 5'], + // }, + // }, + // { + // name: 'Mobile Safari', + // use: { + // ...devices['iPhone 12'], + // }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { + // channel: 'msedge', + // }, + // }, + // { + // name: 'Google Chrome', + // use: { + // channel: 'chrome', + // }, + // }, + ], + + /* Folder for test artifacts such as screenshots, videos, traces, etc. */ + // outputDir: 'test-results/', + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'npm run preview', + port: 3000, + }, +}; + +export default config; diff --git a/remix.config.js b/remix.config.js index 21b3a6f..88a6ffb 100644 --- a/remix.config.js +++ b/remix.config.js @@ -24,4 +24,9 @@ module.exports = { serverMinify: process.env.NODE_ENV === 'production', postcss: true, tailwind: true, + future: { + v3_fetcherPersist: true, + v3_relativeSplatpath: true, + v3_throwAbortReason: true, + }, }; diff --git a/remix.env.d.ts b/remix.env.d.ts index 8245a18..509c908 100644 --- a/remix.env.d.ts +++ b/remix.env.d.ts @@ -3,8 +3,8 @@ /// import type {WithCache, HydrogenCart} from '@shopify/hydrogen'; -import type {Storefront} from '~/lib/type'; -import type {HydrogenSession} from '~/lib/session.server'; +import type {Storefront, CustomerAccount} from '~/lib/type'; +import type {AppSession} from '~/lib/session.server'; import type {WeaverseClient} from '@weaverse/hydrogen'; declare global { @@ -37,19 +37,13 @@ declare module '@shopify/remix-oxygen' { */ export interface AppLoadContext { waitUntil: ExecutionContext['waitUntil']; - session: HydrogenSession; + session: AppSession; storefront: Storefront; + customerAccount: CustomerAccount; cart: HydrogenCart; env: Env; weaverse: WeaverseClient; } - - /** - * Declare the data we expect to access via `context.session`. - */ - export interface SessionData { - customerAccessToken: string; - } } // Needed to make this file a module. diff --git a/server.ts b/server.ts index 8b0ebbd..ef75dd0 100644 --- a/server.ts +++ b/server.ts @@ -1,18 +1,19 @@ // Virtual entry point for the app import * as remixBuild from '@remix-run/dev/server-build'; +import { + createRequestHandler, + getStorefrontHeaders, +} from '@shopify/remix-oxygen'; import { cartGetIdDefault, cartSetIdDefault, createCartHandler, createStorefrontClient, storefrontRedirect, + createCustomerAccountClient, } from '@shopify/hydrogen'; -import { - createRequestHandler, - getStorefrontHeaders, -} from '@shopify/remix-oxygen'; -import {HydrogenSession} from '~/lib/session.server'; +import {AppSession} from '~/lib/session.server'; import {getLocaleFromRequest} from '~/lib/utils'; import {createWeaverseClient} from '~/weaverse/create-weaverse.server'; @@ -36,7 +37,7 @@ export default { const waitUntil = executionContext.waitUntil.bind(executionContext); const [cache, session] = await Promise.all([ caches.open('hydrogen'), - HydrogenSession.init(request, [env.SESSION_SECRET]), + AppSession.init(request, [env.SESSION_SECRET]), ]); /** @@ -53,8 +54,20 @@ export default { storefrontHeaders: getStorefrontHeaders(request), }); + /** + * Create a client for Customer Account API. + */ + const customerAccount = createCustomerAccountClient({ + waitUntil, + request, + session, + customerAccountId: env.PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID, + customerAccountUrl: env.PUBLIC_CUSTOMER_ACCOUNT_API_URL, + }); + const cart = createCartHandler({ storefront, + customerAccount, getCartId: cartGetIdDefault(request.headers), setCartId: cartSetIdDefault(), }); @@ -70,6 +83,7 @@ export default { session, waitUntil, storefront, + customerAccount, cart, env, weaverse: createWeaverseClient({ diff --git a/tests/cart.test.ts b/tests/cart.test.ts new file mode 100644 index 0000000..694b832 --- /dev/null +++ b/tests/cart.test.ts @@ -0,0 +1,84 @@ +import {test, expect} from '@playwright/test'; + +import {formatPrice, normalizePrice} from './utils'; + +test.describe('Cart', () => { + test('From home to checkout flow', async ({page}) => { + // Home => Collections => First collection => First product + await page.goto(`/`); + await page.locator(`header nav a:text-is("Collections")`).click(); + await page.locator(`[data-test=collection-grid] a >> nth=0`).click(); + await page.locator(`[data-test=product-grid] a >> nth=0`).click(); + + const firstItemPrice = normalizePrice( + await page.locator(`[data-test=price]`).textContent(), + ); + + await page.locator(`[data-test=add-to-cart]`).click(); + + await expect( + page.locator('[data-test=subtotal]'), + 'should show the correct price', + ).toContainText(formatPrice(firstItemPrice)); + + // Add an extra unit by increasing quantity + await page + .locator(`button :text-is("+")`) + .click({clickCount: 1, delay: 600}); + + await expect( + page.locator('[data-test=subtotal]'), + 'should double the price', + ).toContainText(formatPrice(2 * firstItemPrice)); + + await expect( + page.locator('[data-test=item-quantity]'), + 'should increase quantity', + ).toContainText('2'); + + // Close cart drawer => Products => First product + await page.locator('[data-test=close-cart]').click(); + await page.locator(`header nav a:text-is("Products")`).click(); + await page.locator(`[data-test=product-grid] a >> nth=0`).click(); + + const secondItemPrice = normalizePrice( + await page.locator(`[data-test=price]`).textContent(), + ); + + // Add another unit by adding to cart the same item + await page.locator(`[data-test=add-to-cart]`).click(); + + await expect( + page.locator('[data-test=subtotal]'), + 'should add the price of the second item', + ).toContainText(formatPrice(2 * firstItemPrice + secondItemPrice)); + + const quantities = await page + .locator('[data-test=item-quantity]') + .allTextContents(); + await expect( + quantities.reduce((a, b) => Number(a) + Number(b), 0), + 'should have the correct item quantities', + ).toEqual(3); + + const priceInStore = await page + .locator('[data-test=subtotal]') + .textContent(); + + await page.locator('a :text("Checkout")').click(); + + await expect(page.url(), 'should navigate to checkout').toMatch( + /checkout\.hydrogen\.shop\/checkouts\/[\d\w]+/, + ); + + const priceInCheckout = await page + .locator('[role=cell] > span') + .getByText(/^\$\d/) + .textContent(); + + await expect( + normalizePrice(priceInCheckout), + 'should show the same price in checkout', + ).toEqual(normalizePrice(priceInStore)); + }); +}); diff --git a/tests/utils.ts b/tests/utils.ts new file mode 100644 index 0000000..a5c5399 --- /dev/null +++ b/tests/utils.ts @@ -0,0 +1,33 @@ +/** + * Formats a number as USD. Example: 1800 => $1,800.00 + */ +export function formatPrice( + price: string | number, + currency = 'USD', + locale = 'en-US', +) { + const formatter = new Intl.NumberFormat(locale, { + style: 'currency', + currency, + }); + + return formatter.format(Number(price)); +} + +/** + * Removes symbols and decimals from a price and converts to number. + */ +export function normalizePrice(price: string | null) { + if (!price || !/^[$\d.,]+$/.test(price)) { + throw new Error('Price was not found'); + } + + return Number( + price + .replace('$', '') + .trim() + .replace(/[.,](\d\d)$/, '-$1') + .replace(/[.,]/g, '') + .replace('-', '.'), + ); +}