diff --git a/docs/preview/package.json b/docs/preview/package.json index 64dd8d29fd..af09e6e075 100644 --- a/docs/preview/package.json +++ b/docs/preview/package.json @@ -27,6 +27,7 @@ "@types/react-dom": "^18.2.7", "@types/react-syntax-highlighter": "^15.5.7", "eslint": "^8.38.0", + "tailwindcss": "^3.3.0", "typescript": "^5.2.2" }, "engines": { diff --git a/examples/b2b/.gitignore b/examples/b2b/.gitignore deleted file mode 100644 index ad5f2cad45..0000000000 --- a/examples/b2b/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.shopify diff --git a/examples/b2b/env.d.ts b/examples/b2b/env.d.ts deleted file mode 100644 index b14ff8eaa2..0000000000 --- a/examples/b2b/env.d.ts +++ /dev/null @@ -1,54 +0,0 @@ -/// -/// -/// - -// Enhance TypeScript's built-in typings. -import '@total-typescript/ts-reset'; - -import type { - Storefront, - CustomerAccount, - HydrogenCart, - HydrogenSessionData, -} from '@shopify/hydrogen'; -import type {AppSession} from '~/lib/session'; - -declare global { - /** - * A global `process` object is only available during build to access NODE_ENV. - */ - const process: {env: {NODE_ENV: 'production' | 'development'}}; - - /** - * Declare expected Env parameter in fetch handler. - */ - interface Env { - SESSION_SECRET: string; - PUBLIC_STOREFRONT_API_TOKEN: string; - PRIVATE_STOREFRONT_API_TOKEN: string; - PUBLIC_STORE_DOMAIN: string; - PUBLIC_STOREFRONT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_URL: string; - PUBLIC_CHECKOUT_DOMAIN: string; - } -} - -declare module '@shopify/remix-oxygen' { - /** - * Declare local additions to the Remix loader context. - */ - interface AppLoadContext { - env: Env; - cart: HydrogenCart; - storefront: Storefront; - customerAccount: CustomerAccount; - session: AppSession; - waitUntil: ExecutionContext['waitUntil']; - } - - /** - * Declare local additions to the Remix session data. - */ - interface SessionData extends HydrogenSessionData {} -} diff --git a/examples/b2b/tsconfig.json b/examples/b2b/tsconfig.json index 110d781eea..5b672cc6e1 100644 --- a/examples/b2b/tsconfig.json +++ b/examples/b2b/tsconfig.json @@ -1,6 +1,11 @@ { "extends": "../../templates/skeleton/tsconfig.json", - "include": ["./**/*.d.ts", "./**/*.ts", "./**/*.tsx"], + "include": [ + "./**/*.d.ts", + "./**/*.ts", + "./**/*.tsx", + "../../templates/skeleton/*.d.ts" + ], "compilerOptions": { "baseUrl": ".", "paths": { diff --git a/examples/gtm/env.d.ts b/examples/gtm/env.d.ts deleted file mode 100644 index b14ff8eaa2..0000000000 --- a/examples/gtm/env.d.ts +++ /dev/null @@ -1,54 +0,0 @@ -/// -/// -/// - -// Enhance TypeScript's built-in typings. -import '@total-typescript/ts-reset'; - -import type { - Storefront, - CustomerAccount, - HydrogenCart, - HydrogenSessionData, -} from '@shopify/hydrogen'; -import type {AppSession} from '~/lib/session'; - -declare global { - /** - * A global `process` object is only available during build to access NODE_ENV. - */ - const process: {env: {NODE_ENV: 'production' | 'development'}}; - - /** - * Declare expected Env parameter in fetch handler. - */ - interface Env { - SESSION_SECRET: string; - PUBLIC_STOREFRONT_API_TOKEN: string; - PRIVATE_STOREFRONT_API_TOKEN: string; - PUBLIC_STORE_DOMAIN: string; - PUBLIC_STOREFRONT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_URL: string; - PUBLIC_CHECKOUT_DOMAIN: string; - } -} - -declare module '@shopify/remix-oxygen' { - /** - * Declare local additions to the Remix loader context. - */ - interface AppLoadContext { - env: Env; - cart: HydrogenCart; - storefront: Storefront; - customerAccount: CustomerAccount; - session: AppSession; - waitUntil: ExecutionContext['waitUntil']; - } - - /** - * Declare local additions to the Remix session data. - */ - interface SessionData extends HydrogenSessionData {} -} diff --git a/examples/gtm/tsconfig.json b/examples/gtm/tsconfig.json index 110d781eea..5b672cc6e1 100644 --- a/examples/gtm/tsconfig.json +++ b/examples/gtm/tsconfig.json @@ -1,6 +1,11 @@ { "extends": "../../templates/skeleton/tsconfig.json", - "include": ["./**/*.d.ts", "./**/*.ts", "./**/*.tsx"], + "include": [ + "./**/*.d.ts", + "./**/*.ts", + "./**/*.tsx", + "../../templates/skeleton/*.d.ts" + ], "compilerOptions": { "baseUrl": ".", "paths": { diff --git a/examples/subscriptions/.env.example b/examples/subscriptions/.env.example deleted file mode 100644 index 7d98ff48de..0000000000 --- a/examples/subscriptions/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -PUBLIC_STOREFRONT_ID= -PUBLIC_STORE_DOMAIN= -SESSION_SECRET= -PRIVATE_STOREFRONT_API_TOKEN= diff --git a/examples/subscriptions/.eslintignore b/examples/subscriptions/.eslintignore deleted file mode 100644 index a362bcaa13..0000000000 --- a/examples/subscriptions/.eslintignore +++ /dev/null @@ -1,5 +0,0 @@ -build -node_modules -bin -*.d.ts -dist diff --git a/examples/subscriptions/.eslintrc.js b/examples/subscriptions/.eslintrc.js deleted file mode 100644 index 57a969e3ad..0000000000 --- a/examples/subscriptions/.eslintrc.js +++ /dev/null @@ -1,18 +0,0 @@ -/** - * @type {import("@types/eslint").Linter.BaseConfig} - */ -module.exports = { - extends: [ - '@remix-run/eslint-config', - 'plugin:hydrogen/recommended', - 'plugin:hydrogen/typescript', - ], - rules: { - '@typescript-eslint/ban-ts-comment': 'off', - '@typescript-eslint/naming-convention': 'off', - 'hydrogen/prefer-image-component': 'off', - 'no-useless-escape': 'off', - '@typescript-eslint/no-non-null-asserted-optional-chain': 'off', - 'no-case-declarations': 'off', - }, -}; diff --git a/examples/subscriptions/.gitignore b/examples/subscriptions/.gitignore deleted file mode 100644 index e87116641b..0000000000 --- a/examples/subscriptions/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -node_modules -/.cache -/build -/dist -/public/build -/.mf -.env -.shopify diff --git a/examples/subscriptions/.graphqlrc.yml b/examples/subscriptions/.graphqlrc.yml deleted file mode 100644 index bd38d076bc..0000000000 --- a/examples/subscriptions/.graphqlrc.yml +++ /dev/null @@ -1 +0,0 @@ -schema: node_modules/@shopify/hydrogen-react/storefront.schema.json diff --git a/examples/subscriptions/README.md b/examples/subscriptions/README.md index c258d3a84f..e385234f7d 100644 --- a/examples/subscriptions/README.md +++ b/examples/subscriptions/README.md @@ -10,7 +10,7 @@ This example is connected to the `hydrogen-preview` storefront which contains on To run this example on your own store, you'll need to: -- Install a [subscription app](https://apps.shopify.com/categories/selling-products-purchase-options-subscriptions). +- Install a [subscription app](https://apps.shopify.com/shopify-subscriptions). - Use the subscription app to create a selling plan for a product. ## Install diff --git a/examples/subscriptions/app/assets/favicon.svg b/examples/subscriptions/app/assets/favicon.svg deleted file mode 100644 index f6c649733d..0000000000 --- a/examples/subscriptions/app/assets/favicon.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - - - diff --git a/examples/subscriptions/app/components/Aside.tsx b/examples/subscriptions/app/components/Aside.tsx deleted file mode 100644 index f486f1992e..0000000000 --- a/examples/subscriptions/app/components/Aside.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/** - * A side bar component with Overlay that works without JavaScript. - * @example - * ```jsx - * - * ``` - */ -export function Aside({ - children, - heading, - id = 'aside', -}: { - children?: React.ReactNode; - heading: React.ReactNode; - id?: string; -}) { - return ( - - ); -} - -function CloseAside() { - return ( - /* eslint-disable-next-line jsx-a11y/anchor-is-valid */ - history.go(-1)}> - × - - ); -} diff --git a/examples/subscriptions/app/components/Cart.tsx b/examples/subscriptions/app/components/Cart.tsx index d2f45fb7b5..f2b6bf5abf 100644 --- a/examples/subscriptions/app/components/Cart.tsx +++ b/examples/subscriptions/app/components/Cart.tsx @@ -1,17 +1,25 @@ -import {CartForm, Image, Money} from '@shopify/hydrogen'; +import { + CartForm, + Image, + Money, + useOptimisticCart, + type OptimisticCart, +} from '@shopify/hydrogen'; import type {CartLineUpdateInput} from '@shopify/hydrogen/storefront-api-types'; import {Link} from '@remix-run/react'; import type {CartApiQueryFragment} from 'storefrontapi.generated'; -import {useVariantUrl} from '~/utils'; +import {useVariantUrl} from '~/lib/variants'; -type CartLine = CartApiQueryFragment['lines']['nodes'][0]; +type CartLine = OptimisticCart['lines']['nodes'][0]; type CartMainProps = { cart: CartApiQueryFragment | null; layout: 'page' | 'aside'; }; -export function CartMain({layout, cart}: CartMainProps) { +export function CartMain({layout, cart: originalCart}: CartMainProps) { + const cart = useOptimisticCart(originalCart); + const linesCount = Boolean(cart?.lines?.nodes?.length || 0); const withDiscount = cart && @@ -26,12 +34,18 @@ export function CartMain({layout, cart}: CartMainProps) { ); } -function CartDetails({layout, cart}: CartMainProps) { +function CartDetails({ + layout, + cart, +}: { + cart: OptimisticCart; + layout: 'page' | 'aside'; +}) { const cartHasItems = !!cart && cart.totalQuantity > 0; return (
- + {cartHasItems && ( @@ -47,14 +61,14 @@ function CartLines({ layout, }: { layout: CartMainProps['layout']; - lines: CartApiQueryFragment['lines'] | undefined; + lines: CartLine[]; }) { if (!lines) return null; return (
    - {lines.nodes.map((line) => ( + {lines.map((line) => ( ))}
@@ -69,7 +83,12 @@ function CartLineItem({ layout: CartMainProps['layout']; line: CartLine; }) { + /***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/ const {id, merchandise, sellingPlanAllocation} = line; + /********** EXAMPLE UPDATE END ************/ + /***********************************************/ + const {product, title, image, selectedOptions} = merchandise; const lineItemUrl = useVariantUrl(product.handle, selectedOptions); @@ -99,27 +118,27 @@ function CartLineItem({ >

{product.title} -

    + {/***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/} {/* Optionally render the selling plan name if available */} {sellingPlanAllocation && (
  • {sellingPlanAllocation.sellingPlan.name}
  • )} - {selectedOptions.map( - (option) => - option.value !== 'Default Title' && ( -
  • - - {option.name}: {option.value} - -
  • - ), - )} + {/********** EXAMPLE UPDATE END ************/ + /***********************************************/} + {selectedOptions.map((option) => ( +
  • + + {option.name}: {option.value} + +
  • + ))}
@@ -170,15 +189,21 @@ export function CartSummary({ ); } -function CartLineRemoveButton({lineIds}: {lineIds: string[]}) { +function CartLineRemoveButton({ + lineIds, + disabled, +}: { + lineIds: string[]; + disabled: boolean; +}) { return ( - ); @@ -186,7 +211,7 @@ function CartLineRemoveButton({lineIds}: {lineIds: string[]}) { function CartLineQuantity({line}: {line: CartLine}) { if (!line || typeof line?.quantity === 'undefined') return null; - const {id: lineId, quantity} = line; + const {id: lineId, quantity, isOptimistic} = line; const prevQuantity = Number(Math.max(0, quantity - 1).toFixed(0)); const nextQuantity = Number((quantity + 1).toFixed(0)); @@ -196,7 +221,7 @@ function CartLineQuantity({line}: {line: CartLine}) {   - +
); } @@ -228,7 +254,8 @@ function CartLinePrice({ priceType?: 'regular' | 'compareAt'; [key: string]: any; }) { - if (!line?.cost?.amountPerQuantity || !line?.cost?.totalAmount) return null; + if (!line?.cost?.amountPerQuantity || !line?.cost?.totalAmount) + return
 
; const moneyV2 = priceType === 'regular' @@ -236,7 +263,7 @@ function CartLinePrice({ : line.cost.compareAtAmountPerQuantity; if (moneyV2 == null) { - return null; + return
 
; } return ( @@ -295,9 +322,7 @@ function CartDiscounts({
{codes?.join(', ')}   - +
diff --git a/examples/subscriptions/app/components/Header.tsx b/examples/subscriptions/app/components/Header.tsx deleted file mode 100644 index 578e146df3..0000000000 --- a/examples/subscriptions/app/components/Header.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import {Await, NavLink} from '@remix-run/react'; -import {Suspense} from 'react'; -import type {LayoutProps} from './Layout'; - -type HeaderProps = Pick; - -type Viewport = 'desktop' | 'mobile'; - -export function Header({header, cart}: HeaderProps) { - const {shop} = header; - return ( -
- - {shop.name} - - -
- ); -} - -export function HeaderMenu({viewport}: {viewport: Viewport}) { - const className = `header-menu-${viewport}`; - - function closeAside(event: React.MouseEvent) { - if (viewport === 'mobile') { - event.preventDefault(); - window.location.href = event.currentTarget.href; - } - } - - return ( - - ); -} - -function HeaderCtas({cart}: Pick) { - return ( - - ); -} - -function HeaderMenuMobileToggle() { - return ( - -

-
- ); -} - -function CartBadge({count}: {count: number}) { - return Cart {count}; -} - -function CartToggle({cart}: Pick) { - return ( - }> - - {(cart) => { - if (!cart) return ; - return ; - }} - - - ); -} - -function activeLinkStyle({ - isActive, - isPending, -}: { - isActive: boolean; - isPending: boolean; -}) { - return { - fontWeight: isActive ? 'bold' : undefined, - color: isPending ? 'grey' : 'black', - }; -} diff --git a/examples/subscriptions/app/components/Layout.tsx b/examples/subscriptions/app/components/Layout.tsx deleted file mode 100644 index 81f07215fb..0000000000 --- a/examples/subscriptions/app/components/Layout.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import {Await} from '@remix-run/react'; -import {Suspense} from 'react'; -import type {CartApiQueryFragment, HeaderQuery} from 'storefrontapi.generated'; -import {Aside} from '~/components/Aside'; -import {Header, HeaderMenu} from '~/components/Header'; -import {CartMain} from '~/components/Cart'; - -export type LayoutProps = { - cart: Promise; - children?: React.ReactNode; - header: HeaderQuery; -}; - -export function Layout({cart, children = null, header}: LayoutProps) { - return ( - <> - - -
-
{children}
- - ); -} - -function CartAside({cart}: {cart: LayoutProps['cart']}) { - return ( - - ); -} - -function MobileMenuAside() { - return ( - - ); -} diff --git a/examples/subscriptions/app/components/SellingPlanSelector.tsx b/examples/subscriptions/app/components/SellingPlanSelector.tsx index adbba34abd..18c6739f24 100644 --- a/examples/subscriptions/app/components/SellingPlanSelector.tsx +++ b/examples/subscriptions/app/components/SellingPlanSelector.tsx @@ -43,9 +43,7 @@ export function SellingPlanSelector({ sellingPlanGroups: ProductFragment['sellingPlanGroups']; selectedSellingPlan: SellingPlanFragment | null; paramKey?: string; - children: ({ - sellingPlanGroup, - }: { + children: (params: { sellingPlanGroup: SellingPlanGroup; selectedSellingPlan: SellingPlanFragment | null; }) => React.ReactNode; @@ -71,9 +69,9 @@ export function SellingPlanSelector({ params.set(paramKey, sellingPlan.id); sellingPlan.isSelected = selectedSellingPlan?.id === sellingPlan.id; sellingPlan.url = `${pathname}?${params.toString()}`; - return sellingPlan as SellingPlan; + return sellingPlan; }) - .filter(Boolean); + .filter(Boolean) as SellingPlan[]; sellingPlanGroup.sellingPlans.nodes = sellingPlans; return children({sellingPlanGroup, selectedSellingPlan}); }), diff --git a/examples/subscriptions/app/entry.client.tsx b/examples/subscriptions/app/entry.client.tsx deleted file mode 100644 index ba957c430e..0000000000 --- a/examples/subscriptions/app/entry.client.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import {RemixBrowser} from '@remix-run/react'; -import {startTransition, StrictMode} from 'react'; -import {hydrateRoot} from 'react-dom/client'; - -startTransition(() => { - hydrateRoot( - document, - - - , - ); -}); diff --git a/examples/subscriptions/app/entry.server.tsx b/examples/subscriptions/app/entry.server.tsx deleted file mode 100644 index de368829b7..0000000000 --- a/examples/subscriptions/app/entry.server.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import type {EntryContext, AppLoadContext} from '@shopify/remix-oxygen'; -import {RemixServer} from '@remix-run/react'; -import isbot from 'isbot'; -import {renderToReadableStream} from 'react-dom/server'; -import {createContentSecurityPolicy} from '@shopify/hydrogen'; - -export default async function handleRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext, - context: AppLoadContext, -) { - const {nonce, header, NonceProvider} = createContentSecurityPolicy({ - shop: { - checkoutDomain: context.env.PUBLIC_CHECKOUT_DOMAIN, - storeDomain: context.env.PUBLIC_STORE_DOMAIN, - } - }); - - const body = await renderToReadableStream( - - - , - { - nonce, - signal: request.signal, - onError(error) { - // eslint-disable-next-line no-console - console.error(error); - responseStatusCode = 500; - }, - }, - ); - - if (isbot(request.headers.get('user-agent'))) { - await body.allReady; - } - - responseHeaders.set('Content-Type', 'text/html'); - responseHeaders.set('Content-Security-Policy', header); - - return new Response(body, { - headers: responseHeaders, - status: responseStatusCode, - }); -} diff --git a/examples/subscriptions/app/root.tsx b/examples/subscriptions/app/root.tsx deleted file mode 100644 index df9886015a..0000000000 --- a/examples/subscriptions/app/root.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import {useNonce, getShopAnalytics, Analytics} from '@shopify/hydrogen'; -import { - defer, - type SerializeFrom, - type LoaderFunctionArgs, -} from '@shopify/remix-oxygen'; -import { - Links, - Meta, - Outlet, - Scripts, - LiveReload, - useMatches, - useRouteError, - useLoaderData, - ScrollRestoration, - isRouteErrorResponse, - type ShouldRevalidateFunction, -} from '@remix-run/react'; -import favicon from './assets/favicon.svg'; -import resetStyles from './styles/reset.css'; -import appStyles from './styles/app.css'; -import {Layout} from '~/components/Layout'; -import tailwindCss from './styles/tailwind.css'; - -/** - * This is important to avoid re-fetching root queries on sub-navigations - */ -export const shouldRevalidate: ShouldRevalidateFunction = ({ - formMethod, - currentUrl, - nextUrl, -}) => { - // revalidate when a mutation is performed e.g add to cart, login... - if (formMethod && formMethod !== 'GET') { - return true; - } - - // revalidate when manually revalidating via useRevalidator - if (currentUrl.toString() === nextUrl.toString()) { - return true; - } - - return false; -}; - -export function links() { - return [ - {rel: 'stylesheet', href: tailwindCss}, - {rel: 'stylesheet', href: resetStyles}, - {rel: 'stylesheet', href: appStyles}, - { - rel: 'preconnect', - href: 'https://cdn.shopify.com', - }, - { - rel: 'preconnect', - href: 'https://shop.app', - }, - {rel: 'icon', type: 'image/svg+xml', href: favicon}, - ]; -} - -export const useRootLoaderData = () => { - const [root] = useMatches(); - return root?.data as SerializeFrom; -}; - -export async function loader(args: LoaderFunctionArgs) { - // Start fetching non-critical data without blocking time to first byte - const deferredData = loadDeferredData(args); - - // Await the critical data required to render initial state of the page - const criticalData = await loadCriticalData(args); - - const {storefront, env} = args.context; - - return defer({ - ...deferredData, - ...criticalData, - shop: getShopAnalytics({ - storefront, - publicStorefrontId: env.PUBLIC_STOREFRONT_ID, - }), - consent: { - checkoutDomain: env.PUBLIC_CHECKOUT_DOMAIN, - storefrontAccessToken: env.PUBLIC_STOREFRONT_API_TOKEN, - }, - }); -} - -/** - * Load data necessary for rendering content above the fold. This is the critical data - * needed to render the page. If it's unavailable, the whole page should 400 or 500 error. - */ -async function loadCriticalData({context}: LoaderFunctionArgs) { - const {storefront} = context; - - // await the header query (above the fold) - const [header] = await Promise.all([ - storefront.query(HEADER_QUERY, { - cache: storefront.CacheLong(), - }), - // Add other queries here, so that they are loaded in parallel - ]); - - return { - header, - }; -} - -/** - * Load data for rendering content below the fold. This data is deferred and will be - * fetched after the initial page load. If it's unavailable, the page should still 200. - * Make sure to not throw any errors here, as it will cause the page to 500. - */ -function loadDeferredData({context}: LoaderFunctionArgs) { - // defer the cart query by not awaiting it - const cartPromise = context.cart.get(); - - return {cart: cartPromise}; -} - -export default function App() { - const nonce = useNonce(); - const data = useLoaderData(); - - return ( - - - - - - - - - - - - - - - - - - - ); -} - -export function ErrorBoundary() { - const error = useRouteError(); - const rootData = useRootLoaderData(); - const nonce = useNonce(); - let errorMessage = 'Unknown error'; - let errorStatus = 500; - - if (isRouteErrorResponse(error)) { - errorMessage = error?.data?.message ?? error.data; - errorStatus = error.status; - } else if (error instanceof Error) { - errorMessage = error.message; - } - - return ( - - - - - - - - - -
-

Oops

-

{errorStatus}

- {errorMessage && ( -
-
{errorMessage}
-
- )} -
-
- - - - - - ); -} - -const HEADER_QUERY = `#graphql - fragment Shop on Shop { - id - name - description - primaryDomain { - url - } - brand { - logo { - image { - url - } - } - } - } - query Header { - shop { - ...Shop - } - } -` as const; diff --git a/examples/subscriptions/app/routes/cart.tsx b/examples/subscriptions/app/routes/cart.tsx deleted file mode 100644 index be481db45a..0000000000 --- a/examples/subscriptions/app/routes/cart.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import {Await, type MetaFunction} from '@remix-run/react'; -import {Suspense} from 'react'; -import type {CartQueryDataReturn} from '@shopify/hydrogen'; -import {CartForm} from '@shopify/hydrogen'; -import {json, type ActionFunctionArgs} from '@shopify/remix-oxygen'; -import {CartMain} from '~/components/Cart'; -import {useRootLoaderData} from '~/root'; - -export const meta: MetaFunction = () => { - return [{title: `Hydrogen | Cart`}]; -}; - -export async function action({request, context}: ActionFunctionArgs) { - const {cart} = context; - - const formData = await request.formData(); - - const {action, inputs} = CartForm.getFormInput(formData); - - if (!action) { - throw new Error('No action provided'); - } - - let status = 200; - let result: CartQueryDataReturn; - - switch (action) { - case CartForm.ACTIONS.LinesAdd: - result = await cart.addLines(inputs.lines); - break; - case CartForm.ACTIONS.LinesUpdate: - result = await cart.updateLines(inputs.lines); - break; - case CartForm.ACTIONS.LinesRemove: - result = await cart.removeLines(inputs.lineIds); - break; - case CartForm.ACTIONS.DiscountCodesUpdate: { - const formDiscountCode = inputs.discountCode; - - // User inputted discount code - const discountCodes = ( - formDiscountCode ? [formDiscountCode] : [] - ) as string[]; - - // Combine discount codes already applied on cart - discountCodes.push(...inputs.discountCodes); - - result = await cart.updateDiscountCodes(discountCodes); - break; - } - case CartForm.ACTIONS.BuyerIdentityUpdate: { - result = await cart.updateBuyerIdentity({ - ...inputs.buyerIdentity, - }); - break; - } - default: - throw new Error(`${action} cart action is not defined`); - } - - const cartId = result.cart.id; - const headers = cart.setCartId(result.cart.id); - const {cart: cartResult, errors} = result; - - const redirectTo = formData.get('redirectTo') ?? null; - if (typeof redirectTo === 'string') { - status = 303; - headers.set('Location', redirectTo); - } - - return json( - { - cart: cartResult, - errors, - analytics: { - cartId, - }, - }, - {status, headers}, - ); -} - -export default function Cart() { - const rootData = useRootLoaderData(); - const cartPromise = rootData.cart; - - return ( -
-

Cart

- Loading cart ...

}> - An error occurred
} - > - {(cart) => { - return ; - }} - - - - ); -} diff --git a/examples/subscriptions/app/routes/products.$handle.tsx b/examples/subscriptions/app/routes/products.$handle.tsx index de46f50d70..0a938c1001 100644 --- a/examples/subscriptions/app/routes/products.$handle.tsx +++ b/examples/subscriptions/app/routes/products.$handle.tsx @@ -1,5 +1,7 @@ -import {json, redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen'; +import {Suspense} from 'react'; +import {defer, redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen'; import { + Await, Link, useLoaderData, type MetaFunction, @@ -7,20 +9,51 @@ import { } from '@remix-run/react'; import type { ProductFragment, + ProductVariantsQuery, ProductVariantFragment, + /***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/ SellingPlanFragment, + /********** EXAMPLE UPDATE END ************/ + /***********************************************/ } from 'storefrontapi.generated'; -import {Image, Money, CartForm, Analytics} from '@shopify/hydrogen'; +import { + Image, + Money, + VariantSelector, + type VariantOption, + getSelectedProductOptions, + CartForm, + type OptimisticCartLine, + Analytics, + type CartViewPayload, + useAnalytics, +} from '@shopify/hydrogen'; import type { - CartLineInput, + SelectedOption, + /***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/ CurrencyCode, + /********** EXAMPLE UPDATE END ************/ + /***********************************************/ } from '@shopify/hydrogen/storefront-api-types'; - +import {getVariantUrl} from '~/lib/variants'; +import {useAside} from '~/components/Aside'; +/***********************************************/ +/********** EXAMPLE UPDATE STARTS ************/ // 1. Import the SellingPlanSelector component and type import { SellingPlanSelector, type SellingPlanGroup, } from '~/components/SellingPlanSelector'; +import sellingPanStyle from '~/styles/selling-plan.css?url'; +import type {LinksFunction} from '@remix-run/node'; + +export const links: LinksFunction = () => [ + {rel: 'stylesheet', href: sellingPanStyle}, +]; +/********** EXAMPLE UPDATE END ************/ +/***********************************************/ export const meta: MetaFunction = ({data}) => { return [{title: `Hydrogen | ${data?.product.title ?? ''}`}]; @@ -30,56 +63,130 @@ export async function loader({params, request, context}: LoaderFunctionArgs) { const {handle} = params; const {storefront} = context; - // 2. Get the selected selling plan id from the request url - const selectedSellingPlanId = - new URL(request.url).searchParams.get('selling_plan') ?? null; - if (!handle) { throw new Error('Expected product handle to be defined'); } + // await the query for the critical product data const {product} = await storefront.query(PRODUCT_QUERY, { - variables: {handle}, + variables: {handle, selectedOptions: getSelectedProductOptions(request)}, }); if (!product?.id) { throw new Response(null, {status: 404}); } + /***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/ + // 2. Get the selected selling plan id from the request url + const selectedSellingPlanId = + new URL(request.url).searchParams.get('selling_plan') ?? null; // 3. Get the selected selling plan from the product const selectedSellingPlan = product.sellingPlanGroups.nodes?.[0]?.sellingPlans.nodes?.find( - (sellingPlan) => sellingPlan.id === selectedSellingPlanId, + (sellingPlan: SellingPlanFragment) => + sellingPlan.id === selectedSellingPlanId, ) ?? null; - /** - 4. If the product includes selling plans but no selling plan is selected, we - redirect to the first selling plan, so that's is selected by default - **/ + // 4. If the product includes selling plans but no selling plan is selected, + // we redirect to the first selling plan, so that's is selected by default if (product.sellingPlanGroups.nodes?.length && !selectedSellingPlan) { const firstSellingPlanId = product.sellingPlanGroups.nodes[0].sellingPlans.nodes[0].id; return redirect( - `/products/${product.handle}?selling_plan=${firstSellingPlanId}`, + `/products/${product.handle}?${new URLSearchParams({ + selling_plan: firstSellingPlanId, + }).toString()}`, ); } + /********** EXAMPLE UPDATE END ************/ + /***********************************************/ + + const firstVariant = product.variants.nodes[0]; + const firstVariantIsDefault = Boolean( + firstVariant.selectedOptions.find( + (option: SelectedOption) => + option.name === 'Title' && option.value === 'Default Title', + ), + ); - const selectedVariant = product.variants.nodes[0]; + if (firstVariantIsDefault) { + product.selectedVariant = firstVariant; + } else { + // if no selected variant was returned from the selected options, + // we redirect to the first variant's url with it's selected options applied + if (!product.selectedVariant) { + throw redirectToFirstVariant({product, request}); + } + } - // 5. Pass the selectedSellingPlan to the client - return json({product, selectedVariant, selectedSellingPlan}); + // In order to show which variants are available in the UI, we need to query + // all of them. But there might be a *lot*, so instead separate the variants + // into it's own separate query that is deferred. So there's a brief moment + // where variant options might show as available when they're not, but after + // this deffered query resolves, the UI will update. + const variants = storefront.query(VARIANTS_QUERY, { + variables: {handle}, + }); + + return defer({ + product, + variants, + /***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/ + // 5. Pass the selectedSellingPlan to the client + selectedSellingPlan, + /********** EXAMPLE UPDATE END ************/ + /***********************************************/ + }); +} + +function redirectToFirstVariant({ + product, + request, +}: { + product: ProductFragment; + request: Request; +}) { + const url = new URL(request.url); + const firstVariant = product.variants.nodes[0]; + + return redirect( + getVariantUrl({ + pathname: url.pathname, + handle: product.handle, + selectedOptions: firstVariant.selectedOptions, + searchParams: new URLSearchParams(url.search), + }), + { + status: 302, + }, + ); } export default function Product() { - const {product, selectedSellingPlan, selectedVariant} = - useLoaderData(); + const { + product, + variants, + /***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/ + selectedSellingPlan, + /********** EXAMPLE UPDATE END ************/ + /***********************************************/ + } = useLoaderData(); + const {selectedVariant} = product; return (
; + /***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/ selectedSellingPlan: SellingPlanFragment | null; + /********** EXAMPLE UPDATE END ************/ + /***********************************************/ }) { - const {title, descriptionHtml, sellingPlanGroups} = product; + const { + title, + descriptionHtml, + /***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/ + sellingPlanGroups, + /********** EXAMPLE UPDATE END ************/ + /***********************************************/ + } = product; return (

{title}


- + + } + > + + {(data) => ( + + )} + + +

Description @@ -156,23 +311,48 @@ function ProductPrice({ selectedVariant, selectedSellingPlan, }: { - selectedVariant: ProductVariantFragment; + selectedVariant: ProductFragment['selectedVariant']; + /***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/ selectedSellingPlan: SellingPlanFragment | null; + /********** EXAMPLE UPDATE END ************/ + /***********************************************/ }) { + /***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/ + if (selectedSellingPlan) { + return ( + + ); + } + /********** EXAMPLE UPDATE END ************/ + /***********************************************/ + return (

- {selectedSellingPlan ? ( - + {selectedVariant?.compareAtPrice ? ( + <> +

Sale

+
+
+ {selectedVariant ? : null} + + + +
+ ) : ( - + selectedVariant?.price && )}
); } +/***********************************************/ +/********** EXAMPLE UPDATE STARTS ************/ type SellingPlanPrice = { amount: number; currencyCode: CurrencyCode; @@ -186,12 +366,16 @@ function SellingPlanPrice({ selectedVariant, }: { selectedSellingPlan: SellingPlanFragment; - selectedVariant: ProductVariantFragment; + selectedVariant: ProductFragment['selectedVariant']; }) { + if (!selectedVariant) { + return null; + } + const sellingPlanPriceAdjustments = selectedSellingPlan?.priceAdjustments; if (!sellingPlanPriceAdjustments?.length) { - return ; + return selectedVariant ? : null; } const selectedVariantPrice: SellingPlanPrice = { @@ -218,7 +402,7 @@ function SellingPlanPrice({ return { amount: acc.amount * - (1 - adjustment.adjustmentValue.adjustmentPercentage), + (1 - adjustment.adjustmentValue.adjustmentPercentage / 100), currencyCode: acc.currencyCode, }; default: @@ -240,124 +424,172 @@ function SellingPlanPrice({ ); } -/** - Render the price of a product that does not have selling plans -**/ -function ProductVariantPrice({ - selectedVariant, +// Update as you see fit to match your design and requirements +function SellingPlanGroup({ + sellingPlanGroup, }: { - selectedVariant: ProductVariantFragment; + sellingPlanGroup: SellingPlanGroup; }) { - return selectedVariant?.compareAtPrice ? ( - <> -

Sale

-
-
- {selectedVariant ? : null} - - - -
- - ) : ( - selectedVariant?.price && + return ( +
+

+ {sellingPlanGroup.name}: +

+ {sellingPlanGroup.sellingPlans.nodes.map((sellingPlan) => { + return ( + +

+ {sellingPlan.options.map( + (option) => `${option.name} ${option.value}`, + )} +

+ + ); + })} +
); } +/********** EXAMPLE UPDATE END ************/ +/***********************************************/ function ProductForm({ - selectedSellingPlan, + product, selectedVariant, + variants, + selectedSellingPlan, sellingPlanGroups, }: { + product: ProductFragment; + selectedVariant: ProductFragment['selectedVariant']; + variants: Array; + /***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/ selectedSellingPlan: SellingPlanFragment | null; - selectedVariant: ProductVariantFragment; sellingPlanGroups: ProductFragment['sellingPlanGroups']; + /********** EXAMPLE UPDATE END ************/ + /***********************************************/ }) { + const {open} = useAside(); + const {publish, shop, cart, prevCart} = useAnalytics(); + return (
- {/* 4. Add the SellingPlanSelector component inside the ProductForm */} - - {({sellingPlanGroup}) => ( - /* 5. Render the SellingPlanGroup component inside the SellingPlanSelector */ - - )} - + {/***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/} + {sellingPlanGroups.nodes.length > 0 ? ( + <> + {/* 4. Add the SellingPlanSelector component inside the ProductForm */} + + {({sellingPlanGroup}) => ( + /* 5. Render the SellingPlanGroup component inside the SellingPlanSelector */ + + )} + + + ) : ( + + {({option}) => } + + )} + {/********** EXAMPLE UPDATE END ************/ + /***********************************************/}
- - {/* 6. Update the AddToCart button text and pass in the sellingPlanId */} 0 && !selectedSellingPlan) + /********** EXAMPLE UPDATE END ************/ + /***********************************************/ } onClick={() => { - window.location.href = window.location.href + '#cart-aside'; + open('cart'); + publish('cart_viewed', { + cart, + prevCart, + shop, + url: window.location.href || '', + } as CartViewPayload); }} lines={ selectedVariant ? [ { - merchandiseId: selectedVariant?.id, - sellingPlanId: selectedSellingPlan?.id, + merchandiseId: selectedVariant.id, quantity: 1, + selectedVariant, + /***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/ + sellingPlanId: selectedSellingPlan?.id, + /********** EXAMPLE UPDATE END ************/ + /***********************************************/ }, ] : [] } > - {sellingPlanGroups.nodes + {/***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/} + {sellingPlanGroups.nodes.length > 0 ? selectedSellingPlan ? 'Subscribe' : 'Select a subscription' : selectedVariant?.availableForSale ? 'Add to cart' : 'Sold out'} + {/********** EXAMPLE UPDATE END ************/ + /***********************************************/}
); } -// Update as you see fit to match your design and requirements -function SellingPlanGroup({ - sellingPlanGroup, -}: { - sellingPlanGroup: SellingPlanGroup; -}) { +function ProductOptions({option}: {option: VariantOption}) { return ( -
-

- {sellingPlanGroup.name}: -

- {sellingPlanGroup.sellingPlans.nodes.map((sellingPlan) => { - return ( - -

- {sellingPlan.options.map( - (option) => `${option.name} ${option.value}`, - )} -

- - ); - })} +
+
{option.name}
+
+ {option.values.map(({value, isAvailable, isActive, to}) => { + return ( + + {value} + + ); + })} +
+
); } @@ -372,7 +604,7 @@ function AddToCartButton({ analytics?: unknown; children: React.ReactNode; disabled?: boolean; - lines: CartLineInput[]; + lines: Array; onClick?: () => void; }) { return ( @@ -387,7 +619,6 @@ function AddToCartButton({