diff --git a/framework-boilerplates/hydrogen-2/.env.example b/framework-boilerplates/hydrogen-2/.env.example new file mode 100644 index 0000000000..95abc6f3a7 --- /dev/null +++ b/framework-boilerplates/hydrogen-2/.env.example @@ -0,0 +1,4 @@ +# The variables added in this file are only available locally in MiniOxygen + +SESSION_SECRET="foobar" +PUBLIC_STORE_DOMAIN="mock.shop" diff --git a/framework-boilerplates/hydrogen-2/.eslintignore b/framework-boilerplates/hydrogen-2/.eslintignore new file mode 100644 index 0000000000..a362bcaa13 --- /dev/null +++ b/framework-boilerplates/hydrogen-2/.eslintignore @@ -0,0 +1,5 @@ +build +node_modules +bin +*.d.ts +dist diff --git a/framework-boilerplates/hydrogen-2/.eslintrc.js b/framework-boilerplates/hydrogen-2/.eslintrc.js new file mode 100644 index 0000000000..57a969e3ad --- /dev/null +++ b/framework-boilerplates/hydrogen-2/.eslintrc.js @@ -0,0 +1,18 @@ +/** + * @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/framework-boilerplates/hydrogen-2/.gitignore b/framework-boilerplates/hydrogen-2/.gitignore new file mode 100644 index 0000000000..d17ede3d2e --- /dev/null +++ b/framework-boilerplates/hydrogen-2/.gitignore @@ -0,0 +1,9 @@ +node_modules +/.cache +/build +/dist +/public/build +/.mf +.env +.shopify +.vercel diff --git a/framework-boilerplates/hydrogen-2/.graphqlrc.yml b/framework-boilerplates/hydrogen-2/.graphqlrc.yml new file mode 100644 index 0000000000..bd38d076bc --- /dev/null +++ b/framework-boilerplates/hydrogen-2/.graphqlrc.yml @@ -0,0 +1 @@ +schema: node_modules/@shopify/hydrogen-react/storefront.schema.json diff --git a/framework-boilerplates/hydrogen-2/.vercelignore b/framework-boilerplates/hydrogen-2/.vercelignore new file mode 100644 index 0000000000..d49b7fb90e --- /dev/null +++ b/framework-boilerplates/hydrogen-2/.vercelignore @@ -0,0 +1,3 @@ +.cache +dist +.shopify diff --git a/framework-boilerplates/hydrogen-2/README.md b/framework-boilerplates/hydrogen-2/README.md new file mode 100644 index 0000000000..322be46519 --- /dev/null +++ b/framework-boilerplates/hydrogen-2/README.md @@ -0,0 +1,48 @@ +# Hydrogen v2 + +This directory is a brief example of a [Hydrogen v2](https://shopify.dev/custom-storefronts/hydrogen) storefront that can be deployed to Vercel with zero configuration. + +## Deploy Your Own + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/vercel/examples/tree/main/framework-boilerplates/hydrogen-2&template=hydrogen-2) + +_Live Example: https://hydrogen-v2-template.vercel.app_ + +You can also deploy using the [Vercel CLI](https://vercel.com/docs/cli): + +```sh +npm i -g vercel +vercel +``` + +Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen. + +[Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) +[Get familiar with Remix](https://remix.run/docs/en/v1) + +## What's included + +- Remix +- Hydrogen +- Oxygen +- Shopify CLI +- ESLint +- Prettier +- GraphQL generator +- TypeScript and JavaScript flavors +- Minimal setup of components and routes + +## Environment Variables + +Using Hydrogen requires a few [environment variables](https://shopify.dev/docs/custom-storefronts/hydrogen/environment-variables) to be set in order to properly connect to Shopify. For this template, the minimal set of environment variables are defined in the `vercel.json` file, which will be applied to the deployment when deployed to Vercel. However, you should migrate these default environment variables to your Project's Environment Variables configuration in the Vercel dashboard (or using the `vc env` commands), and update them according to your needs (also change the `SESSION_SECRET` to your own value). Once that is done, delete the `vercel.json` file from your project to prevent the environment variables defined there from taking precedence. + +## Local development + +Rename the `.env.example` file to `.env` in order for the Shopify dev server to use those environment variables during local development. If you defined/modified additional environment variables based on the section above, be sure to apply those changes in your `.env` file as well. + +Then run the following commands: + +```bash +npm install +npm run dev +``` diff --git a/framework-boilerplates/hydrogen-2/app/components/Aside.tsx b/framework-boilerplates/hydrogen-2/app/components/Aside.tsx new file mode 100644 index 0000000000..2e4c30a589 --- /dev/null +++ b/framework-boilerplates/hydrogen-2/app/components/Aside.tsx @@ -0,0 +1,47 @@ +/** + * A side bar component with Overlay that works without JavaScript. + * @example + * ```ts + * + * ``` + */ +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/framework-boilerplates/hydrogen-2/app/components/Cart.tsx b/framework-boilerplates/hydrogen-2/app/components/Cart.tsx new file mode 100644 index 0000000000..e5c48cc1fb --- /dev/null +++ b/framework-boilerplates/hydrogen-2/app/components/Cart.tsx @@ -0,0 +1,340 @@ +import {CartForm, Image, Money} 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'; + +type CartLine = CartApiQueryFragment['lines']['nodes'][0]; + +type CartMainProps = { + cart: CartApiQueryFragment | null; + layout: 'page' | 'aside'; +}; + +export function CartMain({layout, cart}: CartMainProps) { + const linesCount = Boolean(cart?.lines?.nodes?.length || 0); + const withDiscount = + cart && + Boolean(cart.discountCodes.filter((code) => code.applicable).length); + const className = `cart-main ${withDiscount ? 'with-discount' : ''}`; + + return ( +
+
+ ); +} + +function CartDetails({layout, cart}: CartMainProps) { + const cartHasItems = !!cart && cart.totalQuantity > 0; + + return ( +
+ + {cartHasItems && ( + + + + + )} +
+ ); +} + +function CartLines({ + lines, + layout, +}: { + layout: CartMainProps['layout']; + lines: CartApiQueryFragment['lines'] | undefined; +}) { + if (!lines) return null; + + return ( +
+ +
+ ); +} + +function CartLineItem({ + layout, + line, +}: { + layout: CartMainProps['layout']; + line: CartLine; +}) { + const {id, merchandise} = line; + const {product, title, image, selectedOptions} = merchandise; + const lineItemUrl = useVariantUrl(product.handle, selectedOptions); + + return ( +
  • + {image && ( + {title} + )} + +
    + { + if (layout === 'aside') { + // close the drawer + window.location.href = lineItemUrl; + } + }} + > +

    + {product.title} +

    + + +
      + {selectedOptions.map((option) => ( +
    • + + {option.name}: {option.value} + +
    • + ))} +
    + +
    +
  • + ); +} + +function CartCheckoutActions({checkoutUrl}: {checkoutUrl: string}) { + if (!checkoutUrl) return null; + + return ( +
    + +

    Continue to Checkout →

    +
    +
    +
    + ); +} + +export function CartSummary({ + cost, + layout, + children = null, +}: { + children?: React.ReactNode; + cost: CartApiQueryFragment['cost']; + layout: CartMainProps['layout']; +}) { + const className = + layout === 'page' ? 'cart-summary-page' : 'cart-summary-aside'; + + return ( +
    +

    Totals

    +
    +
    Subtotal
    +
    + {cost?.subtotalAmount?.amount ? ( + + ) : ( + '-' + )} +
    +
    + {children} +
    + ); +} + +function CartLineRemoveButton({lineIds}: {lineIds: string[]}) { + return ( + + + + ); +} + +function CartLineQuantity({line}: {line: CartLine}) { + if (!line || typeof line?.quantity === 'undefined') return null; + const {id: lineId, quantity} = line; + const prevQuantity = Number(Math.max(0, quantity - 1).toFixed(0)); + const nextQuantity = Number((quantity + 1).toFixed(0)); + + return ( +
    + Quantity: {quantity}    + + + +   + + + +   + +
    + ); +} + +function CartLinePrice({ + line, + priceType = 'regular', + ...passthroughProps +}: { + line: CartLine; + priceType?: 'regular' | 'compareAt'; + [key: string]: any; +}) { + if (!line?.cost?.amountPerQuantity || !line?.cost?.totalAmount) return null; + + const moneyV2 = + priceType === 'regular' + ? line.cost.totalAmount + : line.cost.compareAtAmountPerQuantity; + + if (moneyV2 == null) { + return null; + } + + return ( +
    + +
    + ); +} + +export function CartEmpty({ + hidden = false, + layout = 'aside', +}: { + hidden: boolean; + layout?: CartMainProps['layout']; +}) { + return ( + + ); +} + +function CartDiscounts({ + discountCodes, +}: { + discountCodes: CartApiQueryFragment['discountCodes']; +}) { + const codes: string[] = + discountCodes + ?.filter((discount) => discount.applicable) + ?.map(({code}) => code) || []; + + return ( +
    + {/* Have existing discount, display it with a remove option */} + + + {/* Show an input to apply a discount */} + +
    + +   + +
    +
    +
    + ); +} + +function UpdateDiscountForm({ + discountCodes, + children, +}: { + discountCodes?: string[]; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +function CartLineUpdateButton({ + children, + lines, +}: { + children: React.ReactNode; + lines: CartLineUpdateInput[]; +}) { + return ( + + {children} + + ); +} diff --git a/framework-boilerplates/hydrogen-2/app/components/Footer.tsx b/framework-boilerplates/hydrogen-2/app/components/Footer.tsx new file mode 100644 index 0000000000..b7c243cf4c --- /dev/null +++ b/framework-boilerplates/hydrogen-2/app/components/Footer.tsx @@ -0,0 +1,99 @@ +import {useMatches, NavLink} from '@remix-run/react'; +import type {FooterQuery} from 'storefrontapi.generated'; + +export function Footer({menu}: FooterQuery) { + return ( + + ); +} + +function FooterMenu({menu}: Pick) { + const [root] = useMatches(); + const publicStoreDomain = root?.data?.publicStoreDomain; + return ( + + ); +} + +const FALLBACK_FOOTER_MENU = { + id: 'gid://shopify/Menu/199655620664', + items: [ + { + id: 'gid://shopify/MenuItem/461633060920', + resourceId: 'gid://shopify/ShopPolicy/23358046264', + tags: [], + title: 'Privacy Policy', + type: 'SHOP_POLICY', + url: '/policies/privacy-policy', + items: [], + }, + { + id: 'gid://shopify/MenuItem/461633093688', + resourceId: 'gid://shopify/ShopPolicy/23358013496', + tags: [], + title: 'Refund Policy', + type: 'SHOP_POLICY', + url: '/policies/refund-policy', + items: [], + }, + { + id: 'gid://shopify/MenuItem/461633126456', + resourceId: 'gid://shopify/ShopPolicy/23358111800', + tags: [], + title: 'Shipping Policy', + type: 'SHOP_POLICY', + url: '/policies/shipping-policy', + items: [], + }, + { + id: 'gid://shopify/MenuItem/461633159224', + resourceId: 'gid://shopify/ShopPolicy/23358079032', + tags: [], + title: 'Terms of Service', + type: 'SHOP_POLICY', + url: '/policies/terms-of-service', + items: [], + }, + ], +}; + +function activeLinkStyle({ + isActive, + isPending, +}: { + isActive: boolean; + isPending: boolean; +}) { + return { + fontWeight: isActive ? 'bold' : '', + color: isPending ? 'grey' : 'white', + }; +} diff --git a/framework-boilerplates/hydrogen-2/app/components/Header.tsx b/framework-boilerplates/hydrogen-2/app/components/Header.tsx new file mode 100644 index 0000000000..b818b0efb8 --- /dev/null +++ b/framework-boilerplates/hydrogen-2/app/components/Header.tsx @@ -0,0 +1,178 @@ +import {Await, NavLink, useMatches} from '@remix-run/react'; +import {Suspense} from 'react'; +import type {LayoutProps} from './Layout'; + +type HeaderProps = Pick; + +type Viewport = 'desktop' | 'mobile'; + +export function Header({header, isLoggedIn, cart}: HeaderProps) { + const {shop, menu} = header; + return ( +
    + + {shop.name} + + + +
    + ); +} + +export function HeaderMenu({ + menu, + viewport, +}: { + menu: HeaderProps['header']['menu']; + viewport: Viewport; +}) { + const [root] = useMatches(); + const publicStoreDomain = root?.data?.publicStoreDomain; + const className = `header-menu-${viewport}`; + + function closeAside(event: React.MouseEvent) { + if (viewport === 'mobile') { + event.preventDefault(); + window.location.href = event.currentTarget.href; + } + } + + return ( + + ); +} + +function HeaderCtas({ + isLoggedIn, + cart, +}: Pick) { + return ( + + ); +} + +function HeaderMenuMobileToggle() { + return ( + +

    +
    + ); +} + +function SearchToggle() { + return Search; +} + +function CartBadge({count}: {count: number}) { + return Cart {count}; +} + +function CartToggle({cart}: Pick) { + return ( + }> + + {(cart) => { + if (!cart) return ; + return ; + }} + + + ); +} + +const FALLBACK_HEADER_MENU = { + id: 'gid://shopify/Menu/199655587896', + items: [ + { + id: 'gid://shopify/MenuItem/461609500728', + resourceId: null, + tags: [], + title: 'Collections', + type: 'HTTP', + url: '/collections', + items: [], + }, + { + id: 'gid://shopify/MenuItem/461609533496', + resourceId: null, + tags: [], + title: 'Blog', + type: 'HTTP', + url: '/blogs/journal', + items: [], + }, + { + id: 'gid://shopify/MenuItem/461609566264', + resourceId: null, + tags: [], + title: 'Policies', + type: 'HTTP', + url: '/policies', + items: [], + }, + { + id: 'gid://shopify/MenuItem/461609599032', + resourceId: 'gid://shopify/Page/92591030328', + tags: [], + title: 'About', + type: 'PAGE', + url: '/pages/about', + items: [], + }, + ], +}; + +function activeLinkStyle({ + isActive, + isPending, +}: { + isActive: boolean; + isPending: boolean; +}) { + return { + fontWeight: isActive ? 'bold' : '', + color: isPending ? 'grey' : 'black', + }; +} diff --git a/framework-boilerplates/hydrogen-2/app/components/Layout.tsx b/framework-boilerplates/hydrogen-2/app/components/Layout.tsx new file mode 100644 index 0000000000..a4f7cb5d9f --- /dev/null +++ b/framework-boilerplates/hydrogen-2/app/components/Layout.tsx @@ -0,0 +1,95 @@ +import {Await} from '@remix-run/react'; +import {Suspense} from 'react'; +import type { + CartApiQueryFragment, + FooterQuery, + HeaderQuery, +} from 'storefrontapi.generated'; +import {Aside} from '~/components/Aside'; +import {Footer} from '~/components/Footer'; +import {Header, HeaderMenu} from '~/components/Header'; +import {CartMain} from '~/components/Cart'; +import { + PredictiveSearchForm, + PredictiveSearchResults, +} from '~/components/Search'; + +export type LayoutProps = { + cart: Promise; + children?: React.ReactNode; + footer: Promise; + header: HeaderQuery; + isLoggedIn: boolean; +}; + +export function Layout({ + cart, + children = null, + footer, + header, + isLoggedIn, +}: LayoutProps) { + return ( + <> + + + +
    +
    {children}
    + + + {(footer) =>