From d5a707302d25d7aefc8c699ad743a58d91094ca1 Mon Sep 17 00:00:00 2001 From: "Juan P. Prieto" Date: Tue, 12 Jul 2022 16:49:33 -0700 Subject: [PATCH 1/6] first commit --- examples/cart-server-session/README.md | 44 ++++++ .../cart-server-session/hydrogen.config.js | 23 +++ examples/cart-server-session/index.html | 16 ++ examples/cart-server-session/jsconfig.json | 12 ++ examples/cart-server-session/package.json | 23 +++ .../public/assets/favicon.svg | 18 +++ .../cart-server-session/src/App.server.jsx | 42 ++++++ .../src/components/AddToCart.server.jsx | 36 +++++ .../components/AsyncGetCartLines.server.jsx | 13 ++ .../src/components/CartCount.server.jsx | 7 + .../src/components/CartLines.server.jsx | 74 +++++++++ .../src/components/Header.server.jsx | 27 ++++ .../src/components/ProductItem.server.jsx | 52 +++++++ .../src/components/ProductsList.server.jsx | 73 +++++++++ .../src/components/Sidebar.server.jsx | 34 +++++ .../src/components/SidebarToggle.server.jsx | 37 +++++ .../cart-server-session/src/graphql/index.js | 93 ++++++++++++ examples/cart-server-session/src/index.css | 26 ++++ .../src/routes/api/cart/[action].server.jsx | 115 ++++++++++++++ .../src/routes/index.server.jsx | 16 ++ .../cart-server-session/src/utils/cart.js | 141 ++++++++++++++++++ .../src/utils/suspendedFn.js | 27 ++++ .../src/utils/wrapPromise.js | 27 ++++ examples/cart-server-session/vite.config.js | 14 ++ 24 files changed, 990 insertions(+) create mode 100644 examples/cart-server-session/README.md create mode 100644 examples/cart-server-session/hydrogen.config.js create mode 100644 examples/cart-server-session/index.html create mode 100644 examples/cart-server-session/jsconfig.json create mode 100644 examples/cart-server-session/package.json create mode 100644 examples/cart-server-session/public/assets/favicon.svg create mode 100644 examples/cart-server-session/src/App.server.jsx create mode 100644 examples/cart-server-session/src/components/AddToCart.server.jsx create mode 100644 examples/cart-server-session/src/components/AsyncGetCartLines.server.jsx create mode 100644 examples/cart-server-session/src/components/CartCount.server.jsx create mode 100644 examples/cart-server-session/src/components/CartLines.server.jsx create mode 100644 examples/cart-server-session/src/components/Header.server.jsx create mode 100644 examples/cart-server-session/src/components/ProductItem.server.jsx create mode 100644 examples/cart-server-session/src/components/ProductsList.server.jsx create mode 100644 examples/cart-server-session/src/components/Sidebar.server.jsx create mode 100644 examples/cart-server-session/src/components/SidebarToggle.server.jsx create mode 100644 examples/cart-server-session/src/graphql/index.js create mode 100644 examples/cart-server-session/src/index.css create mode 100644 examples/cart-server-session/src/routes/api/cart/[action].server.jsx create mode 100644 examples/cart-server-session/src/routes/index.server.jsx create mode 100644 examples/cart-server-session/src/utils/cart.js create mode 100644 examples/cart-server-session/src/utils/suspendedFn.js create mode 100644 examples/cart-server-session/src/utils/wrapPromise.js create mode 100644 examples/cart-server-session/vite.config.js diff --git a/examples/cart-server-session/README.md b/examples/cart-server-session/README.md new file mode 100644 index 0000000000..1a5c65bde8 --- /dev/null +++ b/examples/cart-server-session/README.md @@ -0,0 +1,44 @@ +# RSC/SSR Hydrogen Cart with session + +An example demonstrating a .server driven basic Cart logic. + +- Provides a set of cart utilities and cart client +- Provides a `/api/cart/[action]` to handle POST requests from the different `get`, `create`, `add` and `remove` form actions +- Add `cartId` and `cartCount` to the session as cookies so they can be used during .server +- Uses form actions for all interactions with the API. e.g — AddToCart, remove.. + +# Hydrogen + +Hydrogen is a React framework and SDK that you can use to build fast and dynamic Shopify custom storefronts. + +[Check out the docs](https://shopify.dev/custom-storefronts/hydrogen) + +[Run this template on StackBlitz](https://stackblitz.com/github/Shopify/hydrogen/tree/stackblitz/templates/hello-world-js) + +## Getting started + +**Requirements:** + +- Node.js version 16.5.0 or higher +- Yarn + +```bash +npm init @shopify/hydrogen@latest --template hello-world-ts +``` + +Remember to update `hydrogen.config.js` with your shop's domain and Storefront API token! + +## Building for production + +```bash +yarn build +``` + +## Previewing a production build + +To run a local preview of your Hydrogen app in an environment similar to Oxygen, build your Hydrogen app and then run `yarn preview`: + +```bash +yarn build +yarn preview +``` diff --git a/examples/cart-server-session/hydrogen.config.js b/examples/cart-server-session/hydrogen.config.js new file mode 100644 index 0000000000..eb1e35f945 --- /dev/null +++ b/examples/cart-server-session/hydrogen.config.js @@ -0,0 +1,23 @@ +import { + defineConfig, + CookieSessionStorage, + PerformanceMetricsServerAnalyticsConnector, +} from '@shopify/hydrogen/config'; + +export default defineConfig({ + shopify: () => ({ + defaultLanguageCode: 'EN', + defaultCountryCode: 'GB', + storeDomain: 'hydrogen-preview.myshopify.com', + storefrontToken: '3b580e70970c4528da70c98e097c2fa0', + storefrontApiVersion: '2022-07', + }), + session: CookieSessionStorage('__session', { + path: '/', + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 60 * 60 * 24 * 30, + }), + serverAnalyticsConnectors: [PerformanceMetricsServerAnalyticsConnector], +}); diff --git a/examples/cart-server-session/index.html b/examples/cart-server-session/index.html new file mode 100644 index 0000000000..448ec2b950 --- /dev/null +++ b/examples/cart-server-session/index.html @@ -0,0 +1,16 @@ + + + + + + + + Hydrogen App + + + + +
+ + + diff --git a/examples/cart-server-session/jsconfig.json b/examples/cart-server-session/jsconfig.json new file mode 100644 index 0000000000..534677ef05 --- /dev/null +++ b/examples/cart-server-session/jsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "esnext", + "moduleResolution": "node16", + "lib": ["dom", "dom.iterable", "scripthost", "es2020"], + "jsx": "react", + "types": ["vite/client"] + }, + "exclude": ["node_modules", "dist"], + "include": ["**/*.js", "**/*.jsx"] +} diff --git a/examples/cart-server-session/package.json b/examples/cart-server-session/package.json new file mode 100644 index 0000000000..b761fce8ac --- /dev/null +++ b/examples/cart-server-session/package.json @@ -0,0 +1,23 @@ +{ + "name": "hello-cart-session", + "description": "An example using JavaScript in Hydrogen", + "version": "0.0.0", + "license": "MIT", + "private": true, + "scripts": { + "dev": "shopify hydrogen dev", + "build": "shopify hydrogen build", + "preview": "shopify hydrogen preview" + }, + "devDependencies": { + "@shopify/cli": "3.0.27", + "@shopify/cli-hydrogen": "3.0.27", + "vite": "^2.9.0" + }, + "dependencies": { + "@shopify/hydrogen": "^1.0.2", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "author": "juanp.prieto" +} diff --git a/examples/cart-server-session/public/assets/favicon.svg b/examples/cart-server-session/public/assets/favicon.svg new file mode 100644 index 0000000000..a699e015fb --- /dev/null +++ b/examples/cart-server-session/public/assets/favicon.svg @@ -0,0 +1,18 @@ + + + + + diff --git a/examples/cart-server-session/src/App.server.jsx b/examples/cart-server-session/src/App.server.jsx new file mode 100644 index 0000000000..d6853c9f6f --- /dev/null +++ b/examples/cart-server-session/src/App.server.jsx @@ -0,0 +1,42 @@ +import { + ShopifyProvider, + Router, + FileRoutes, + CacheNone, +} from '@shopify/hydrogen'; + +import React from 'react'; +import renderHydrogen from '@shopify/hydrogen/entry-server'; +import {Suspense} from 'react'; +import {Header} from '~/components/Header.server'; +import {Sidebar} from '~/components/Sidebar.server'; +import {AsyncGetCartLines} from '~/components/AsyncGetCartLines.server'; + +function App({request, response}) { + response.cache(CacheNone()); + const isFavIconRequest = request.normalizedUrl.includes('favicon'); + + if (isFavIconRequest) { + return null; + } + + const url = new URL(request.normalizedUrl); + const toggleSidebar = url.searchParams.get('toggleSidebar') || false; + + return ( + +
+ + + + + +
+ + + +
+ ); +} + +export default renderHydrogen(App); diff --git a/examples/cart-server-session/src/components/AddToCart.server.jsx b/examples/cart-server-session/src/components/AddToCart.server.jsx new file mode 100644 index 0000000000..eebff2f99e --- /dev/null +++ b/examples/cart-server-session/src/components/AddToCart.server.jsx @@ -0,0 +1,36 @@ +export function AddToCart({cartId}) { + return ( +
+ + + + + {/* add lines or create a cart with lines */} + {cartId ? ( + <> + + + + ) : ( + + )} +
+ ); +} diff --git a/examples/cart-server-session/src/components/AsyncGetCartLines.server.jsx b/examples/cart-server-session/src/components/AsyncGetCartLines.server.jsx new file mode 100644 index 0000000000..581d70994f --- /dev/null +++ b/examples/cart-server-session/src/components/AsyncGetCartLines.server.jsx @@ -0,0 +1,13 @@ +import {useSession} from '@shopify/hydrogen'; +import {getCart} from '~/utils/cart'; +import {suspendedFn} from '~/utils/suspendedFn'; +import {CartLines} from '~/components/CartLines.server'; + +const getCartLinesSync = suspendedFn(getCart); + +export function AsyncGetCartLines() { + const {cartId} = useSession(); + const cart = cartId ? getCartLinesSync({id: cartId}) : []; + + return ; +} diff --git a/examples/cart-server-session/src/components/CartCount.server.jsx b/examples/cart-server-session/src/components/CartCount.server.jsx new file mode 100644 index 0000000000..100c697840 --- /dev/null +++ b/examples/cart-server-session/src/components/CartCount.server.jsx @@ -0,0 +1,7 @@ +import {useSession} from '@shopify/hydrogen'; + +export function CartCount() { + const {cartCount} = useSession(); + + return

CART ({cartCount || 0})

; +} diff --git a/examples/cart-server-session/src/components/CartLines.server.jsx b/examples/cart-server-session/src/components/CartLines.server.jsx new file mode 100644 index 0000000000..1928c4c12e --- /dev/null +++ b/examples/cart-server-session/src/components/CartLines.server.jsx @@ -0,0 +1,74 @@ +import {useSession, flattenConnection} from '@shopify/hydrogen'; + +export function CartLines({lines = []}) { + return ( +
+
+
+ {/* each line items */} + {lines?.length ? ( + (lines || []).map((line) => { + return ; + }) + ) : ( +

No items in cart

+ )} +
+
+ ); +} + +function CartLine({id, merchandise, quantity}) { + const [image] = flattenConnection(merchandise.product.images); + const {cartId} = useSession(); + return ( +
+ {image?.thumb ? : null} +

{merchandise.product.title}

+ {merchandise.title} + +
+ {/* hidden info fields needed by the cart api */} + + + + +
+ + +
+
+
+ ); +} diff --git a/examples/cart-server-session/src/components/Header.server.jsx b/examples/cart-server-session/src/components/Header.server.jsx new file mode 100644 index 0000000000..621667b526 --- /dev/null +++ b/examples/cart-server-session/src/components/Header.server.jsx @@ -0,0 +1,27 @@ +import {SidebarToggle} from '~/components/SidebarToggle.server'; +import {CartCount} from '~/components/CartCount.server'; + +export function Header({children, toggleSidebar}) { + return ( +
+
+ + + Cart Session + Server Demo + + + + {/* Sidebar will render here */} + {children} +
+
+
+ ); +} diff --git a/examples/cart-server-session/src/components/ProductItem.server.jsx b/examples/cart-server-session/src/components/ProductItem.server.jsx new file mode 100644 index 0000000000..0f13f87704 --- /dev/null +++ b/examples/cart-server-session/src/components/ProductItem.server.jsx @@ -0,0 +1,52 @@ +import {useSession} from '@shopify/hydrogen'; + +export function ProductItem({id, title, image, variant}) { + const {cartId} = useSession(); + + return ( +
+ {/* hidden info fields needed by the cart api */} + + + + + + +
+ {/* + if no cart is available, we create it with the line item, + else we update it with the new item + */} + + + +
+
+ ); +} diff --git a/examples/cart-server-session/src/components/ProductsList.server.jsx b/examples/cart-server-session/src/components/ProductsList.server.jsx new file mode 100644 index 0000000000..1fb700dbce --- /dev/null +++ b/examples/cart-server-session/src/components/ProductsList.server.jsx @@ -0,0 +1,73 @@ +import { + CacheNone, + flattenConnection, + gql, + useShopQuery, +} from '@shopify/hydrogen'; + +import {ProductItem} from './ProductItem.server'; + +export function ProductsList() { + const {data} = useShopQuery({ + query: GET_PRODUCTS_QUERY, + cache: CacheNone(), + preload: '*', + }); + + const products = flattenConnection(data?.products).map((product) => ({ + ...product, + variants: flattenConnection(product.variants), + })); + + return ( +
+ {(products || []).map(({id, ...product}) => { + return ( + + ); + })} +
+ ); +} + +const GET_PRODUCTS_QUERY = gql` + query GetProducts { + products(first: 20) { + edges { + node { + id + title + image: featuredImage { + src + thumb: url(transform: {maxWidth: 100}) + id + height + width + } + variants(first: 1) { + edges { + node { + availableForSale + id + title + } + } + } + } + } + } + } +`; diff --git a/examples/cart-server-session/src/components/Sidebar.server.jsx b/examples/cart-server-session/src/components/Sidebar.server.jsx new file mode 100644 index 0000000000..bc1594585d --- /dev/null +++ b/examples/cart-server-session/src/components/Sidebar.server.jsx @@ -0,0 +1,34 @@ +import {CartCount} from '~/components/CartCount.server'; +import {SidebarToggle} from '~/components/SidebarToggle.server'; + +export function Sidebar({children}) { + return ( + + ); +} diff --git a/examples/cart-server-session/src/components/SidebarToggle.server.jsx b/examples/cart-server-session/src/components/SidebarToggle.server.jsx new file mode 100644 index 0000000000..5f0b07ff65 --- /dev/null +++ b/examples/cart-server-session/src/components/SidebarToggle.server.jsx @@ -0,0 +1,37 @@ +export function SidebarToggle({open = false, color = 'rgb(0, 0, 0)'}) { + return ( + <> + + + + ); +} diff --git a/examples/cart-server-session/src/graphql/index.js b/examples/cart-server-session/src/graphql/index.js new file mode 100644 index 0000000000..5eefadd227 --- /dev/null +++ b/examples/cart-server-session/src/graphql/index.js @@ -0,0 +1,93 @@ +import {gql} from '@shopify/hydrogen'; + +const CART_FRAGMENT = gql` + fragment CartFragment on Cart { + id + createdAt + updatedAt + lines(first: 10) { + edges { + node { + id + quantity + merchandise { + ... on ProductVariant { + title + id + product { + title + images(first: 1) { + edges { + node { + src + thumb: url(transform: {maxWidth: 100}) + id + height + width + } + } + } + } + } + } + } + } + } + } +`; + +/** + * Queries + */ +export const CART_GET_QUERY = gql` + ${CART_FRAGMENT} + query getCart($id: ID!) { + cart(id: $id) { + ...CartFragment + } + } +`; + +/** + * Mutations + */ +export const CART_LINES_REMOVE_MUTATION = gql` + ${CART_FRAGMENT} + mutation cartLinesRemove($cartId: ID!, $lineIds: [ID!]!) { + query: cartLinesRemove(cartId: $cartId, lineIds: $lineIds) { + cart { + ...CartFragment + } + userErrors { + field + message + } + } + } +`; + +export const CART_LINES_ADD_MUTATION = gql` + ${CART_FRAGMENT} + mutation cartLinesAdd($cartId: ID!, $lines: [CartLineInput!]!) { + query: cartLinesAdd(cartId: $cartId, lines: $lines) { + cart { + ...CartFragment + } + userErrors { + field + message + } + } + } +`; + +export const CART_CREATE_MUTATION = gql` + ${CART_FRAGMENT} + mutation createCart($input: CartInput!) { + query: cartCreate(input: $input) { + cart { + ...CartFragment + } + } + } +`; diff --git a/examples/cart-server-session/src/index.css b/examples/cart-server-session/src/index.css new file mode 100644 index 0000000000..961b6edd33 --- /dev/null +++ b/examples/cart-server-session/src/index.css @@ -0,0 +1,26 @@ +input#header__checkbox { + display: none; +} + +#header__toggle { + cursor: pointer; +} + +/* open sidebar when SidebarToggle is clicked */ +#header__checkbox:checked ~ aside { + transform: translateX(0) !important; +} + +#header__toggle > #header__toggle__icon > rect { + transform-origin: center center; + transition: transform 300ms ease 0s; +} + +/* animate icon */ +#header__checkbox:checked ~ #header__toggle > #header__toggle__icon > #header__toggle__icon__top { + transform: rotate(-45deg) translate3d(0px, 6.5px, 0px); +} + +#header__checkbox:checked ~ #header__toggle > #header__toggle__icon > #header__toggle__icon__bottom { + transform: rotate(45deg) translate3d(0px, -6.5px, 0px); +} diff --git a/examples/cart-server-session/src/routes/api/cart/[action].server.jsx b/examples/cart-server-session/src/routes/api/cart/[action].server.jsx new file mode 100644 index 0000000000..184899e846 --- /dev/null +++ b/examples/cart-server-session/src/routes/api/cart/[action].server.jsx @@ -0,0 +1,115 @@ +import {createCart, cartLinesAdd, cartLinesRemove, getCart} from '~/utils/cart'; + +export async function api(request, {session, queryShop, params, ...props}) { + try { + let body = {}; + const type = request.headers.get('content-type'); + const referer = new URL(request.headers.get('referer')).origin; + const isFormRequest = type.includes('form-urlencoded'); + + switch (request.method) { + case 'GET': + return new Response('Pong', {status: 200}); + + case 'POST': { + if (!isFormRequest) { + return RedirectToOrigin(referer); + } + + const formData = await request.formData(); + for (const entry of formData.entries()) { + body[entry[0]] = entry[1]; + } + + let cart = null; + + // execute the correct POST action handler + switch (params.action) { + case 'get': { + cart = await getCart({ + id: body.cartId, + }); + break; + } + + case 'create': { + cart = await createCart({ + lines: [ + { + merchandiseId: body.merchandiseId, + quantity: JSON.parse(body.quantity), + }, + ], + }); + console.log('created cart', cart); + + break; + } + + case 'linesAdd': { + cart = await cartLinesAdd({ + cartId: body.cartId, + lines: [ + { + merchandiseId: body.merchandiseId, + quantity: JSON.parse(body.quantity), + }, + ], + }); + // await sleep(2000); + break; + } + + case 'linesRemove': { + cart = await cartLinesRemove({ + cartId: body.cartId, + lineIds: JSON.parse(body.lineIds), + }); + break; + } + + default: + console.log('Unsupported cart action provided', params.action); + return RedirectToOrigin(referer); + } + + const cartCount = (cart?.lines || []).reduce((acc, line) => { + return acc + line.quantity; + }, 0); + + console.log('\n\n\n\ncartCount', cartCount); + + // save cart to session + await session.set('cartId', cart.id); + await session.set('cartCount', cartCount); + + return RedirectToOrigin({referer, toggleSidebar: body.toggleSidebar}); + } + + default: + console.log('Unsupported request method', request.method); + return new Response('Error', { + status: 404, + }); + } + } catch (error) { + console.log('cart:api error', error.message); + return new Response(error.message, { + status: 400, + }); + } +} + +function RedirectToOrigin( + {referer, toggleSidebar} = {referer: '/', toggleSidebar: 'off'} +) { + return new Response(null, { + status: 303, + statusText: 'OK', + headers: { + Location: `${referer}${ + toggleSidebar === 'on' ? '?toggleSidebar=true' : '' + }`, + }, + }); +} diff --git a/examples/cart-server-session/src/routes/index.server.jsx b/examples/cart-server-session/src/routes/index.server.jsx new file mode 100644 index 0000000000..32a06f7bbc --- /dev/null +++ b/examples/cart-server-session/src/routes/index.server.jsx @@ -0,0 +1,16 @@ +import {ProductsList} from '~/components/ProductsList.server'; +import {CacheNone, CacheLong} from '@shopify/hydrogen'; +import {Suspense} from 'react'; + +export default function Home({request, response, ...props}) { + response.cache(CacheLong()); + return ( +
+

Products

+
+ + + +
+ ); +} diff --git a/examples/cart-server-session/src/utils/cart.js b/examples/cart-server-session/src/utils/cart.js new file mode 100644 index 0000000000..98222dd268 --- /dev/null +++ b/examples/cart-server-session/src/utils/cart.js @@ -0,0 +1,141 @@ +import {gql} from '@shopify/hydrogen'; +import {flattenConnection} from '@shopify/hydrogen'; +import { + CART_GET_QUERY, + CART_CREATE_MUTATION, + CART_LINES_ADD_MUTATION, + CART_LINES_REMOVE_MUTATION, +} from '~/graphql'; + +export async function getCart({id}) { + console.log('getCart:getting cart with id', id); + try { + const result = await graphqlClient({ + query: CART_GET_QUERY, + variables: {id}, + }); + + const {data, errors} = result; + + if (errors?.length) { + console.log('get:Fetching existing cart error', errors); + throw new Error(errors[0].message); + } + + return { + ...data.cart, + lines: flattenConnection(data.cart.lines), + }; + } catch (error) { + console.log('error:get:Fetching existing cart error', error); + return null; + } +} + +export async function createCart(input) { + console.log('createCart:creating a cart with', input); + const {data, errors} = await graphqlClient({ + query: CART_CREATE_MUTATION, + variables: {input}, + }); + + if (errors) { + console.log('createCart:new cart error', errors); + throw new Error(errors[0].message); + } + + return { + ...data.query.cart, + lines: flattenConnection(data.query.cart.lines), + }; +} + +export async function cartLinesAdd(input) { + console.log('cartLinesAdd:Adding lines to the cart', input); + const {data, errors} = await graphqlClient({ + query: CART_LINES_ADD_MUTATION, + variables: input, + }); + + if (errors) { + console.log('cartLinesAdd: add lines error', errors); + throw new Error(errors[0].message); + } + + return { + ...data.query.cart, + lines: flattenConnection(data.query.cart.lines), + }; +} + +export async function cartLinesRemove(input) { + console.log('cartLinesRemove:Removing lines from the cart', input); + const {data, errors} = await graphqlClient({ + query: CART_LINES_REMOVE_MUTATION, + variables: input, + }); + + if (errors) { + console.log('cartLinesRemove:Removing lines error', errors); + throw new Error(errors[0].message); + } + + return { + ...data.query.cart, + lines: flattenConnection(data.query.cart.lines), + }; +} + +/** + * A basic Admin API fetch-based client. + * @param {gql} query - GraphQL query + * @param {object} variables - GraphQL variables + * @returns {object} - {error: [], data: object} + */ +async function graphqlClient( + {query, variables} = {id: '', query: null, variables: {}} +) { + if (!query) { + throw new Error('Must provide a `query` to the admin client'); + } + + const endpoint = `https://hydrogen-preview.myshopify.com/api/2022-07/graphql.json`; + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Shopify-Storefront-Access-Token': '3b580e70970c4528da70c98e097c2fa0', + }, + body: JSON.stringify({ + query, + variables, + }), + }; + + const request = await fetch(endpoint, options); + + if (!request.ok) { + throw new Error( + `graphql api request not ok ${request.status} ${request.statusText}` + ); + } + + try { + const response = await request.json(); + + if (response?.errors?.length) { + throw new Error(response.errors[0].message); + } + + return { + error: null, + data: response.data, + }; + } catch (error) { + console.log('graphqlClient error', error); + return { + error: error.message, + data: null, + }; + } +} diff --git a/examples/cart-server-session/src/utils/suspendedFn.js b/examples/cart-server-session/src/utils/suspendedFn.js new file mode 100644 index 0000000000..bde329cc13 --- /dev/null +++ b/examples/cart-server-session/src/utils/suspendedFn.js @@ -0,0 +1,27 @@ +import {wrapPromise} from '~/utils/wrapPromise'; + +/* + Makes any async function Suspense friendly on .server components. +*/ +export function suspendedFn(fn) { + let state = { + status: 'pending', + response: undefined, + }; + + return (props) => { + const promise = wrapPromise(fn(props), state); + try { + const p = promise.read(); + // reset state on success + state = { + status: 'pending', + response: undefined, + }; + return p; + } catch (e) { + // loop until promise is resolved + throw e; + } + }; +} diff --git a/examples/cart-server-session/src/utils/wrapPromise.js b/examples/cart-server-session/src/utils/wrapPromise.js new file mode 100644 index 0000000000..3d66550d86 --- /dev/null +++ b/examples/cart-server-session/src/utils/wrapPromise.js @@ -0,0 +1,27 @@ +export function wrapPromise(promise, state) { + const suspender = promise.then( + (res) => { + state.status = 'success'; + state.response = res; + }, + (err) => { + state.status = 'error'; + state.response = err; + } + ); + + function read() { + console.log('state.status', state.status); + switch (state.status) { + case 'pending': + throw suspender; + case 'error': + throw state.response; + + default: + return state.response; + } + } + + return {read}; +} diff --git a/examples/cart-server-session/vite.config.js b/examples/cart-server-session/vite.config.js new file mode 100644 index 0000000000..fb573d7869 --- /dev/null +++ b/examples/cart-server-session/vite.config.js @@ -0,0 +1,14 @@ +import {defineConfig} from 'vite'; +import hydrogen from '@shopify/hydrogen/plugin'; + +export default defineConfig({ + plugins: [hydrogen()], + resolve: { + alias: [ + { + find: /^~\/(.*)/, + replacement: '/src/$1', + }, + ], + }, +}); From 416b77920d181b51ee51e79afff48154346e1b5c Mon Sep 17 00:00:00 2001 From: "Juan P. Prieto" Date: Tue, 12 Jul 2022 16:55:18 -0700 Subject: [PATCH 2/6] Add additional comments --- examples/cart-server-session/README.md | 6 ++++++ examples/cart-server-session/hydrogen.config.js | 4 ++-- .../src/components/AsyncGetCartLines.server.jsx | 1 + 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/examples/cart-server-session/README.md b/examples/cart-server-session/README.md index 1a5c65bde8..35ee344da2 100644 --- a/examples/cart-server-session/README.md +++ b/examples/cart-server-session/README.md @@ -7,6 +7,12 @@ An example demonstrating a .server driven basic Cart logic. - Add `cartId` and `cartCount` to the session as cookies so they can be used during .server - Uses form actions for all interactions with the API. e.g — AddToCart, remove.. +# .env + +Please add these two your .env +SHOPIFY_STORE_DOMAIN=... +SHOPIFY_STOREFRONT_API_PUBLIC_TOKEN=... + # Hydrogen Hydrogen is a React framework and SDK that you can use to build fast and dynamic Shopify custom storefronts. diff --git a/examples/cart-server-session/hydrogen.config.js b/examples/cart-server-session/hydrogen.config.js index eb1e35f945..c641e6ba3b 100644 --- a/examples/cart-server-session/hydrogen.config.js +++ b/examples/cart-server-session/hydrogen.config.js @@ -8,8 +8,8 @@ export default defineConfig({ shopify: () => ({ defaultLanguageCode: 'EN', defaultCountryCode: 'GB', - storeDomain: 'hydrogen-preview.myshopify.com', - storefrontToken: '3b580e70970c4528da70c98e097c2fa0', + storeDomain: Oxygen.env.SHOPIFY_STORE_DOMAIN, + storefrontToken: Oxygen.env.SHOPIFY_STOREFRONT_API_PUBLIC_TOKEN, storefrontApiVersion: '2022-07', }), session: CookieSessionStorage('__session', { diff --git a/examples/cart-server-session/src/components/AsyncGetCartLines.server.jsx b/examples/cart-server-session/src/components/AsyncGetCartLines.server.jsx index 581d70994f..c453081c0a 100644 --- a/examples/cart-server-session/src/components/AsyncGetCartLines.server.jsx +++ b/examples/cart-server-session/src/components/AsyncGetCartLines.server.jsx @@ -3,6 +3,7 @@ import {getCart} from '~/utils/cart'; import {suspendedFn} from '~/utils/suspendedFn'; import {CartLines} from '~/components/CartLines.server'; +// Create a .server friendly version of an async function. const getCartLinesSync = suspendedFn(getCart); export function AsyncGetCartLines() { From 77d1b8131de3ad0a10e2acf509ea60c49a9ae3b6 Mon Sep 17 00:00:00 2001 From: "Juan P. Prieto" Date: Tue, 12 Jul 2022 17:01:17 -0700 Subject: [PATCH 3/6] cleanup --- examples/cart-server-session/README.md | 6 -- .../cart-server-session/hydrogen.config.js | 4 +- .../cart-server-session/src/utils/cart.js | 61 +------------------ .../src/utils/graphqlClient.js | 57 +++++++++++++++++ 4 files changed, 61 insertions(+), 67 deletions(-) create mode 100644 examples/cart-server-session/src/utils/graphqlClient.js diff --git a/examples/cart-server-session/README.md b/examples/cart-server-session/README.md index 35ee344da2..1a5c65bde8 100644 --- a/examples/cart-server-session/README.md +++ b/examples/cart-server-session/README.md @@ -7,12 +7,6 @@ An example demonstrating a .server driven basic Cart logic. - Add `cartId` and `cartCount` to the session as cookies so they can be used during .server - Uses form actions for all interactions with the API. e.g — AddToCart, remove.. -# .env - -Please add these two your .env -SHOPIFY_STORE_DOMAIN=... -SHOPIFY_STOREFRONT_API_PUBLIC_TOKEN=... - # Hydrogen Hydrogen is a React framework and SDK that you can use to build fast and dynamic Shopify custom storefronts. diff --git a/examples/cart-server-session/hydrogen.config.js b/examples/cart-server-session/hydrogen.config.js index c641e6ba3b..eb1e35f945 100644 --- a/examples/cart-server-session/hydrogen.config.js +++ b/examples/cart-server-session/hydrogen.config.js @@ -8,8 +8,8 @@ export default defineConfig({ shopify: () => ({ defaultLanguageCode: 'EN', defaultCountryCode: 'GB', - storeDomain: Oxygen.env.SHOPIFY_STORE_DOMAIN, - storefrontToken: Oxygen.env.SHOPIFY_STOREFRONT_API_PUBLIC_TOKEN, + storeDomain: 'hydrogen-preview.myshopify.com', + storefrontToken: '3b580e70970c4528da70c98e097c2fa0', storefrontApiVersion: '2022-07', }), session: CookieSessionStorage('__session', { diff --git a/examples/cart-server-session/src/utils/cart.js b/examples/cart-server-session/src/utils/cart.js index 98222dd268..78c585d448 100644 --- a/examples/cart-server-session/src/utils/cart.js +++ b/examples/cart-server-session/src/utils/cart.js @@ -1,5 +1,6 @@ -import {gql} from '@shopify/hydrogen'; import {flattenConnection} from '@shopify/hydrogen'; +import {graphqlClient} from '~/utils/graphqlClient'; + import { CART_GET_QUERY, CART_CREATE_MUTATION, @@ -8,7 +9,6 @@ import { } from '~/graphql'; export async function getCart({id}) { - console.log('getCart:getting cart with id', id); try { const result = await graphqlClient({ query: CART_GET_QUERY, @@ -33,7 +33,6 @@ export async function getCart({id}) { } export async function createCart(input) { - console.log('createCart:creating a cart with', input); const {data, errors} = await graphqlClient({ query: CART_CREATE_MUTATION, variables: {input}, @@ -51,7 +50,6 @@ export async function createCart(input) { } export async function cartLinesAdd(input) { - console.log('cartLinesAdd:Adding lines to the cart', input); const {data, errors} = await graphqlClient({ query: CART_LINES_ADD_MUTATION, variables: input, @@ -69,7 +67,6 @@ export async function cartLinesAdd(input) { } export async function cartLinesRemove(input) { - console.log('cartLinesRemove:Removing lines from the cart', input); const {data, errors} = await graphqlClient({ query: CART_LINES_REMOVE_MUTATION, variables: input, @@ -85,57 +82,3 @@ export async function cartLinesRemove(input) { lines: flattenConnection(data.query.cart.lines), }; } - -/** - * A basic Admin API fetch-based client. - * @param {gql} query - GraphQL query - * @param {object} variables - GraphQL variables - * @returns {object} - {error: [], data: object} - */ -async function graphqlClient( - {query, variables} = {id: '', query: null, variables: {}} -) { - if (!query) { - throw new Error('Must provide a `query` to the admin client'); - } - - const endpoint = `https://hydrogen-preview.myshopify.com/api/2022-07/graphql.json`; - const options = { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Shopify-Storefront-Access-Token': '3b580e70970c4528da70c98e097c2fa0', - }, - body: JSON.stringify({ - query, - variables, - }), - }; - - const request = await fetch(endpoint, options); - - if (!request.ok) { - throw new Error( - `graphql api request not ok ${request.status} ${request.statusText}` - ); - } - - try { - const response = await request.json(); - - if (response?.errors?.length) { - throw new Error(response.errors[0].message); - } - - return { - error: null, - data: response.data, - }; - } catch (error) { - console.log('graphqlClient error', error); - return { - error: error.message, - data: null, - }; - } -} diff --git a/examples/cart-server-session/src/utils/graphqlClient.js b/examples/cart-server-session/src/utils/graphqlClient.js new file mode 100644 index 0000000000..86f1fcf43a --- /dev/null +++ b/examples/cart-server-session/src/utils/graphqlClient.js @@ -0,0 +1,57 @@ +import config from '../../hydrogen.config'; + +const {shopify} = config; + +/** + * A basic Admin API fetch-based client. + * @param {gql} query - GraphQL query + * @param {object} variables - GraphQL variables + * @returns {object} - {error: [], data: object} + */ +export async function graphqlClient( + {query, variables} = {id: '', query: null, variables: {}} +) { + if (!query) { + throw new Error('Must provide a `query` to the admin client'); + } + + const endpoint = `https://${shopify().storeDomain}/api/2022-07/graphql.json`; + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Shopify-Storefront-Access-Token': shopify().storefrontToken, + }, + body: JSON.stringify({ + query, + variables, + }), + }; + + const request = await fetch(endpoint, options); + + if (!request.ok) { + throw new Error( + `graphql api request not ok ${request.status} ${request.statusText}` + ); + } + + try { + const response = await request.json(); + + if (response?.errors?.length) { + throw new Error(response.errors[0].message); + } + + return { + error: null, + data: response.data, + }; + } catch (error) { + console.log('graphqlClient error', error); + return { + error: error.message, + data: null, + }; + } +} From 78e1946761a3efd1c051f59bcdf958759278ac15 Mon Sep 17 00:00:00 2001 From: "Juan P. Prieto" Date: Tue, 12 Jul 2022 17:07:03 -0700 Subject: [PATCH 4/6] update README --- examples/cart-server-session/README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/examples/cart-server-session/README.md b/examples/cart-server-session/README.md index 1a5c65bde8..31a74cc1bf 100644 --- a/examples/cart-server-session/README.md +++ b/examples/cart-server-session/README.md @@ -1,11 +1,16 @@ # RSC/SSR Hydrogen Cart with session -An example demonstrating a .server driven basic Cart logic. +A hydrogen example demonstrating a 💯 `.server-driven` basic Cart workflow. -- Provides a set of cart utilities and cart client -- Provides a `/api/cart/[action]` to handle POST requests from the different `get`, `create`, `add` and `remove` form actions -- Add `cartId` and `cartCount` to the session as cookies so they can be used during .server +- Provides a set of `cart` utilities (`getCart`, `createCart`...) as well as a GraphQL cart client +- Provides a `/api/cart/[action]` that handle POST requests from the different `get`, `create`, `add` and `remove` form actions. +- Persists `cartId` and `cartCount` to the session (cookie), so they can be used used during the `.server` lifecycle - Uses form actions for all interactions with the API. e.g — AddToCart, remove.. +- Provides `suspendedFn` a utility to enable `` friendly async API calls from `.server` components + +### Additional context + +[Oxygen Demo](https://hello-server-cart-596cadc157f9402f1570.o2.myshopify.dev/) # Hydrogen From b379eb27ff6ee590a1a8c32e9529ef38bb51471a Mon Sep 17 00:00:00 2001 From: "Juan P. Prieto" Date: Tue, 12 Jul 2022 17:43:38 -0700 Subject: [PATCH 5/6] update package.json --- examples/cart-server-session/package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/cart-server-session/package.json b/examples/cart-server-session/package.json index b761fce8ac..ab09b5deb4 100644 --- a/examples/cart-server-session/package.json +++ b/examples/cart-server-session/package.json @@ -1,6 +1,6 @@ { "name": "hello-cart-session", - "description": "An example using JavaScript in Hydrogen", + "description": "A hydrogen example demonstrating a .server-driven basic Cart workflow.", "version": "0.0.0", "license": "MIT", "private": true, @@ -10,12 +10,12 @@ "preview": "shopify hydrogen preview" }, "devDependencies": { - "@shopify/cli": "3.0.27", - "@shopify/cli-hydrogen": "3.0.27", + "@shopify/cli": "latest", + "@shopify/cli-hydrogen": "latest", "vite": "^2.9.0" }, "dependencies": { - "@shopify/hydrogen": "^1.0.2", + "@shopify/hydrogen": "latest", "react": "^18.2.0", "react-dom": "^18.2.0" }, From dd908a337ac1080af252d274518d4efc6cfb9a7c Mon Sep 17 00:00:00 2001 From: "Juan P. Prieto" Date: Tue, 12 Jul 2022 18:29:33 -0700 Subject: [PATCH 6/6] implement AddToCart --- .../src/components/AddToCart.server.jsx | 61 +++++++++++-------- .../src/components/ProductItem.server.jsx | 50 ++------------- 2 files changed, 42 insertions(+), 69 deletions(-) diff --git a/examples/cart-server-session/src/components/AddToCart.server.jsx b/examples/cart-server-session/src/components/AddToCart.server.jsx index eebff2f99e..2910cfa98e 100644 --- a/examples/cart-server-session/src/components/AddToCart.server.jsx +++ b/examples/cart-server-session/src/components/AddToCart.server.jsx @@ -1,36 +1,49 @@ -export function AddToCart({cartId}) { +import {useSession} from '@shopify/hydrogen'; + +export function AddToCart({id, variant}) { + const {cartId} = useSession(); return ( -
- - + + {/* hidden info fields needed by the cart api */} + + - {/* add lines or create a cart with lines */} - {cartId ? ( - <> - - - - ) : ( - - )} + + +
); } diff --git a/examples/cart-server-session/src/components/ProductItem.server.jsx b/examples/cart-server-session/src/components/ProductItem.server.jsx index 0f13f87704..9b746ab71a 100644 --- a/examples/cart-server-session/src/components/ProductItem.server.jsx +++ b/examples/cart-server-session/src/components/ProductItem.server.jsx @@ -1,52 +1,12 @@ -import {useSession} from '@shopify/hydrogen'; +import {AddToCart} from '~/components/AddToCart.server'; export function ProductItem({id, title, image, variant}) { - const {cartId} = useSession(); - return ( -
- {/* hidden info fields needed by the cart api */} - - - - +
- -
- {/* - if no cart is available, we create it with the line item, - else we update it with the new item - */} - +

{title}

- -
- + +
); }