mirror of
https://github.com/LukeHagar/vercel.git
synced 2025-12-06 04:22:01 +00:00
[examples] Add "hydrogen-2" template (#10319)
Adds a Hydrogen v2 template which is the output of the `npm create @shopify/hydrogen@latest` command. Note that a `vercel.json` file is being used to define the environment variables that are required at runtime. This is required for the template to deploy with zero configuration, however the user should update these values (including replacing the session secret) and migrate them to the Project settings in the Vercel dashboard. [Live example](https://hydrogen-v2-template.vercel.app)
This commit is contained in:
2
.changeset/strange-gorillas-greet.md
Normal file
2
.changeset/strange-gorillas-greet.md
Normal file
@@ -0,0 +1,2 @@
|
||||
---
|
||||
---
|
||||
5
examples/__tests__/integration/hydrogen-2.test.ts
Normal file
5
examples/__tests__/integration/hydrogen-2.test.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { deployExample } from '../test-utils';
|
||||
it('should deploy', async () => {
|
||||
await deployExample(__filename);
|
||||
});
|
||||
|
||||
4
examples/hydrogen-2/.env.example
Normal file
4
examples/hydrogen-2/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
# The variables added in this file are only available locally in MiniOxygen
|
||||
|
||||
SESSION_SECRET="foobar"
|
||||
PUBLIC_STORE_DOMAIN="mock.shop"
|
||||
5
examples/hydrogen-2/.eslintignore
Normal file
5
examples/hydrogen-2/.eslintignore
Normal file
@@ -0,0 +1,5 @@
|
||||
build
|
||||
node_modules
|
||||
bin
|
||||
*.d.ts
|
||||
dist
|
||||
18
examples/hydrogen-2/.eslintrc.js
Normal file
18
examples/hydrogen-2/.eslintrc.js
Normal file
@@ -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',
|
||||
},
|
||||
};
|
||||
9
examples/hydrogen-2/.gitignore
vendored
Normal file
9
examples/hydrogen-2/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
/.cache
|
||||
/build
|
||||
/dist
|
||||
/public/build
|
||||
/.mf
|
||||
.env
|
||||
.shopify
|
||||
.vercel
|
||||
1
examples/hydrogen-2/.graphqlrc.yml
Normal file
1
examples/hydrogen-2/.graphqlrc.yml
Normal file
@@ -0,0 +1 @@
|
||||
schema: node_modules/@shopify/hydrogen-react/storefront.schema.json
|
||||
3
examples/hydrogen-2/.vercelignore
Normal file
3
examples/hydrogen-2/.vercelignore
Normal file
@@ -0,0 +1,3 @@
|
||||
.cache
|
||||
dist
|
||||
.shopify
|
||||
43
examples/hydrogen-2/README.md
Normal file
43
examples/hydrogen-2/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# 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
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/vercel/vercel/tree/main/examples/hydrogen-2&template=hydrogen-2)
|
||||
|
||||
_Live Example: https://hydrogen-v2-template.vercel.app_
|
||||
|
||||
You can also deploy using the [Vercel CLI](https://vercel.com/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 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).
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
47
examples/hydrogen-2/app/components/Aside.tsx
Normal file
47
examples/hydrogen-2/app/components/Aside.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* A side bar component with Overlay that works without JavaScript.
|
||||
* @example
|
||||
* ```ts
|
||||
* <Aside id="search-aside" heading="SEARCH">`
|
||||
* <input type="search" />
|
||||
* ...
|
||||
* </Aside>
|
||||
* ```
|
||||
*/
|
||||
export function Aside({
|
||||
children,
|
||||
heading,
|
||||
id = 'aside',
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
heading: React.ReactNode;
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<div aria-modal className="overlay" id={id} role="dialog">
|
||||
<button
|
||||
className="close-outside"
|
||||
onClick={() => {
|
||||
history.go(-1);
|
||||
window.location.hash = '';
|
||||
}}
|
||||
/>
|
||||
<aside>
|
||||
<header>
|
||||
<h3>{heading}</h3>
|
||||
<CloseAside />
|
||||
</header>
|
||||
<main>{children}</main>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CloseAside() {
|
||||
return (
|
||||
/* eslint-disable-next-line jsx-a11y/anchor-is-valid */
|
||||
<a className="close" href="#" onChange={() => history.go(-1)}>
|
||||
×
|
||||
</a>
|
||||
);
|
||||
}
|
||||
340
examples/hydrogen-2/app/components/Cart.tsx
Normal file
340
examples/hydrogen-2/app/components/Cart.tsx
Normal file
@@ -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 (
|
||||
<div className={className}>
|
||||
<CartEmpty hidden={linesCount} layout={layout} />
|
||||
<CartDetails cart={cart} layout={layout} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CartDetails({layout, cart}: CartMainProps) {
|
||||
const cartHasItems = !!cart && cart.totalQuantity > 0;
|
||||
|
||||
return (
|
||||
<div className="cart-details">
|
||||
<CartLines lines={cart?.lines} layout={layout} />
|
||||
{cartHasItems && (
|
||||
<CartSummary cost={cart.cost} layout={layout}>
|
||||
<CartDiscounts discountCodes={cart.discountCodes} />
|
||||
<CartCheckoutActions checkoutUrl={cart.checkoutUrl} />
|
||||
</CartSummary>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CartLines({
|
||||
lines,
|
||||
layout,
|
||||
}: {
|
||||
layout: CartMainProps['layout'];
|
||||
lines: CartApiQueryFragment['lines'] | undefined;
|
||||
}) {
|
||||
if (!lines) return null;
|
||||
|
||||
return (
|
||||
<div aria-labelledby="cart-lines">
|
||||
<ul>
|
||||
{lines.nodes.map((line) => (
|
||||
<CartLineItem key={line.id} line={line} layout={layout} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<li key={id} className="cart-line">
|
||||
{image && (
|
||||
<Image
|
||||
alt={title}
|
||||
aspectRatio="1/1"
|
||||
data={image}
|
||||
height={100}
|
||||
loading="lazy"
|
||||
width={100}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Link
|
||||
prefetch="intent"
|
||||
to={lineItemUrl}
|
||||
onClick={() => {
|
||||
if (layout === 'aside') {
|
||||
// close the drawer
|
||||
window.location.href = lineItemUrl;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
<strong>{product.title}</strong>
|
||||
</p>
|
||||
</Link>
|
||||
<CartLinePrice line={line} as="span" />
|
||||
<ul>
|
||||
{selectedOptions.map((option) => (
|
||||
<li key={option.name}>
|
||||
<small>
|
||||
{option.name}: {option.value}
|
||||
</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<CartLineQuantity line={line} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function CartCheckoutActions({checkoutUrl}: {checkoutUrl: string}) {
|
||||
if (!checkoutUrl) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<a href={checkoutUrl} target="_self">
|
||||
<p>Continue to Checkout →</p>
|
||||
</a>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div aria-labelledby="cart-summary" className={className}>
|
||||
<h4>Totals</h4>
|
||||
<dl className="cart-subtotal">
|
||||
<dt>Subtotal</dt>
|
||||
<dd>
|
||||
{cost?.subtotalAmount?.amount ? (
|
||||
<Money data={cost?.subtotalAmount} />
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CartLineRemoveButton({lineIds}: {lineIds: string[]}) {
|
||||
return (
|
||||
<CartForm
|
||||
route="/cart"
|
||||
action={CartForm.ACTIONS.LinesRemove}
|
||||
inputs={{lineIds}}
|
||||
>
|
||||
<button type="submit">Remove</button>
|
||||
</CartForm>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="cart-line-quantiy">
|
||||
<small>Quantity: {quantity} </small>
|
||||
<CartLineUpdateButton lines={[{id: lineId, quantity: prevQuantity}]}>
|
||||
<button
|
||||
aria-label="Decrease quantity"
|
||||
disabled={quantity <= 1}
|
||||
name="decrease-quantity"
|
||||
value={prevQuantity}
|
||||
>
|
||||
<span>− </span>
|
||||
</button>
|
||||
</CartLineUpdateButton>
|
||||
|
||||
<CartLineUpdateButton lines={[{id: lineId, quantity: nextQuantity}]}>
|
||||
<button
|
||||
aria-label="Increase quantity"
|
||||
name="increase-quantity"
|
||||
value={nextQuantity}
|
||||
>
|
||||
<span>+</span>
|
||||
</button>
|
||||
</CartLineUpdateButton>
|
||||
|
||||
<CartLineRemoveButton lineIds={[lineId]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<Money withoutTrailingZeros {...passthroughProps} data={moneyV2} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CartEmpty({
|
||||
hidden = false,
|
||||
layout = 'aside',
|
||||
}: {
|
||||
hidden: boolean;
|
||||
layout?: CartMainProps['layout'];
|
||||
}) {
|
||||
return (
|
||||
<div hidden={hidden}>
|
||||
<br />
|
||||
<p>
|
||||
Looks like you haven’t added anything yet, let’s get you
|
||||
started!
|
||||
</p>
|
||||
<br />
|
||||
<Link
|
||||
to="/collections"
|
||||
onClick={() => {
|
||||
if (layout === 'aside') {
|
||||
window.location.href = '/collections';
|
||||
}
|
||||
}}
|
||||
>
|
||||
Continue shopping →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CartDiscounts({
|
||||
discountCodes,
|
||||
}: {
|
||||
discountCodes: CartApiQueryFragment['discountCodes'];
|
||||
}) {
|
||||
const codes: string[] =
|
||||
discountCodes
|
||||
?.filter((discount) => discount.applicable)
|
||||
?.map(({code}) => code) || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Have existing discount, display it with a remove option */}
|
||||
<dl hidden={!codes.length}>
|
||||
<div>
|
||||
<dt>Discount(s)</dt>
|
||||
<UpdateDiscountForm>
|
||||
<div className="cart-discount">
|
||||
<code>{codes?.join(', ')}</code>
|
||||
|
||||
<button>Remove</button>
|
||||
</div>
|
||||
</UpdateDiscountForm>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{/* Show an input to apply a discount */}
|
||||
<UpdateDiscountForm discountCodes={codes}>
|
||||
<div>
|
||||
<input type="text" name="discountCode" placeholder="Discount code" />
|
||||
|
||||
<button type="submit">Apply</button>
|
||||
</div>
|
||||
</UpdateDiscountForm>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateDiscountForm({
|
||||
discountCodes,
|
||||
children,
|
||||
}: {
|
||||
discountCodes?: string[];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<CartForm
|
||||
route="/cart"
|
||||
action={CartForm.ACTIONS.DiscountCodesUpdate}
|
||||
inputs={{
|
||||
discountCodes: discountCodes || [],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CartForm>
|
||||
);
|
||||
}
|
||||
|
||||
function CartLineUpdateButton({
|
||||
children,
|
||||
lines,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
lines: CartLineUpdateInput[];
|
||||
}) {
|
||||
return (
|
||||
<CartForm
|
||||
route="/cart"
|
||||
action={CartForm.ACTIONS.LinesUpdate}
|
||||
inputs={{lines}}
|
||||
>
|
||||
{children}
|
||||
</CartForm>
|
||||
);
|
||||
}
|
||||
99
examples/hydrogen-2/app/components/Footer.tsx
Normal file
99
examples/hydrogen-2/app/components/Footer.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import {useMatches, NavLink} from '@remix-run/react';
|
||||
import type {FooterQuery} from 'storefrontapi.generated';
|
||||
|
||||
export function Footer({menu}: FooterQuery) {
|
||||
return (
|
||||
<footer className="footer">
|
||||
<FooterMenu menu={menu} />
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterMenu({menu}: Pick<FooterQuery, 'menu'>) {
|
||||
const [root] = useMatches();
|
||||
const publicStoreDomain = root?.data?.publicStoreDomain;
|
||||
return (
|
||||
<nav className="footer-menu" role="navigation">
|
||||
{(menu || FALLBACK_FOOTER_MENU).items.map((item) => {
|
||||
if (!item.url) return null;
|
||||
// if the url is internal, we strip the domain
|
||||
const url =
|
||||
item.url.includes('myshopify.com') ||
|
||||
item.url.includes(publicStoreDomain)
|
||||
? new URL(item.url).pathname
|
||||
: item.url;
|
||||
const isExternal = !url.startsWith('/');
|
||||
return isExternal ? (
|
||||
<a href={url} key={item.id} rel="noopener noreferrer" target="_blank">
|
||||
{item.title}
|
||||
</a>
|
||||
) : (
|
||||
<NavLink
|
||||
end
|
||||
key={item.id}
|
||||
prefetch="intent"
|
||||
style={activeLinkStyle}
|
||||
to={url}
|
||||
>
|
||||
{item.title}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
};
|
||||
}
|
||||
178
examples/hydrogen-2/app/components/Header.tsx
Normal file
178
examples/hydrogen-2/app/components/Header.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import {Await, NavLink, useMatches} from '@remix-run/react';
|
||||
import {Suspense} from 'react';
|
||||
import type {LayoutProps} from './Layout';
|
||||
|
||||
type HeaderProps = Pick<LayoutProps, 'header' | 'cart' | 'isLoggedIn'>;
|
||||
|
||||
type Viewport = 'desktop' | 'mobile';
|
||||
|
||||
export function Header({header, isLoggedIn, cart}: HeaderProps) {
|
||||
const {shop, menu} = header;
|
||||
return (
|
||||
<header className="header">
|
||||
<NavLink prefetch="intent" to="/" style={activeLinkStyle} end>
|
||||
<strong>{shop.name}</strong>
|
||||
</NavLink>
|
||||
<HeaderMenu menu={menu} viewport="desktop" />
|
||||
<HeaderCtas isLoggedIn={isLoggedIn} cart={cart} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLAnchorElement>) {
|
||||
if (viewport === 'mobile') {
|
||||
event.preventDefault();
|
||||
window.location.href = event.currentTarget.href;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className={className} role="navigation">
|
||||
{viewport === 'mobile' && (
|
||||
<NavLink
|
||||
end
|
||||
onClick={closeAside}
|
||||
prefetch="intent"
|
||||
style={activeLinkStyle}
|
||||
to="/"
|
||||
>
|
||||
Home
|
||||
</NavLink>
|
||||
)}
|
||||
{(menu || FALLBACK_HEADER_MENU).items.map((item) => {
|
||||
if (!item.url) return null;
|
||||
|
||||
// if the url is internal, we strip the domain
|
||||
const url =
|
||||
item.url.includes('myshopify.com') ||
|
||||
item.url.includes(publicStoreDomain)
|
||||
? new URL(item.url).pathname
|
||||
: item.url;
|
||||
return (
|
||||
<NavLink
|
||||
className="header-menu-item"
|
||||
end
|
||||
key={item.id}
|
||||
onClick={closeAside}
|
||||
prefetch="intent"
|
||||
style={activeLinkStyle}
|
||||
to={url}
|
||||
>
|
||||
{item.title}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderCtas({
|
||||
isLoggedIn,
|
||||
cart,
|
||||
}: Pick<HeaderProps, 'isLoggedIn' | 'cart'>) {
|
||||
return (
|
||||
<nav className="header-ctas" role="navigation">
|
||||
<HeaderMenuMobileToggle />
|
||||
<NavLink prefetch="intent" to="/account" style={activeLinkStyle}>
|
||||
{isLoggedIn ? 'Account' : 'Sign in'}
|
||||
</NavLink>
|
||||
<SearchToggle />
|
||||
<CartToggle cart={cart} />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderMenuMobileToggle() {
|
||||
return (
|
||||
<a className="header-menu-mobile-toggle" href="#mobile-menu-aside">
|
||||
<h3>☰</h3>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchToggle() {
|
||||
return <a href="#search-aside">Search</a>;
|
||||
}
|
||||
|
||||
function CartBadge({count}: {count: number}) {
|
||||
return <a href="#cart-aside">Cart {count}</a>;
|
||||
}
|
||||
|
||||
function CartToggle({cart}: Pick<HeaderProps, 'cart'>) {
|
||||
return (
|
||||
<Suspense fallback={<CartBadge count={0} />}>
|
||||
<Await resolve={cart}>
|
||||
{(cart) => {
|
||||
if (!cart) return <CartBadge count={0} />;
|
||||
return <CartBadge count={cart.totalQuantity || 0} />;
|
||||
}}
|
||||
</Await>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
};
|
||||
}
|
||||
95
examples/hydrogen-2/app/components/Layout.tsx
Normal file
95
examples/hydrogen-2/app/components/Layout.tsx
Normal file
@@ -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<CartApiQueryFragment | null>;
|
||||
children?: React.ReactNode;
|
||||
footer: Promise<FooterQuery>;
|
||||
header: HeaderQuery;
|
||||
isLoggedIn: boolean;
|
||||
};
|
||||
|
||||
export function Layout({
|
||||
cart,
|
||||
children = null,
|
||||
footer,
|
||||
header,
|
||||
isLoggedIn,
|
||||
}: LayoutProps) {
|
||||
return (
|
||||
<>
|
||||
<CartAside cart={cart} />
|
||||
<SearchAside />
|
||||
<MobileMenuAside menu={header.menu} />
|
||||
<Header header={header} cart={cart} isLoggedIn={isLoggedIn} />
|
||||
<main>{children}</main>
|
||||
<Suspense>
|
||||
<Await resolve={footer}>
|
||||
{(footer) => <Footer menu={footer.menu} />}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CartAside({cart}: {cart: LayoutProps['cart']}) {
|
||||
return (
|
||||
<Aside id="cart-aside" heading="CART">
|
||||
<Suspense fallback={<p>Loading cart ...</p>}>
|
||||
<Await resolve={cart}>
|
||||
{(cart) => {
|
||||
return <CartMain cart={cart} layout="aside" />;
|
||||
}}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</Aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchAside() {
|
||||
return (
|
||||
<Aside id="search-aside" heading="SEARCH">
|
||||
<div className="predictive-search">
|
||||
<br />
|
||||
<PredictiveSearchForm>
|
||||
{({fetchResults, inputRef}) => (
|
||||
<div>
|
||||
<input
|
||||
name="q"
|
||||
onChange={fetchResults}
|
||||
onFocus={fetchResults}
|
||||
placeholder="Search"
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
/>
|
||||
|
||||
<button type="submit">Search</button>
|
||||
</div>
|
||||
)}
|
||||
</PredictiveSearchForm>
|
||||
<PredictiveSearchResults />
|
||||
</div>
|
||||
</Aside>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileMenuAside({menu}: {menu: HeaderQuery['menu']}) {
|
||||
return (
|
||||
<Aside id="mobile-menu-aside" heading="MENU">
|
||||
<HeaderMenu menu={menu} viewport="mobile" />
|
||||
</Aside>
|
||||
);
|
||||
}
|
||||
480
examples/hydrogen-2/app/components/Search.tsx
Normal file
480
examples/hydrogen-2/app/components/Search.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
import {
|
||||
useParams,
|
||||
useFetcher,
|
||||
Link,
|
||||
Form,
|
||||
type FormProps,
|
||||
} from '@remix-run/react';
|
||||
import {Image, Money, Pagination} from '@shopify/hydrogen';
|
||||
import React, {useRef, useEffect} from 'react';
|
||||
import {useFetchers} from '@remix-run/react';
|
||||
|
||||
import type {
|
||||
PredictiveProductFragment,
|
||||
PredictiveCollectionFragment,
|
||||
PredictiveArticleFragment,
|
||||
SearchQuery,
|
||||
} from 'storefrontapi.generated';
|
||||
|
||||
type PredicticeSearchResultItemImage =
|
||||
| PredictiveCollectionFragment['image']
|
||||
| PredictiveArticleFragment['image']
|
||||
| PredictiveProductFragment['variants']['nodes'][0]['image'];
|
||||
|
||||
type PredictiveSearchResultItemPrice =
|
||||
| PredictiveProductFragment['variants']['nodes'][0]['price'];
|
||||
|
||||
export type NormalizedPredictiveSearchResultItem = {
|
||||
__typename: string | undefined;
|
||||
handle: string;
|
||||
id: string;
|
||||
image?: PredicticeSearchResultItemImage;
|
||||
price?: PredictiveSearchResultItemPrice;
|
||||
styledTitle?: string;
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type NormalizedPredictiveSearchResults = Array<
|
||||
| {type: 'queries'; items: Array<NormalizedPredictiveSearchResultItem>}
|
||||
| {type: 'products'; items: Array<NormalizedPredictiveSearchResultItem>}
|
||||
| {type: 'collections'; items: Array<NormalizedPredictiveSearchResultItem>}
|
||||
| {type: 'pages'; items: Array<NormalizedPredictiveSearchResultItem>}
|
||||
| {type: 'articles'; items: Array<NormalizedPredictiveSearchResultItem>}
|
||||
>;
|
||||
|
||||
export type NormalizedPredictiveSearch = {
|
||||
results: NormalizedPredictiveSearchResults;
|
||||
totalResults: number;
|
||||
};
|
||||
|
||||
type FetchSearchResultsReturn = {
|
||||
searchResults: {
|
||||
results: SearchQuery | null;
|
||||
totalResults: number;
|
||||
};
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
export const NO_PREDICTIVE_SEARCH_RESULTS: NormalizedPredictiveSearchResults = [
|
||||
{type: 'queries', items: []},
|
||||
{type: 'products', items: []},
|
||||
{type: 'collections', items: []},
|
||||
{type: 'pages', items: []},
|
||||
{type: 'articles', items: []},
|
||||
];
|
||||
|
||||
export function SearchForm({searchTerm}: {searchTerm: string}) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// focus the input when cmd+k is pressed
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'k' && event.metaKey) {
|
||||
event.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Form method="get">
|
||||
<input
|
||||
defaultValue={searchTerm}
|
||||
name="q"
|
||||
placeholder="Search…"
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
/>
|
||||
|
||||
<button type="submit">Search</button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchResults({
|
||||
results,
|
||||
}: Pick<FetchSearchResultsReturn['searchResults'], 'results'>) {
|
||||
if (!results) {
|
||||
return null;
|
||||
}
|
||||
const keys = Object.keys(results) as Array<keyof typeof results>;
|
||||
return (
|
||||
<div>
|
||||
{results &&
|
||||
keys.map((type) => {
|
||||
const resourceResults = results[type];
|
||||
|
||||
if (resourceResults.nodes[0]?.__typename === 'Page') {
|
||||
const pageResults = resourceResults as SearchQuery['pages'];
|
||||
return resourceResults.nodes.length ? (
|
||||
<SearchResultPageGrid key="pages" pages={pageResults} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
if (resourceResults.nodes[0]?.__typename === 'Product') {
|
||||
const productResults = resourceResults as SearchQuery['products'];
|
||||
return resourceResults.nodes.length ? (
|
||||
<SearchResultsProductsGrid
|
||||
key="products"
|
||||
products={productResults}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
if (resourceResults.nodes[0]?.__typename === 'Article') {
|
||||
const articleResults = resourceResults as SearchQuery['articles'];
|
||||
return resourceResults.nodes.length ? (
|
||||
<SearchResultArticleGrid
|
||||
key="articles"
|
||||
articles={articleResults}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultsProductsGrid({products}: Pick<SearchQuery, 'products'>) {
|
||||
return (
|
||||
<div className="search-result">
|
||||
<h3>Products</h3>
|
||||
<Pagination connection={products}>
|
||||
{({nodes, isLoading, NextLink, PreviousLink}) => {
|
||||
const itemsMarkup = nodes.map((product) => (
|
||||
<div className="search-results-item" key={product.id}>
|
||||
<Link prefetch="intent" to={`/products/${product.handle}`}>
|
||||
<span>{product.title}</span>
|
||||
</Link>
|
||||
</div>
|
||||
));
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<PreviousLink>
|
||||
{isLoading ? 'Loading...' : <span>↑ Load previous</span>}
|
||||
</PreviousLink>
|
||||
</div>
|
||||
<div>
|
||||
{itemsMarkup}
|
||||
<br />
|
||||
</div>
|
||||
<div>
|
||||
<NextLink>
|
||||
{isLoading ? 'Loading...' : <span>Load more ↓</span>}
|
||||
</NextLink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Pagination>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultPageGrid({pages}: Pick<SearchQuery, 'pages'>) {
|
||||
return (
|
||||
<div className="search-result">
|
||||
<h2>Pages</h2>
|
||||
<div>
|
||||
{pages?.nodes?.map((page) => (
|
||||
<div className="search-results-item" key={page.id}>
|
||||
<Link prefetch="intent" to={`/pages/${page.handle}`}>
|
||||
{page.title}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultArticleGrid({articles}: Pick<SearchQuery, 'articles'>) {
|
||||
return (
|
||||
<div className="search-result">
|
||||
<h2>Articles</h2>
|
||||
<div>
|
||||
{articles?.nodes?.map((article) => (
|
||||
<div className="search-results-item" key={article.id}>
|
||||
<Link prefetch="intent" to={`/blog/${article.handle}`}>
|
||||
{article.title}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoSearchResults() {
|
||||
return <p>No results, try a different search.</p>;
|
||||
}
|
||||
|
||||
type ChildrenRenderProps = {
|
||||
fetchResults: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
fetcher: ReturnType<typeof useFetcher<NormalizedPredictiveSearchResults>>;
|
||||
inputRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
};
|
||||
|
||||
type SearchFromProps = {
|
||||
action?: FormProps['action'];
|
||||
method?: FormProps['method'];
|
||||
className?: string;
|
||||
children: (passedProps: ChildrenRenderProps) => React.ReactNode;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Search form component that posts search requests to the `/search` route
|
||||
**/
|
||||
export function PredictiveSearchForm({
|
||||
action,
|
||||
children,
|
||||
className = 'predictive-search-form',
|
||||
method = 'POST',
|
||||
...props
|
||||
}: SearchFromProps) {
|
||||
const params = useParams();
|
||||
const fetcher = useFetcher<NormalizedPredictiveSearchResults>();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
function fetchResults(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const searchAction = action ?? '/api/predictive-search';
|
||||
const localizedAction = params.locale
|
||||
? `/${params.locale}${searchAction}`
|
||||
: searchAction;
|
||||
const newSearchTerm = event.target.value || '';
|
||||
fetcher.submit(
|
||||
{q: newSearchTerm, limit: '6'},
|
||||
{method, action: localizedAction},
|
||||
);
|
||||
}
|
||||
|
||||
// ensure the passed input has a type of search, because SearchResults
|
||||
// will select the element based on the input
|
||||
useEffect(() => {
|
||||
inputRef?.current?.setAttribute('type', 'search');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<fetcher.Form
|
||||
{...props}
|
||||
className={className}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!inputRef?.current || inputRef.current.value === '') {
|
||||
return;
|
||||
}
|
||||
inputRef.current.blur();
|
||||
}}
|
||||
>
|
||||
{children({fetchResults, inputRef, fetcher})}
|
||||
</fetcher.Form>
|
||||
);
|
||||
}
|
||||
|
||||
export function PredictiveSearchResults() {
|
||||
const {results, totalResults, searchInputRef, searchTerm} =
|
||||
usePredictiveSearch();
|
||||
|
||||
function goToSearchResult(event: React.MouseEvent<HTMLAnchorElement>) {
|
||||
if (!searchInputRef.current) return;
|
||||
searchInputRef.current.blur();
|
||||
searchInputRef.current.value = '';
|
||||
// close the aside
|
||||
window.location.href = event.currentTarget.href;
|
||||
}
|
||||
|
||||
if (!totalResults) {
|
||||
return <NoPredictiveSearchResults searchTerm={searchTerm} />;
|
||||
}
|
||||
return (
|
||||
<div className="predictive-search-results">
|
||||
<div>
|
||||
{results.map(({type, items}) => (
|
||||
<PredictiveSearchResult
|
||||
goToSearchResult={goToSearchResult}
|
||||
items={items}
|
||||
key={type}
|
||||
searchTerm={searchTerm}
|
||||
type={type}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* view all results /search?q=term */}
|
||||
{searchTerm.current && (
|
||||
<Link onClick={goToSearchResult} to={`/search?q=${searchTerm.current}`}>
|
||||
<p>
|
||||
View all results for <q>{searchTerm.current}</q>
|
||||
→
|
||||
</p>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoPredictiveSearchResults({
|
||||
searchTerm,
|
||||
}: {
|
||||
searchTerm: React.MutableRefObject<string>;
|
||||
}) {
|
||||
if (!searchTerm.current) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<p>
|
||||
No results found for <q>{searchTerm.current}</q>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
type SearchResultTypeProps = {
|
||||
goToSearchResult: (event: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
items: NormalizedPredictiveSearchResultItem[];
|
||||
searchTerm: UseSearchReturn['searchTerm'];
|
||||
type: NormalizedPredictiveSearchResults[number]['type'];
|
||||
};
|
||||
|
||||
function PredictiveSearchResult({
|
||||
goToSearchResult,
|
||||
items,
|
||||
searchTerm,
|
||||
type,
|
||||
}: SearchResultTypeProps) {
|
||||
const isSuggestions = type === 'queries';
|
||||
const categoryUrl = `/search?q=${
|
||||
searchTerm.current
|
||||
}&type=${pluralToSingularSearchType(type)}`;
|
||||
|
||||
return (
|
||||
<div className="predictive-search-result" key={type}>
|
||||
<Link prefetch="intent" to={categoryUrl} onClick={goToSearchResult}>
|
||||
<h5>{isSuggestions ? 'Suggestions' : type}</h5>
|
||||
</Link>
|
||||
<ul>
|
||||
{items.map((item: NormalizedPredictiveSearchResultItem) => (
|
||||
<SearchResultItem
|
||||
goToSearchResult={goToSearchResult}
|
||||
item={item}
|
||||
key={item.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SearchResultItemProps = Pick<SearchResultTypeProps, 'goToSearchResult'> & {
|
||||
item: NormalizedPredictiveSearchResultItem;
|
||||
};
|
||||
|
||||
function SearchResultItem({goToSearchResult, item}: SearchResultItemProps) {
|
||||
return (
|
||||
<li className="predictive-search-result-item" key={item.id}>
|
||||
<Link onClick={goToSearchResult} to={item.url}>
|
||||
{item.image?.url && (
|
||||
<Image
|
||||
alt={item.image.altText ?? ''}
|
||||
src={item.image.url}
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
{item.styledTitle ? (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: item.styledTitle,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span>{item.title}</span>
|
||||
)}
|
||||
{item?.price && (
|
||||
<small>
|
||||
<Money data={item.price} />
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
type UseSearchReturn = NormalizedPredictiveSearch & {
|
||||
searchInputRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
searchTerm: React.MutableRefObject<string>;
|
||||
};
|
||||
|
||||
function usePredictiveSearch(): UseSearchReturn {
|
||||
const fetchers = useFetchers();
|
||||
const searchTerm = useRef<string>('');
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const searchFetcher = fetchers.find((fetcher) => fetcher.data?.searchResults);
|
||||
|
||||
if (searchFetcher?.state === 'loading') {
|
||||
searchTerm.current = (searchFetcher.formData?.get('q') || '') as string;
|
||||
}
|
||||
|
||||
const search = (searchFetcher?.data?.searchResults || {
|
||||
results: NO_PREDICTIVE_SEARCH_RESULTS,
|
||||
totalResults: 0,
|
||||
}) as NormalizedPredictiveSearch;
|
||||
|
||||
// capture the search input element as a ref
|
||||
useEffect(() => {
|
||||
if (searchInputRef.current) return;
|
||||
searchInputRef.current = document.querySelector('input[type="search"]');
|
||||
}, []);
|
||||
|
||||
return {...search, searchInputRef, searchTerm};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a plural search type to a singular search type
|
||||
* @param type - The plural search type
|
||||
* @returns The singular search type
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* pluralToSingularSearchType('articles') // => 'ARTICLE'
|
||||
* pluralToSingularSearchType(['articles', 'products']) // => 'ARTICLE,PRODUCT'
|
||||
* ```
|
||||
*/
|
||||
function pluralToSingularSearchType(
|
||||
type:
|
||||
| NormalizedPredictiveSearchResults[number]['type']
|
||||
| Array<NormalizedPredictiveSearchResults[number]['type']>,
|
||||
) {
|
||||
const plural = {
|
||||
articles: 'ARTICLE',
|
||||
collections: 'COLLECTION',
|
||||
pages: 'PAGE',
|
||||
products: 'PRODUCT',
|
||||
queries: 'QUERY',
|
||||
};
|
||||
|
||||
if (typeof type === 'string') {
|
||||
return plural[type];
|
||||
}
|
||||
|
||||
return type.map((t) => plural[t]).join(',');
|
||||
}
|
||||
12
examples/hydrogen-2/app/entry.client.tsx
Normal file
12
examples/hydrogen-2/app/entry.client.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import {RemixBrowser} from '@remix-run/react';
|
||||
import {startTransition, StrictMode} from 'react';
|
||||
import {hydrateRoot} from 'react-dom/client';
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<RemixBrowser />
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
33
examples/hydrogen-2/app/entry.server.tsx
Normal file
33
examples/hydrogen-2/app/entry.server.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type {EntryContext} from '@shopify/remix-oxygen';
|
||||
import {RemixServer} from '@remix-run/react';
|
||||
import isbot from 'isbot';
|
||||
import {renderToReadableStream} from 'react-dom/server';
|
||||
|
||||
export default async function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext,
|
||||
) {
|
||||
const body = await renderToReadableStream(
|
||||
<RemixServer context={remixContext} url={request.url} />,
|
||||
{
|
||||
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');
|
||||
return new Response(body, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
});
|
||||
}
|
||||
245
examples/hydrogen-2/app/root.tsx
Normal file
245
examples/hydrogen-2/app/root.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import {defer, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
useMatches,
|
||||
useRouteError,
|
||||
useLoaderData,
|
||||
ScrollRestoration,
|
||||
isRouteErrorResponse,
|
||||
} from '@remix-run/react';
|
||||
import type {CustomerAccessToken} from '@shopify/hydrogen-react/storefront-api-types';
|
||||
import type {HydrogenSession} from '../server';
|
||||
import favicon from '../public/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';
|
||||
|
||||
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 async function loader({context}: LoaderArgs) {
|
||||
const {storefront, session, cart} = context;
|
||||
const customerAccessToken = await session.get('customerAccessToken');
|
||||
const publicStoreDomain = context.env.PUBLIC_STORE_DOMAIN;
|
||||
|
||||
// validate the customer access token is valid
|
||||
const {isLoggedIn, headers} = await validateCustomerAccessToken(
|
||||
customerAccessToken,
|
||||
session,
|
||||
);
|
||||
|
||||
// defer the cart query by not awaiting it
|
||||
const cartPromise = cart.get();
|
||||
|
||||
// defer the footer query (below the fold)
|
||||
const footerPromise = storefront.query(FOOTER_QUERY, {
|
||||
cache: storefront.CacheLong(),
|
||||
variables: {
|
||||
footerMenuHandle: 'footer', // Adjust to your footer menu handle
|
||||
},
|
||||
});
|
||||
|
||||
// await the header query (above the fold)
|
||||
const headerPromise = storefront.query(HEADER_QUERY, {
|
||||
cache: storefront.CacheLong(),
|
||||
variables: {
|
||||
headerMenuHandle: 'main-menu', // Adjust to your header menu handle
|
||||
},
|
||||
});
|
||||
|
||||
return defer(
|
||||
{
|
||||
cart: cartPromise,
|
||||
footer: footerPromise,
|
||||
header: await headerPromise,
|
||||
isLoggedIn,
|
||||
publicStoreDomain,
|
||||
},
|
||||
{headers},
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
<Layout {...data}>
|
||||
<Outlet />
|
||||
</Layout>
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
const [root] = useMatches();
|
||||
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 (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
<Layout {...root.data}>
|
||||
<div className="route-error">
|
||||
<h1>Oops</h1>
|
||||
<h2>{errorStatus}</h2>
|
||||
{errorMessage && (
|
||||
<fieldset>
|
||||
<pre>{errorMessage}</pre>
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the customer access token and returns a boolean and headers
|
||||
* @see https://shopify.dev/docs/api/storefront/latest/objects/CustomerAccessToken
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* //
|
||||
* const {isLoggedIn, headers} = await validateCustomerAccessToken(
|
||||
* customerAccessToken,
|
||||
* session,
|
||||
* );
|
||||
* ```
|
||||
* */
|
||||
async function validateCustomerAccessToken(
|
||||
customerAccessToken: CustomerAccessToken,
|
||||
session: HydrogenSession,
|
||||
) {
|
||||
let isLoggedIn = false;
|
||||
const headers = new Headers();
|
||||
if (!customerAccessToken?.accessToken || !customerAccessToken?.expiresAt) {
|
||||
return {isLoggedIn, headers};
|
||||
}
|
||||
const expiresAt = new Date(customerAccessToken.expiresAt);
|
||||
const dateNow = new Date();
|
||||
const customerAccessTokenExpired = expiresAt < dateNow;
|
||||
if (customerAccessTokenExpired) {
|
||||
session.unset('customerAccessToken');
|
||||
headers.append('Set-Cookie', await session.commit());
|
||||
} else {
|
||||
isLoggedIn = true;
|
||||
}
|
||||
|
||||
return {isLoggedIn, headers};
|
||||
}
|
||||
|
||||
const MENU_FRAGMENT = `#graphql
|
||||
fragment MenuItem on MenuItem {
|
||||
id
|
||||
resourceId
|
||||
tags
|
||||
title
|
||||
type
|
||||
url
|
||||
}
|
||||
fragment ChildMenuItem on MenuItem {
|
||||
...MenuItem
|
||||
}
|
||||
fragment ParentMenuItem on MenuItem {
|
||||
...MenuItem
|
||||
items {
|
||||
...ChildMenuItem
|
||||
}
|
||||
}
|
||||
fragment Menu on Menu {
|
||||
id
|
||||
items {
|
||||
...ParentMenuItem
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
const HEADER_QUERY = `#graphql
|
||||
fragment Shop on Shop {
|
||||
id
|
||||
name
|
||||
description
|
||||
primaryDomain {
|
||||
url
|
||||
}
|
||||
brand {
|
||||
logo {
|
||||
image {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
query Header(
|
||||
$country: CountryCode
|
||||
$headerMenuHandle: String!
|
||||
$language: LanguageCode
|
||||
) @inContext(language: $language, country: $country) {
|
||||
shop {
|
||||
...Shop
|
||||
}
|
||||
menu(handle: $headerMenuHandle) {
|
||||
...Menu
|
||||
}
|
||||
}
|
||||
${MENU_FRAGMENT}
|
||||
` as const;
|
||||
|
||||
const FOOTER_QUERY = `#graphql
|
||||
query Footer(
|
||||
$country: CountryCode
|
||||
$footerMenuHandle: String!
|
||||
$language: LanguageCode
|
||||
) @inContext(language: $language, country: $country) {
|
||||
menu(handle: $footerMenuHandle) {
|
||||
...Menu
|
||||
}
|
||||
}
|
||||
${MENU_FRAGMENT}
|
||||
` as const;
|
||||
7
examples/hydrogen-2/app/routes/$.tsx
Normal file
7
examples/hydrogen-2/app/routes/$.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import type {LoaderArgs} from '@shopify/remix-oxygen';
|
||||
|
||||
export async function loader({request}: LoaderArgs) {
|
||||
throw new Response(`${new URL(request.url).pathname} not found`, {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
145
examples/hydrogen-2/app/routes/[robots.txt].tsx
Normal file
145
examples/hydrogen-2/app/routes/[robots.txt].tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import {type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {useRouteError, isRouteErrorResponse} from '@remix-run/react';
|
||||
import {parseGid} from '@shopify/hydrogen';
|
||||
|
||||
export async function loader({request, context}: LoaderArgs) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
const {shop} = await context.storefront.query(ROBOTS_QUERY);
|
||||
|
||||
const shopId = parseGid(shop.id).id;
|
||||
const body = robotsTxtData({url: url.origin, shopId});
|
||||
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
|
||||
'Cache-Control': `max-age=${60 * 60 * 24}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
return (
|
||||
<div>
|
||||
<h1>Oops</h1>
|
||||
<p>Status: {error.status}</p>
|
||||
<p>{error.data.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let errorMessage = 'Unknown error';
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Uh oh ...</h1>
|
||||
<p>Something went wrong.</p>
|
||||
<pre>{errorMessage}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function robotsTxtData({url, shopId}: {shopId?: string; url?: string}) {
|
||||
const sitemapUrl = url ? `${url}/sitemap.xml` : undefined;
|
||||
|
||||
return `
|
||||
User-agent: *
|
||||
${generalDisallowRules({sitemapUrl, shopId})}
|
||||
|
||||
# Google adsbot ignores robots.txt unless specifically named!
|
||||
User-agent: adsbot-google
|
||||
Disallow: /checkouts/
|
||||
Disallow: /checkout
|
||||
Disallow: /carts
|
||||
Disallow: /orders
|
||||
${shopId ? `Disallow: /${shopId}/checkouts` : ''}
|
||||
${shopId ? `Disallow: /${shopId}/orders` : ''}
|
||||
Disallow: /*?*oseid=*
|
||||
Disallow: /*preview_theme_id*
|
||||
Disallow: /*preview_script_id*
|
||||
|
||||
User-agent: Nutch
|
||||
Disallow: /
|
||||
|
||||
User-agent: AhrefsBot
|
||||
Crawl-delay: 10
|
||||
${generalDisallowRules({sitemapUrl, shopId})}
|
||||
|
||||
User-agent: AhrefsSiteAudit
|
||||
Crawl-delay: 10
|
||||
${generalDisallowRules({sitemapUrl, shopId})}
|
||||
|
||||
User-agent: MJ12bot
|
||||
Crawl-Delay: 10
|
||||
|
||||
User-agent: Pinterest
|
||||
Crawl-delay: 1
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* This function generates disallow rules that generally follow what Shopify's
|
||||
* Online Store has as defaults for their robots.txt
|
||||
*/
|
||||
function generalDisallowRules({
|
||||
shopId,
|
||||
sitemapUrl,
|
||||
}: {
|
||||
shopId?: string;
|
||||
sitemapUrl?: string;
|
||||
}) {
|
||||
return `Disallow: /admin
|
||||
Disallow: /cart
|
||||
Disallow: /orders
|
||||
Disallow: /checkouts/
|
||||
Disallow: /checkout
|
||||
${shopId ? `Disallow: /${shopId}/checkouts` : ''}
|
||||
${shopId ? `Disallow: /${shopId}/orders` : ''}
|
||||
Disallow: /carts
|
||||
Disallow: /account
|
||||
Disallow: /collections/*sort_by*
|
||||
Disallow: /*/collections/*sort_by*
|
||||
Disallow: /collections/*+*
|
||||
Disallow: /collections/*%2B*
|
||||
Disallow: /collections/*%2b*
|
||||
Disallow: /*/collections/*+*
|
||||
Disallow: /*/collections/*%2B*
|
||||
Disallow: /*/collections/*%2b*
|
||||
Disallow: */collections/*filter*&*filter*
|
||||
Disallow: /blogs/*+*
|
||||
Disallow: /blogs/*%2B*
|
||||
Disallow: /blogs/*%2b*
|
||||
Disallow: /*/blogs/*+*
|
||||
Disallow: /*/blogs/*%2B*
|
||||
Disallow: /*/blogs/*%2b*
|
||||
Disallow: /*?*oseid=*
|
||||
Disallow: /*preview_theme_id*
|
||||
Disallow: /*preview_script_id*
|
||||
Disallow: /policies/
|
||||
Disallow: /*/*?*ls=*&ls=*
|
||||
Disallow: /*/*?*ls%3D*%3Fls%3D*
|
||||
Disallow: /*/*?*ls%3d*%3fls%3d*
|
||||
Disallow: /search
|
||||
Allow: /search/
|
||||
Disallow: /search/?*
|
||||
Disallow: /apple-app-site-association
|
||||
Disallow: /.well-known/shopify/monorail
|
||||
${sitemapUrl ? `Sitemap: ${sitemapUrl}` : ''}`;
|
||||
}
|
||||
|
||||
const ROBOTS_QUERY = `#graphql
|
||||
query StoreRobots($country: CountryCode, $language: LanguageCode)
|
||||
@inContext(country: $country, language: $language) {
|
||||
shop {
|
||||
id
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
174
examples/hydrogen-2/app/routes/[sitemap.xml].tsx
Normal file
174
examples/hydrogen-2/app/routes/[sitemap.xml].tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import {flattenConnection} from '@shopify/hydrogen';
|
||||
import type {LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import type {SitemapQuery} from 'storefrontapi.generated';
|
||||
|
||||
/**
|
||||
* the google limit is 50K, however, the storefront API
|
||||
* allows querying only 250 resources per pagination page
|
||||
*/
|
||||
const MAX_URLS = 250;
|
||||
|
||||
type Entry = {
|
||||
url: string;
|
||||
lastMod?: string;
|
||||
changeFreq?: string;
|
||||
image?: {
|
||||
url: string;
|
||||
title?: string;
|
||||
caption?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function loader({request, context: {storefront}}: LoaderArgs) {
|
||||
const data = await storefront.query(SITEMAP_QUERY, {
|
||||
variables: {
|
||||
urlLimits: MAX_URLS,
|
||||
language: storefront.i18n.language,
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
throw new Response('No data found', {status: 404});
|
||||
}
|
||||
|
||||
const sitemap = generateSitemap({data, baseUrl: new URL(request.url).origin});
|
||||
|
||||
return new Response(sitemap, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml',
|
||||
|
||||
'Cache-Control': `max-age=${60 * 60 * 24}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function xmlEncode(string: string) {
|
||||
return string.replace(/[&<>'"]/g, (char) => `&#${char.charCodeAt(0)};`);
|
||||
}
|
||||
|
||||
function generateSitemap({
|
||||
data,
|
||||
baseUrl,
|
||||
}: {
|
||||
data: SitemapQuery;
|
||||
baseUrl: string;
|
||||
}) {
|
||||
const products = flattenConnection(data.products)
|
||||
.filter((product) => product.onlineStoreUrl)
|
||||
.map((product) => {
|
||||
const url = `${baseUrl}/products/${xmlEncode(product.handle)}`;
|
||||
|
||||
const productEntry: Entry = {
|
||||
url,
|
||||
lastMod: product.updatedAt,
|
||||
changeFreq: 'daily',
|
||||
};
|
||||
|
||||
if (product.featuredImage?.url) {
|
||||
productEntry.image = {
|
||||
url: xmlEncode(product.featuredImage.url),
|
||||
};
|
||||
|
||||
if (product.title) {
|
||||
productEntry.image.title = xmlEncode(product.title);
|
||||
}
|
||||
|
||||
if (product.featuredImage.altText) {
|
||||
productEntry.image.caption = xmlEncode(product.featuredImage.altText);
|
||||
}
|
||||
}
|
||||
|
||||
return productEntry;
|
||||
});
|
||||
|
||||
const collections = flattenConnection(data.collections)
|
||||
.filter((collection) => collection.onlineStoreUrl)
|
||||
.map((collection) => {
|
||||
const url = `${baseUrl}/collections/${collection.handle}`;
|
||||
|
||||
return {
|
||||
url,
|
||||
lastMod: collection.updatedAt,
|
||||
changeFreq: 'daily',
|
||||
};
|
||||
});
|
||||
|
||||
const pages = flattenConnection(data.pages)
|
||||
.filter((page) => page.onlineStoreUrl)
|
||||
.map((page) => {
|
||||
const url = `${baseUrl}/pages/${page.handle}`;
|
||||
|
||||
return {
|
||||
url,
|
||||
lastMod: page.updatedAt,
|
||||
changeFreq: 'weekly',
|
||||
};
|
||||
});
|
||||
|
||||
const urls = [...products, ...collections, ...pages];
|
||||
|
||||
return `
|
||||
<urlset
|
||||
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"
|
||||
>
|
||||
${urls.map(renderUrlTag).join('')}
|
||||
</urlset>`;
|
||||
}
|
||||
|
||||
function renderUrlTag({url, lastMod, changeFreq, image}: Entry) {
|
||||
const imageTag = image
|
||||
? `<image:image>
|
||||
<image:loc>${image.url}</image:loc>
|
||||
<image:title>${image.title ?? ''}</image:title>
|
||||
<image:caption>${image.caption ?? ''}</image:caption>
|
||||
</image:image>`.trim()
|
||||
: '';
|
||||
|
||||
return `
|
||||
<url>
|
||||
<loc>${url}</loc>
|
||||
<lastmod>${lastMod}</lastmod>
|
||||
<changefreq>${changeFreq}</changefreq>
|
||||
${imageTag}
|
||||
</url>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
const SITEMAP_QUERY = `#graphql
|
||||
query Sitemap($urlLimits: Int, $language: LanguageCode)
|
||||
@inContext(language: $language) {
|
||||
products(
|
||||
first: $urlLimits
|
||||
query: "published_status:'online_store:visible'"
|
||||
) {
|
||||
nodes {
|
||||
updatedAt
|
||||
handle
|
||||
onlineStoreUrl
|
||||
title
|
||||
featuredImage {
|
||||
url
|
||||
altText
|
||||
}
|
||||
}
|
||||
}
|
||||
collections(
|
||||
first: $urlLimits
|
||||
query: "published_status:'online_store:visible'"
|
||||
) {
|
||||
nodes {
|
||||
updatedAt
|
||||
handle
|
||||
onlineStoreUrl
|
||||
}
|
||||
}
|
||||
pages(first: $urlLimits, query: "published_status:'published'") {
|
||||
nodes {
|
||||
updatedAt
|
||||
handle
|
||||
onlineStoreUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
145
examples/hydrogen-2/app/routes/_index.tsx
Normal file
145
examples/hydrogen-2/app/routes/_index.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import type {V2_MetaFunction} from '@shopify/remix-oxygen';
|
||||
import {defer, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {Await, useLoaderData, Link} from '@remix-run/react';
|
||||
import {Suspense} from 'react';
|
||||
import {Image, Money} from '@shopify/hydrogen';
|
||||
import type {
|
||||
FeaturedCollectionFragment,
|
||||
RecommendedProductsQuery,
|
||||
} from 'storefrontapi.generated';
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: 'Hydrogen | Home'}];
|
||||
};
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
const {storefront} = context;
|
||||
const {collections} = await storefront.query(FEATURED_COLLECTION_QUERY);
|
||||
const featuredCollection = collections.nodes[0];
|
||||
const recommendedProducts = storefront.query(RECOMMENDED_PRODUCTS_QUERY);
|
||||
|
||||
return defer({featuredCollection, recommendedProducts});
|
||||
}
|
||||
|
||||
export default function Homepage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
return (
|
||||
<div className="home">
|
||||
<FeaturedCollection collection={data.featuredCollection} />
|
||||
<RecommendedProducts products={data.recommendedProducts} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturedCollection({
|
||||
collection,
|
||||
}: {
|
||||
collection: FeaturedCollectionFragment;
|
||||
}) {
|
||||
const image = collection.image;
|
||||
return (
|
||||
<Link
|
||||
className="featured-collection"
|
||||
to={`/collections/${collection.handle}`}
|
||||
>
|
||||
{image && (
|
||||
<div className="featured-collection-image">
|
||||
<Image data={image} sizes="100vw" />
|
||||
</div>
|
||||
)}
|
||||
<h1>{collection.title}</h1>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendedProducts({
|
||||
products,
|
||||
}: {
|
||||
products: Promise<RecommendedProductsQuery>;
|
||||
}) {
|
||||
return (
|
||||
<div className="recommended-products">
|
||||
<h2>Recommended Products</h2>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<Await resolve={products}>
|
||||
{({products}) => (
|
||||
<div className="recommended-products-grid">
|
||||
{products.nodes.map((product) => (
|
||||
<Link
|
||||
key={product.id}
|
||||
className="recommended-product"
|
||||
to={`/products/${product.handle}`}
|
||||
>
|
||||
<Image
|
||||
data={product.images.nodes[0]}
|
||||
aspectRatio="1/1"
|
||||
sizes="(min-width: 45em) 20vw, 50vw"
|
||||
/>
|
||||
<h4>{product.title}</h4>
|
||||
<small>
|
||||
<Money data={product.priceRange.minVariantPrice} />
|
||||
</small>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Await>
|
||||
</Suspense>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FEATURED_COLLECTION_QUERY = `#graphql
|
||||
fragment FeaturedCollection on Collection {
|
||||
id
|
||||
title
|
||||
image {
|
||||
id
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
handle
|
||||
}
|
||||
query FeaturedCollection($country: CountryCode, $language: LanguageCode)
|
||||
@inContext(country: $country, language: $language) {
|
||||
collections(first: 1, sortKey: UPDATED_AT, reverse: true) {
|
||||
nodes {
|
||||
...FeaturedCollection
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
const RECOMMENDED_PRODUCTS_QUERY = `#graphql
|
||||
fragment RecommendedProduct on Product {
|
||||
id
|
||||
title
|
||||
handle
|
||||
priceRange {
|
||||
minVariantPrice {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
images(first: 1) {
|
||||
nodes {
|
||||
id
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
}
|
||||
}
|
||||
query RecommendedProducts ($country: CountryCode, $language: LanguageCode)
|
||||
@inContext(country: $country, language: $language) {
|
||||
products(first: 4, sortKey: UPDATED_AT, reverse: true) {
|
||||
nodes {
|
||||
...RecommendedProduct
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
9
examples/hydrogen-2/app/routes/account.$.tsx
Normal file
9
examples/hydrogen-2/app/routes/account.$.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import type {LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {redirect} from '@shopify/remix-oxygen';
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
if (await context.session.get('customerAccessToken')) {
|
||||
return redirect('/account');
|
||||
}
|
||||
return redirect('/account/login');
|
||||
}
|
||||
563
examples/hydrogen-2/app/routes/account.addresses.tsx
Normal file
563
examples/hydrogen-2/app/routes/account.addresses.tsx
Normal file
@@ -0,0 +1,563 @@
|
||||
import type {MailingAddressInput} from '@shopify/hydrogen/storefront-api-types';
|
||||
import type {AddressFragment, CustomerFragment} from 'storefrontapi.generated';
|
||||
import {
|
||||
json,
|
||||
redirect,
|
||||
type ActionArgs,
|
||||
type LoaderArgs,
|
||||
type V2_MetaFunction,
|
||||
} from '@shopify/remix-oxygen';
|
||||
import {
|
||||
Form,
|
||||
useActionData,
|
||||
useNavigation,
|
||||
useOutletContext,
|
||||
} from '@remix-run/react';
|
||||
|
||||
export type ActionResponse = {
|
||||
addressId?: string | null;
|
||||
createdAddress?: AddressFragment;
|
||||
defaultAddress?: string | null;
|
||||
deletedAddress?: string | null;
|
||||
error: Record<AddressFragment['id'], string> | null;
|
||||
updatedAddress?: AddressFragment;
|
||||
};
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: 'Addresses'}];
|
||||
};
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
const {session} = context;
|
||||
const customerAccessToken = await session.get('customerAccessToken');
|
||||
if (!customerAccessToken) {
|
||||
return redirect('/account/login');
|
||||
}
|
||||
return json({});
|
||||
}
|
||||
|
||||
export async function action({request, context}: ActionArgs) {
|
||||
const {storefront, session} = context;
|
||||
|
||||
try {
|
||||
const form = await request.formData();
|
||||
|
||||
const addressId = form.has('addressId')
|
||||
? String(form.get('addressId'))
|
||||
: null;
|
||||
if (!addressId) {
|
||||
throw new Error('You must provide an address id.');
|
||||
}
|
||||
|
||||
const customerAccessToken = await session.get('customerAccessToken');
|
||||
if (!customerAccessToken) {
|
||||
return json({error: {[addressId]: 'Unauthorized'}}, {status: 401});
|
||||
}
|
||||
const {accessToken} = customerAccessToken;
|
||||
|
||||
const defaultAddress = form.has('defaultAddress')
|
||||
? String(form.get('defaultAddress')) === 'on'
|
||||
: null;
|
||||
const address: MailingAddressInput = {};
|
||||
const keys: (keyof MailingAddressInput)[] = [
|
||||
'address1',
|
||||
'address2',
|
||||
'city',
|
||||
'company',
|
||||
'country',
|
||||
'firstName',
|
||||
'lastName',
|
||||
'phone',
|
||||
'province',
|
||||
'zip',
|
||||
];
|
||||
|
||||
for (const key of keys) {
|
||||
const value = form.get(key);
|
||||
if (typeof value === 'string') {
|
||||
address[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
switch (request.method) {
|
||||
case 'POST': {
|
||||
// handle new address creation
|
||||
try {
|
||||
const {customerAddressCreate} = await storefront.mutate(
|
||||
CREATE_ADDRESS_MUTATION,
|
||||
{
|
||||
variables: {customerAccessToken: accessToken, address},
|
||||
},
|
||||
);
|
||||
|
||||
if (customerAddressCreate?.customerUserErrors?.length) {
|
||||
const error = customerAddressCreate.customerUserErrors[0];
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
const createdAddress = customerAddressCreate?.customerAddress;
|
||||
if (!createdAddress?.id) {
|
||||
throw new Error(
|
||||
'Expected customer address to be created, but the id is missing',
|
||||
);
|
||||
}
|
||||
|
||||
if (defaultAddress) {
|
||||
const createdAddressId = decodeURIComponent(createdAddress.id);
|
||||
const {customerDefaultAddressUpdate} = await storefront.mutate(
|
||||
UPDATE_DEFAULT_ADDRESS_MUTATION,
|
||||
{
|
||||
variables: {
|
||||
customerAccessToken: accessToken,
|
||||
addressId: createdAddressId,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (customerDefaultAddressUpdate?.customerUserErrors?.length) {
|
||||
const error = customerDefaultAddressUpdate.customerUserErrors[0];
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return json({error: null, createdAddress, defaultAddress});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return json({error: {[addressId]: error.message}}, {status: 400});
|
||||
}
|
||||
return json({error: {[addressId]: error}}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
case 'PUT': {
|
||||
// handle address updates
|
||||
try {
|
||||
const {customerAddressUpdate} = await storefront.mutate(
|
||||
UPDATE_ADDRESS_MUTATION,
|
||||
{
|
||||
variables: {
|
||||
address,
|
||||
customerAccessToken: accessToken,
|
||||
id: decodeURIComponent(addressId),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const updatedAddress = customerAddressUpdate?.customerAddress;
|
||||
|
||||
if (customerAddressUpdate?.customerUserErrors?.length) {
|
||||
const error = customerAddressUpdate.customerUserErrors[0];
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
if (defaultAddress) {
|
||||
const {customerDefaultAddressUpdate} = await storefront.mutate(
|
||||
UPDATE_DEFAULT_ADDRESS_MUTATION,
|
||||
{
|
||||
variables: {
|
||||
customerAccessToken: accessToken,
|
||||
addressId: decodeURIComponent(addressId),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (customerDefaultAddressUpdate?.customerUserErrors?.length) {
|
||||
const error = customerDefaultAddressUpdate.customerUserErrors[0];
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return json({error: null, updatedAddress, defaultAddress});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return json({error: {[addressId]: error.message}}, {status: 400});
|
||||
}
|
||||
return json({error: {[addressId]: error}}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
case 'DELETE': {
|
||||
// handles address deletion
|
||||
try {
|
||||
const {customerAddressDelete} = await storefront.mutate(
|
||||
DELETE_ADDRESS_MUTATION,
|
||||
{
|
||||
variables: {customerAccessToken: accessToken, id: addressId},
|
||||
},
|
||||
);
|
||||
|
||||
if (customerAddressDelete?.customerUserErrors?.length) {
|
||||
const error = customerAddressDelete.customerUserErrors[0];
|
||||
throw new Error(error.message);
|
||||
}
|
||||
return json({error: null, deletedAddress: addressId});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return json({error: {[addressId]: error.message}}, {status: 400});
|
||||
}
|
||||
return json({error: {[addressId]: error}}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
return json(
|
||||
{error: {[addressId]: 'Method not allowed'}},
|
||||
{status: 405},
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return json({error: error.message}, {status: 400});
|
||||
}
|
||||
return json({error}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Addresses() {
|
||||
const {customer} = useOutletContext<{customer: CustomerFragment}>();
|
||||
const {defaultAddress, addresses} = customer;
|
||||
|
||||
return (
|
||||
<div className="account-addresses">
|
||||
<h2>Addresses</h2>
|
||||
<br />
|
||||
{!addresses.nodes.length ? (
|
||||
<p>You have no addresses saved.</p>
|
||||
) : (
|
||||
<div>
|
||||
<div>
|
||||
<legend>Create address</legend>
|
||||
<NewAddressForm />
|
||||
</div>
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<ExistingAddresses
|
||||
addresses={addresses}
|
||||
defaultAddress={defaultAddress}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewAddressForm() {
|
||||
const newAddress = {
|
||||
address1: '',
|
||||
address2: '',
|
||||
city: '',
|
||||
company: '',
|
||||
country: '',
|
||||
firstName: '',
|
||||
id: 'new',
|
||||
lastName: '',
|
||||
phone: '',
|
||||
province: '',
|
||||
zip: '',
|
||||
} as AddressFragment;
|
||||
|
||||
return (
|
||||
<AddressForm address={newAddress} defaultAddress={null}>
|
||||
{({stateForMethod}) => (
|
||||
<div>
|
||||
<button
|
||||
disabled={stateForMethod('POST') !== 'idle'}
|
||||
formMethod="POST"
|
||||
type="submit"
|
||||
>
|
||||
{stateForMethod('POST') !== 'idle' ? 'Creating' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</AddressForm>
|
||||
);
|
||||
}
|
||||
|
||||
function ExistingAddresses({
|
||||
addresses,
|
||||
defaultAddress,
|
||||
}: Pick<CustomerFragment, 'addresses' | 'defaultAddress'>) {
|
||||
return (
|
||||
<div>
|
||||
<legend>Existing addresses</legend>
|
||||
{addresses.nodes.map((address) => (
|
||||
<AddressForm
|
||||
key={address.id}
|
||||
address={address}
|
||||
defaultAddress={defaultAddress}
|
||||
>
|
||||
{({stateForMethod}) => (
|
||||
<div>
|
||||
<button
|
||||
disabled={stateForMethod('PUT') !== 'idle'}
|
||||
formMethod="PUT"
|
||||
type="submit"
|
||||
>
|
||||
{stateForMethod('PUT') !== 'idle' ? 'Saving' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
disabled={stateForMethod('DELETE') !== 'idle'}
|
||||
formMethod="DELETE"
|
||||
type="submit"
|
||||
>
|
||||
{stateForMethod('DELETE') !== 'idle' ? 'Deleting' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</AddressForm>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddressForm({
|
||||
address,
|
||||
defaultAddress,
|
||||
children,
|
||||
}: {
|
||||
children: (props: {
|
||||
stateForMethod: (
|
||||
method: 'PUT' | 'POST' | 'DELETE',
|
||||
) => ReturnType<typeof useNavigation>['state'];
|
||||
}) => React.ReactNode;
|
||||
defaultAddress: CustomerFragment['defaultAddress'];
|
||||
address: AddressFragment;
|
||||
}) {
|
||||
const {state, formMethod} = useNavigation();
|
||||
const action = useActionData<ActionResponse>();
|
||||
const error = action?.error?.[address.id];
|
||||
const isDefaultAddress = defaultAddress?.id === address.id;
|
||||
return (
|
||||
<Form id={address.id}>
|
||||
<fieldset>
|
||||
<input type="hidden" name="addressId" defaultValue={address.id} />
|
||||
<label htmlFor="firstName">First name*</label>
|
||||
<input
|
||||
aria-label="First name"
|
||||
autoComplete="given-name"
|
||||
defaultValue={address?.firstName ?? ''}
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
placeholder="First name"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="lastName">Last name*</label>
|
||||
<input
|
||||
aria-label="Last name"
|
||||
autoComplete="family-name"
|
||||
defaultValue={address?.lastName ?? ''}
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
placeholder="Last name"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="company">Company</label>
|
||||
<input
|
||||
aria-label="Company"
|
||||
autoComplete="organization"
|
||||
defaultValue={address?.company ?? ''}
|
||||
id="company"
|
||||
name="company"
|
||||
placeholder="Company"
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="address1">Address line*</label>
|
||||
<input
|
||||
aria-label="Address line 1"
|
||||
autoComplete="address-line1"
|
||||
defaultValue={address?.address1 ?? ''}
|
||||
id="address1"
|
||||
name="address1"
|
||||
placeholder="Address line 1*"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="address2">Address line 2</label>
|
||||
<input
|
||||
aria-label="Address line 2"
|
||||
autoComplete="address-line2"
|
||||
defaultValue={address?.address2 ?? ''}
|
||||
id="address2"
|
||||
name="address2"
|
||||
placeholder="Address line 2"
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="city">City*</label>
|
||||
<input
|
||||
aria-label="City"
|
||||
autoComplete="address-level2"
|
||||
defaultValue={address?.city ?? ''}
|
||||
id="city"
|
||||
name="city"
|
||||
placeholder="City"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="province">State / Province*</label>
|
||||
<input
|
||||
aria-label="State"
|
||||
autoComplete="address-level1"
|
||||
defaultValue={address?.province ?? ''}
|
||||
id="province"
|
||||
name="province"
|
||||
placeholder="State / Province"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="zip">Zip / Postal Code*</label>
|
||||
<input
|
||||
aria-label="Zip"
|
||||
autoComplete="postal-code"
|
||||
defaultValue={address?.zip ?? ''}
|
||||
id="zip"
|
||||
name="zip"
|
||||
placeholder="Zip / Postal Code"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="country">Country*</label>
|
||||
<input
|
||||
aria-label="Country"
|
||||
autoComplete="country-name"
|
||||
defaultValue={address?.country ?? ''}
|
||||
id="country"
|
||||
name="country"
|
||||
placeholder="Country"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="phone">Phone</label>
|
||||
<input
|
||||
aria-label="Phone"
|
||||
autoComplete="tel"
|
||||
defaultValue={address?.phone ?? ''}
|
||||
id="phone"
|
||||
name="phone"
|
||||
placeholder="+16135551111"
|
||||
pattern="^\+?[1-9]\d{3,14}$"
|
||||
type="tel"
|
||||
/>
|
||||
<div>
|
||||
<input
|
||||
defaultChecked={isDefaultAddress}
|
||||
id="defaultAddress"
|
||||
name="defaultAddress"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label htmlFor="defaultAddress">Set as default address</label>
|
||||
</div>
|
||||
{error ? (
|
||||
<p>
|
||||
<mark>
|
||||
<small>{error}</small>
|
||||
</mark>
|
||||
</p>
|
||||
) : (
|
||||
<br />
|
||||
)}
|
||||
{children({
|
||||
stateForMethod: (method) => (formMethod === method ? state : 'idle'),
|
||||
})}
|
||||
</fieldset>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/2023-04/mutations/customeraddressupdate
|
||||
const UPDATE_ADDRESS_MUTATION = `#graphql
|
||||
mutation customerAddressUpdate(
|
||||
$address: MailingAddressInput!
|
||||
$customerAccessToken: String!
|
||||
$id: ID!
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customerAddressUpdate(
|
||||
address: $address
|
||||
customerAccessToken: $customerAccessToken
|
||||
id: $id
|
||||
) {
|
||||
customerAddress {
|
||||
id
|
||||
}
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customerAddressDelete
|
||||
const DELETE_ADDRESS_MUTATION = `#graphql
|
||||
mutation customerAddressDelete(
|
||||
$customerAccessToken: String!,
|
||||
$id: ID!,
|
||||
$country: CountryCode,
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customerAddressDelete(customerAccessToken: $customerAccessToken, id: $id) {
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
deletedCustomerAddressId
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customerdefaultaddressupdate
|
||||
const UPDATE_DEFAULT_ADDRESS_MUTATION = `#graphql
|
||||
mutation customerDefaultAddressUpdate(
|
||||
$addressId: ID!
|
||||
$customerAccessToken: String!
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customerDefaultAddressUpdate(
|
||||
addressId: $addressId
|
||||
customerAccessToken: $customerAccessToken
|
||||
) {
|
||||
customer {
|
||||
defaultAddress {
|
||||
id
|
||||
}
|
||||
}
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customeraddresscreate
|
||||
const CREATE_ADDRESS_MUTATION = `#graphql
|
||||
mutation customerAddressCreate(
|
||||
$address: MailingAddressInput!
|
||||
$customerAccessToken: String!
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customerAddressCreate(
|
||||
address: $address
|
||||
customerAccessToken: $customerAccessToken
|
||||
) {
|
||||
customerAddress {
|
||||
id
|
||||
}
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
309
examples/hydrogen-2/app/routes/account.orders.$id.tsx
Normal file
309
examples/hydrogen-2/app/routes/account.orders.$id.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import {json, redirect, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {Link, useLoaderData, type V2_MetaFunction} from '@remix-run/react';
|
||||
import {Money, Image, flattenConnection} from '@shopify/hydrogen';
|
||||
import type {OrderLineItemFullFragment} from 'storefrontapi.generated';
|
||||
|
||||
export const meta: V2_MetaFunction<typeof loader> = ({data}) => {
|
||||
return [{title: `Order ${data?.order?.name}`}];
|
||||
};
|
||||
|
||||
export async function loader({params, context}: LoaderArgs) {
|
||||
const {session, storefront} = context;
|
||||
|
||||
if (!params.id) {
|
||||
return redirect('/account/orders');
|
||||
}
|
||||
|
||||
const orderId = atob(params.id);
|
||||
const customerAccessToken = await session.get('customerAccessToken');
|
||||
|
||||
if (!customerAccessToken) {
|
||||
return redirect('/account/login');
|
||||
}
|
||||
|
||||
const {order} = await storefront.query(CUSTOMER_ORDER_QUERY, {
|
||||
variables: {orderId},
|
||||
});
|
||||
|
||||
if (!order || !('lineItems' in order)) {
|
||||
throw new Response('Order not found', {status: 404});
|
||||
}
|
||||
|
||||
const lineItems = flattenConnection(order.lineItems);
|
||||
const discountApplications = flattenConnection(order.discountApplications);
|
||||
|
||||
const firstDiscount = discountApplications[0]?.value;
|
||||
|
||||
const discountValue =
|
||||
firstDiscount?.__typename === 'MoneyV2' && firstDiscount;
|
||||
|
||||
const discountPercentage =
|
||||
firstDiscount?.__typename === 'PricingPercentageValue' &&
|
||||
firstDiscount?.percentage;
|
||||
|
||||
return json({
|
||||
order,
|
||||
lineItems,
|
||||
discountValue,
|
||||
discountPercentage,
|
||||
});
|
||||
}
|
||||
|
||||
export default function OrderRoute() {
|
||||
const {order, lineItems, discountValue, discountPercentage} =
|
||||
useLoaderData<typeof loader>();
|
||||
return (
|
||||
<div className="account-order">
|
||||
<h2>Order {order.name}</h2>
|
||||
<p>Placed on {new Date(order.processedAt!).toDateString()}</p>
|
||||
<br />
|
||||
<div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Product</th>
|
||||
<th scope="col">Price</th>
|
||||
<th scope="col">Quantity</th>
|
||||
<th scope="col">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lineItems.map((lineItem, lineItemIndex) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<OrderLineRow key={lineItemIndex} lineItem={lineItem} />
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
{((discountValue && discountValue.amount) ||
|
||||
discountPercentage) && (
|
||||
<tr>
|
||||
<th scope="row" colSpan={3}>
|
||||
<p>Discounts</p>
|
||||
</th>
|
||||
<th scope="row">
|
||||
<p>Discounts</p>
|
||||
</th>
|
||||
<td>
|
||||
{discountPercentage ? (
|
||||
<span>-{discountPercentage}% OFF</span>
|
||||
) : (
|
||||
discountValue && <Money data={discountValue!} />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<th scope="row" colSpan={3}>
|
||||
<p>Subtotal</p>
|
||||
</th>
|
||||
<th scope="row">
|
||||
<p>Subtotal</p>
|
||||
</th>
|
||||
<td>
|
||||
<Money data={order.subtotalPriceV2!} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row" colSpan={3}>
|
||||
Tax
|
||||
</th>
|
||||
<th scope="row">
|
||||
<p>Tax</p>
|
||||
</th>
|
||||
<td>
|
||||
<Money data={order.totalTaxV2!} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row" colSpan={3}>
|
||||
Total
|
||||
</th>
|
||||
<th scope="row">
|
||||
<p>Total</p>
|
||||
</th>
|
||||
<td>
|
||||
<Money data={order.totalPriceV2!} />
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<div>
|
||||
<h3>Shipping Address</h3>
|
||||
{order?.shippingAddress ? (
|
||||
<address>
|
||||
<p>
|
||||
{order.shippingAddress.firstName &&
|
||||
order.shippingAddress.firstName + ' '}
|
||||
{order.shippingAddress.lastName}
|
||||
</p>
|
||||
{order?.shippingAddress?.formatted ? (
|
||||
order.shippingAddress.formatted.map((line: string) => (
|
||||
<p key={line}>{line}</p>
|
||||
))
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</address>
|
||||
) : (
|
||||
<p>No shipping address defined</p>
|
||||
)}
|
||||
<h3>Status</h3>
|
||||
<div>
|
||||
<p>{order.fulfillmentStatus}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<p>
|
||||
<a target="_blank" href={order.statusUrl} rel="noreferrer">
|
||||
View Order Status →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderLineRow({lineItem}: {lineItem: OrderLineItemFullFragment}) {
|
||||
return (
|
||||
<tr key={lineItem.variant!.id}>
|
||||
<td>
|
||||
<div>
|
||||
<Link to={`/products/${lineItem.variant!.product!.handle}`}>
|
||||
{lineItem?.variant?.image && (
|
||||
<div>
|
||||
<Image data={lineItem.variant.image} width={96} height={96} />
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<div>
|
||||
<p>{lineItem.title}</p>
|
||||
<small>{lineItem.variant!.title}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<Money data={lineItem.variant!.price!} />
|
||||
</td>
|
||||
<td>{lineItem.quantity}</td>
|
||||
<td>
|
||||
<Money data={lineItem.discountedTotalPrice!} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/objects/Order
|
||||
const CUSTOMER_ORDER_QUERY = `#graphql
|
||||
fragment OrderMoney on MoneyV2 {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
fragment AddressFull on MailingAddress {
|
||||
address1
|
||||
address2
|
||||
city
|
||||
company
|
||||
country
|
||||
countryCodeV2
|
||||
firstName
|
||||
formatted
|
||||
id
|
||||
lastName
|
||||
name
|
||||
phone
|
||||
province
|
||||
provinceCode
|
||||
zip
|
||||
}
|
||||
fragment DiscountApplication on DiscountApplication {
|
||||
value {
|
||||
__typename
|
||||
... on MoneyV2 {
|
||||
...OrderMoney
|
||||
}
|
||||
... on PricingPercentageValue {
|
||||
percentage
|
||||
}
|
||||
}
|
||||
}
|
||||
fragment OrderLineProductVariant on ProductVariant {
|
||||
id
|
||||
image {
|
||||
altText
|
||||
height
|
||||
url
|
||||
id
|
||||
width
|
||||
}
|
||||
price {
|
||||
...OrderMoney
|
||||
}
|
||||
product {
|
||||
handle
|
||||
}
|
||||
sku
|
||||
title
|
||||
}
|
||||
fragment OrderLineItemFull on OrderLineItem {
|
||||
title
|
||||
quantity
|
||||
discountAllocations {
|
||||
allocatedAmount {
|
||||
...OrderMoney
|
||||
}
|
||||
discountApplication {
|
||||
...DiscountApplication
|
||||
}
|
||||
}
|
||||
originalTotalPrice {
|
||||
...OrderMoney
|
||||
}
|
||||
discountedTotalPrice {
|
||||
...OrderMoney
|
||||
}
|
||||
variant {
|
||||
...OrderLineProductVariant
|
||||
}
|
||||
}
|
||||
fragment Order on Order {
|
||||
id
|
||||
name
|
||||
orderNumber
|
||||
statusUrl
|
||||
processedAt
|
||||
fulfillmentStatus
|
||||
totalTaxV2 {
|
||||
...OrderMoney
|
||||
}
|
||||
totalPriceV2 {
|
||||
...OrderMoney
|
||||
}
|
||||
subtotalPriceV2 {
|
||||
...OrderMoney
|
||||
}
|
||||
shippingAddress {
|
||||
...AddressFull
|
||||
}
|
||||
discountApplications(first: 100) {
|
||||
nodes {
|
||||
...DiscountApplication
|
||||
}
|
||||
}
|
||||
lineItems(first: 100) {
|
||||
nodes {
|
||||
...OrderLineItemFull
|
||||
}
|
||||
}
|
||||
}
|
||||
query Order(
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
$orderId: ID!
|
||||
) @inContext(country: $country, language: $language) {
|
||||
order: node(id: $orderId) {
|
||||
... on Order {
|
||||
...Order
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
196
examples/hydrogen-2/app/routes/account.orders._index.tsx
Normal file
196
examples/hydrogen-2/app/routes/account.orders._index.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import {Link, useLoaderData} from '@remix-run/react';
|
||||
import {Money, Pagination, getPaginationVariables} from '@shopify/hydrogen';
|
||||
import {
|
||||
json,
|
||||
redirect,
|
||||
type LoaderArgs,
|
||||
type V2_MetaFunction,
|
||||
} from '@shopify/remix-oxygen';
|
||||
import type {
|
||||
CustomerOrdersFragment,
|
||||
OrderItemFragment,
|
||||
} from 'storefrontapi.generated';
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: 'Orders'}];
|
||||
};
|
||||
|
||||
export async function loader({request, context}: LoaderArgs) {
|
||||
const {session, storefront} = context;
|
||||
|
||||
const customerAccessToken = await session.get('customerAccessToken');
|
||||
if (!customerAccessToken?.accessToken) {
|
||||
return redirect('/account/login');
|
||||
}
|
||||
|
||||
try {
|
||||
const paginationVariables = getPaginationVariables(request, {
|
||||
pageBy: 20,
|
||||
});
|
||||
|
||||
const {customer} = await storefront.query(CUSTOMER_ORDERS_QUERY, {
|
||||
variables: {
|
||||
customerAccessToken: customerAccessToken.accessToken,
|
||||
country: storefront.i18n.country,
|
||||
language: storefront.i18n.language,
|
||||
...paginationVariables,
|
||||
},
|
||||
cache: storefront.CacheNone(),
|
||||
});
|
||||
|
||||
if (!customer) {
|
||||
throw new Error('Customer not found');
|
||||
}
|
||||
|
||||
return json({customer});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return json({error: error.message}, {status: 400});
|
||||
}
|
||||
return json({error}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Orders() {
|
||||
const {customer} = useLoaderData<{customer: CustomerOrdersFragment}>();
|
||||
const {orders, numberOfOrders} = customer;
|
||||
return (
|
||||
<div className="orders">
|
||||
<h2>
|
||||
Orders <small>({numberOfOrders})</small>
|
||||
</h2>
|
||||
<br />
|
||||
{orders.nodes.length ? <OrdersTable orders={orders} /> : <EmptyOrders />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrdersTable({orders}: Pick<CustomerOrdersFragment, 'orders'>) {
|
||||
return (
|
||||
<div className="acccount-orders">
|
||||
{orders?.nodes.length ? (
|
||||
<Pagination connection={orders}>
|
||||
{({nodes, isLoading, PreviousLink, NextLink}) => {
|
||||
return (
|
||||
<>
|
||||
<PreviousLink>
|
||||
{isLoading ? 'Loading...' : <span>↑ Load previous</span>}
|
||||
</PreviousLink>
|
||||
{nodes.map((order) => {
|
||||
return <OrderItem key={order.id} order={order} />;
|
||||
})}
|
||||
<NextLink>
|
||||
{isLoading ? 'Loading...' : <span>Load more ↓</span>}
|
||||
</NextLink>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Pagination>
|
||||
) : (
|
||||
<EmptyOrders />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyOrders() {
|
||||
return (
|
||||
<div>
|
||||
<p>You haven't placed any orders yet.</p>
|
||||
<br />
|
||||
<p>
|
||||
<Link to="/collections">Start Shopping →</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderItem({order}: {order: OrderItemFragment}) {
|
||||
return (
|
||||
<>
|
||||
<fieldset>
|
||||
<Link to={`/account/orders/${order.id}`}>
|
||||
<strong>#{order.orderNumber}</strong>
|
||||
</Link>
|
||||
<p>{new Date(order.processedAt).toDateString()}</p>
|
||||
<p>{order.financialStatus}</p>
|
||||
<p>{order.fulfillmentStatus}</p>
|
||||
<Money data={order.currentTotalPrice} />
|
||||
<Link to={`/account/orders/${btoa(order.id)}`}>View Order →</Link>
|
||||
</fieldset>
|
||||
<br />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ORDER_ITEM_FRAGMENT = `#graphql
|
||||
fragment OrderItem on Order {
|
||||
currentTotalPrice {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
financialStatus
|
||||
fulfillmentStatus
|
||||
id
|
||||
lineItems(first: 10) {
|
||||
nodes {
|
||||
title
|
||||
variant {
|
||||
image {
|
||||
url
|
||||
altText
|
||||
height
|
||||
width
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
orderNumber
|
||||
customerUrl
|
||||
statusUrl
|
||||
processedAt
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const CUSTOMER_FRAGMENT = `#graphql
|
||||
fragment CustomerOrders on Customer {
|
||||
numberOfOrders
|
||||
orders(
|
||||
sortKey: PROCESSED_AT,
|
||||
reverse: true,
|
||||
first: $first,
|
||||
last: $last,
|
||||
before: $startCursor,
|
||||
after: $endCursor
|
||||
) {
|
||||
nodes {
|
||||
...OrderItem
|
||||
}
|
||||
pageInfo {
|
||||
hasPreviousPage
|
||||
hasNextPage
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
${ORDER_ITEM_FRAGMENT}
|
||||
` as const;
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/queries/customer
|
||||
const CUSTOMER_ORDERS_QUERY = `#graphql
|
||||
${CUSTOMER_FRAGMENT}
|
||||
query CustomerOrders(
|
||||
$country: CountryCode
|
||||
$customerAccessToken: String!
|
||||
$endCursor: String
|
||||
$first: Int
|
||||
$language: LanguageCode
|
||||
$last: Int
|
||||
$startCursor: String
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customer(customerAccessToken: $customerAccessToken) {
|
||||
...CustomerOrders
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
289
examples/hydrogen-2/app/routes/account.profile.tsx
Normal file
289
examples/hydrogen-2/app/routes/account.profile.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import type {CustomerFragment} from 'storefrontapi.generated';
|
||||
import type {CustomerUpdateInput} from '@shopify/hydrogen/storefront-api-types';
|
||||
import type {ActionArgs, LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {json, redirect, type V2_MetaFunction} from '@shopify/remix-oxygen';
|
||||
import {
|
||||
Form,
|
||||
useActionData,
|
||||
useNavigation,
|
||||
useOutletContext,
|
||||
} from '@remix-run/react';
|
||||
|
||||
export type ActionResponse = {
|
||||
error: string | null;
|
||||
customer: CustomerFragment | null;
|
||||
};
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: 'Profile'}];
|
||||
};
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
const customerAccessToken = await context.session.get('customerAccessToken');
|
||||
if (!customerAccessToken) {
|
||||
return redirect('/account/login');
|
||||
}
|
||||
return json({});
|
||||
}
|
||||
|
||||
export async function action({request, context}: ActionArgs) {
|
||||
const {session, storefront} = context;
|
||||
|
||||
if (request.method !== 'PUT') {
|
||||
return json({error: 'Method not allowed'}, {status: 405});
|
||||
}
|
||||
|
||||
const form = await request.formData();
|
||||
const customerAccessToken = await session.get('customerAccessToken');
|
||||
if (!customerAccessToken) {
|
||||
return json({error: 'Unauthorized'}, {status: 401});
|
||||
}
|
||||
|
||||
try {
|
||||
const password = getPassword(form);
|
||||
const customer: CustomerUpdateInput = {};
|
||||
const validInputKeys = [
|
||||
'firstName',
|
||||
'lastName',
|
||||
'email',
|
||||
'password',
|
||||
'phone',
|
||||
] as const;
|
||||
for (const [key, value] of form.entries()) {
|
||||
if (!validInputKeys.includes(key as any)) {
|
||||
continue;
|
||||
}
|
||||
if (key === 'acceptsMarketing') {
|
||||
customer.acceptsMarketing = value === 'on';
|
||||
}
|
||||
if (typeof value === 'string' && value.length) {
|
||||
customer[key as (typeof validInputKeys)[number]] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (password) {
|
||||
customer.password = password;
|
||||
}
|
||||
|
||||
// update customer and possibly password
|
||||
const updated = await storefront.mutate(CUSTOMER_UPDATE_MUTATION, {
|
||||
variables: {
|
||||
customerAccessToken: customerAccessToken.accessToken,
|
||||
customer,
|
||||
},
|
||||
});
|
||||
|
||||
// check for mutation errors
|
||||
if (updated.customerUpdate?.customerUserErrors?.length) {
|
||||
return json(
|
||||
{error: updated.customerUpdate?.customerUserErrors[0]},
|
||||
{status: 400},
|
||||
);
|
||||
}
|
||||
|
||||
// update session with the updated access token
|
||||
if (updated.customerUpdate?.customerAccessToken?.accessToken) {
|
||||
session.set(
|
||||
'customerAccessToken',
|
||||
updated.customerUpdate?.customerAccessToken,
|
||||
);
|
||||
}
|
||||
|
||||
return json(
|
||||
{error: null, customer: updated.customerUpdate?.customer},
|
||||
{
|
||||
headers: {
|
||||
'Set-Cookie': await session.commit(),
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error: any) {
|
||||
return json({error: error.message, customer: null}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
export default function AccountProfile() {
|
||||
const account = useOutletContext<{customer: CustomerFragment}>();
|
||||
const {state} = useNavigation();
|
||||
const action = useActionData<ActionResponse>();
|
||||
const customer = action?.customer ?? account?.customer;
|
||||
|
||||
return (
|
||||
<div className="account-profile">
|
||||
<h2>My profile</h2>
|
||||
<br />
|
||||
<Form method="PUT">
|
||||
<legend>Personal information</legend>
|
||||
<fieldset>
|
||||
<label htmlFor="firstName">First name</label>
|
||||
<input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
placeholder="First name"
|
||||
aria-label="First name"
|
||||
defaultValue={customer.firstName ?? ''}
|
||||
minLength={2}
|
||||
/>
|
||||
<label htmlFor="lastName">Last name</label>
|
||||
<input
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
type="text"
|
||||
autoComplete="family-name"
|
||||
placeholder="Last name"
|
||||
aria-label="Last name"
|
||||
defaultValue={customer.lastName ?? ''}
|
||||
minLength={2}
|
||||
/>
|
||||
<label htmlFor="phone">Mobile</label>
|
||||
<input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
placeholder="Mobile"
|
||||
aria-label="Mobile"
|
||||
defaultValue={customer.phone ?? ''}
|
||||
/>
|
||||
<label htmlFor="email">Email address</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="Email address"
|
||||
aria-label="Email address"
|
||||
defaultValue={customer.email ?? ''}
|
||||
/>
|
||||
<div className="account-profile-marketing">
|
||||
<input
|
||||
id="acceptsMarketing"
|
||||
name="acceptsMarketing"
|
||||
type="checkbox"
|
||||
placeholder="Accept marketing"
|
||||
aria-label="Accept marketing"
|
||||
defaultChecked={customer.acceptsMarketing}
|
||||
/>
|
||||
<label htmlFor="acceptsMarketing">
|
||||
Subscribed to marketing communications
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<br />
|
||||
<legend>Change password (optional)</legend>
|
||||
<fieldset>
|
||||
<label htmlFor="currentPassword">Current password</label>
|
||||
<input
|
||||
id="currentPassword"
|
||||
name="currentPassword"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Current password"
|
||||
aria-label="Current password"
|
||||
minLength={8}
|
||||
/>
|
||||
|
||||
<label htmlFor="newPassword">New password</label>
|
||||
<input
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
type="password"
|
||||
placeholder="New password"
|
||||
aria-label="New password"
|
||||
minLength={8}
|
||||
/>
|
||||
|
||||
<label htmlFor="newPasswordConfirm">New password (confirm)</label>
|
||||
<input
|
||||
id="newPasswordConfirm"
|
||||
name="newPasswordConfirm"
|
||||
type="password"
|
||||
placeholder="New password (confirm)"
|
||||
aria-label="New password confirm"
|
||||
minLength={8}
|
||||
/>
|
||||
<small>Passwords must be at least 8 characters.</small>
|
||||
</fieldset>
|
||||
{action?.error ? (
|
||||
<p>
|
||||
<mark>
|
||||
<small>{action.error}</small>
|
||||
</mark>
|
||||
</p>
|
||||
) : (
|
||||
<br />
|
||||
)}
|
||||
<button type="submit" disabled={state !== 'idle'}>
|
||||
{state !== 'idle' ? 'Updating' : 'Update'}
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getPassword(form: FormData): string | undefined {
|
||||
let password;
|
||||
const currentPassword = form.get('currentPassword');
|
||||
const newPassword = form.get('newPassword');
|
||||
const newPasswordConfirm = form.get('newPasswordConfirm');
|
||||
|
||||
let passwordError;
|
||||
if (newPassword && !currentPassword) {
|
||||
passwordError = new Error('Current password is required.');
|
||||
}
|
||||
|
||||
if (newPassword && newPassword !== newPasswordConfirm) {
|
||||
passwordError = new Error('New passwords must match.');
|
||||
}
|
||||
|
||||
if (newPassword && currentPassword && newPassword === currentPassword) {
|
||||
passwordError = new Error(
|
||||
'New password must be different than current password.',
|
||||
);
|
||||
}
|
||||
|
||||
if (passwordError) {
|
||||
throw passwordError;
|
||||
}
|
||||
|
||||
if (currentPassword && newPassword) {
|
||||
password = newPassword;
|
||||
} else {
|
||||
password = currentPassword;
|
||||
}
|
||||
|
||||
return String(password);
|
||||
}
|
||||
|
||||
const CUSTOMER_UPDATE_MUTATION = `#graphql
|
||||
# https://shopify.dev/docs/api/storefront/latest/mutations/customerUpdate
|
||||
mutation customerUpdate(
|
||||
$customerAccessToken: String!,
|
||||
$customer: CustomerUpdateInput!
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
) @inContext(language: $language, country: $country) {
|
||||
customerUpdate(customerAccessToken: $customerAccessToken, customer: $customer) {
|
||||
customer {
|
||||
acceptsMarketing
|
||||
email
|
||||
firstName
|
||||
id
|
||||
lastName
|
||||
phone
|
||||
}
|
||||
customerAccessToken {
|
||||
accessToken
|
||||
expiresAt
|
||||
}
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
203
examples/hydrogen-2/app/routes/account.tsx
Normal file
203
examples/hydrogen-2/app/routes/account.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import {Form, NavLink, Outlet, useLoaderData} from '@remix-run/react';
|
||||
import {json, redirect, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import type {CustomerFragment} from 'storefrontapi.generated';
|
||||
|
||||
export function shouldRevalidate() {
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function loader({request, context}: LoaderArgs) {
|
||||
const {session, storefront} = context;
|
||||
const {pathname} = new URL(request.url);
|
||||
const customerAccessToken = await session.get('customerAccessToken');
|
||||
const isLoggedIn = Boolean(customerAccessToken?.accessToken);
|
||||
const isAccountHome = pathname === '/account' || pathname === '/account/';
|
||||
const isPrivateRoute =
|
||||
/^\/account\/(orders|orders\/.*|profile|addresses|addresses\/.*)$/.test(
|
||||
pathname,
|
||||
);
|
||||
|
||||
if (!isLoggedIn) {
|
||||
if (isPrivateRoute || isAccountHome) {
|
||||
session.unset('customerAccessToken');
|
||||
return redirect('/account/login', {
|
||||
headers: {
|
||||
'Set-Cookie': await session.commit(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// public subroute such as /account/login...
|
||||
return json({
|
||||
isLoggedIn: false,
|
||||
isAccountHome,
|
||||
isPrivateRoute,
|
||||
customer: null,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// loggedIn, default redirect to the orders page
|
||||
if (isAccountHome) {
|
||||
return redirect('/account/orders');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const {customer} = await storefront.query(CUSTOMER_QUERY, {
|
||||
variables: {
|
||||
customerAccessToken: customerAccessToken.accessToken,
|
||||
country: storefront.i18n.country,
|
||||
language: storefront.i18n.language,
|
||||
},
|
||||
cache: storefront.CacheNone(),
|
||||
});
|
||||
|
||||
if (!customer) {
|
||||
throw new Error('Customer not found');
|
||||
}
|
||||
|
||||
return json(
|
||||
{isLoggedIn, isPrivateRoute, isAccountHome, customer},
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('There was a problem loading account', error);
|
||||
session.unset('customerAccessToken');
|
||||
return redirect('/account/login', {
|
||||
headers: {
|
||||
'Set-Cookie': await session.commit(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Acccount() {
|
||||
const {customer, isPrivateRoute, isAccountHome} =
|
||||
useLoaderData<typeof loader>();
|
||||
|
||||
if (!isPrivateRoute && !isAccountHome) {
|
||||
return <Outlet context={{customer}} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AccountLayout customer={customer as CustomerFragment}>
|
||||
<br />
|
||||
<br />
|
||||
<Outlet context={{customer}} />
|
||||
</AccountLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountLayout({
|
||||
customer,
|
||||
children,
|
||||
}: {
|
||||
customer: CustomerFragment;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const heading = customer
|
||||
? customer.firstName
|
||||
? `Welcome, ${customer.firstName}`
|
||||
: `Welcome to your account.`
|
||||
: 'Account Details';
|
||||
|
||||
return (
|
||||
<div className="account">
|
||||
<h1>{heading}</h1>
|
||||
<br />
|
||||
<AcccountMenu />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AcccountMenu() {
|
||||
function isActiveStyle({
|
||||
isActive,
|
||||
isPending,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
return {
|
||||
fontWeight: isActive ? 'bold' : '',
|
||||
color: isPending ? 'grey' : 'black',
|
||||
};
|
||||
}
|
||||
return (
|
||||
<nav role="navigation">
|
||||
<NavLink to="/account/orders" style={isActiveStyle}>
|
||||
Orders
|
||||
</NavLink>
|
||||
|
|
||||
<NavLink to="/account/profile" style={isActiveStyle}>
|
||||
Profile
|
||||
</NavLink>
|
||||
|
|
||||
<NavLink to="/account/addresses" style={isActiveStyle}>
|
||||
Addresses
|
||||
</NavLink>
|
||||
|
|
||||
<Logout />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function Logout() {
|
||||
return (
|
||||
<Form className="account-logout" method="POST" action="/account/logout">
|
||||
<button type="submit">Sign out</button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export const CUSTOMER_FRAGMENT = `#graphql
|
||||
fragment Customer on Customer {
|
||||
acceptsMarketing
|
||||
addresses(first: 6) {
|
||||
nodes {
|
||||
...Address
|
||||
}
|
||||
}
|
||||
defaultAddress {
|
||||
...Address
|
||||
}
|
||||
email
|
||||
firstName
|
||||
lastName
|
||||
numberOfOrders
|
||||
phone
|
||||
}
|
||||
fragment Address on MailingAddress {
|
||||
id
|
||||
formatted
|
||||
firstName
|
||||
lastName
|
||||
company
|
||||
address1
|
||||
address2
|
||||
country
|
||||
province
|
||||
city
|
||||
zip
|
||||
phone
|
||||
}
|
||||
` as const;
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/queries/customer
|
||||
const CUSTOMER_QUERY = `#graphql
|
||||
query Customer(
|
||||
$customerAccessToken: String!
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customer(customerAccessToken: $customerAccessToken) {
|
||||
...Customer
|
||||
}
|
||||
}
|
||||
${CUSTOMER_FRAGMENT}
|
||||
` as const;
|
||||
@@ -0,0 +1,157 @@
|
||||
import type {ActionArgs, LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {json, redirect} from '@shopify/remix-oxygen';
|
||||
import {Form, useActionData, type V2_MetaFunction} from '@remix-run/react';
|
||||
|
||||
type ActionResponse = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: 'Activate Account'}];
|
||||
};
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
if (await context.session.get('customerAccessToken')) {
|
||||
return redirect('/account');
|
||||
}
|
||||
return json({});
|
||||
}
|
||||
|
||||
export async function action({request, context, params}: ActionArgs) {
|
||||
const {session, storefront} = context;
|
||||
const {id, activationToken} = params;
|
||||
|
||||
if (request.method !== 'POST') {
|
||||
return json({error: 'Method not allowed'}, {status: 405});
|
||||
}
|
||||
|
||||
try {
|
||||
if (!id || !activationToken) {
|
||||
throw new Error('Missing token. The link you followed might be wrong.');
|
||||
}
|
||||
|
||||
const form = await request.formData();
|
||||
const password = form.has('password') ? String(form.get('password')) : null;
|
||||
const passwordConfirm = form.has('passwordConfirm')
|
||||
? String(form.get('passwordConfirm'))
|
||||
: null;
|
||||
|
||||
const validPasswords =
|
||||
password && passwordConfirm && password === passwordConfirm;
|
||||
|
||||
if (!validPasswords) {
|
||||
throw new Error('Passwords do not match');
|
||||
}
|
||||
|
||||
const {customerActivate} = await storefront.mutate(
|
||||
CUSTOMER_ACTIVATE_MUTATION,
|
||||
{
|
||||
variables: {
|
||||
id: `gid://shopify/Customer/${id}`,
|
||||
input: {
|
||||
password,
|
||||
activationToken,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (customerActivate?.customerUserErrors?.length) {
|
||||
throw new Error(customerActivate.customerUserErrors[0].message);
|
||||
}
|
||||
|
||||
const {customerAccessToken} = customerActivate ?? {};
|
||||
if (!customerAccessToken) {
|
||||
throw new Error('Could not activate account.');
|
||||
}
|
||||
session.set('customerAccessToken', customerAccessToken);
|
||||
|
||||
return redirect('/account', {
|
||||
headers: {
|
||||
'Set-Cookie': await session.commit(),
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return json({error: error.message}, {status: 400});
|
||||
}
|
||||
return json({error}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Activate() {
|
||||
const action = useActionData<ActionResponse>();
|
||||
const error = action?.error ?? null;
|
||||
|
||||
return (
|
||||
<div className="account-activate">
|
||||
<h1>Activate Account.</h1>
|
||||
<p>Create your password to activate your account.</p>
|
||||
<Form method="POST">
|
||||
<fieldset>
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Password"
|
||||
aria-label="Password"
|
||||
minLength={8}
|
||||
required
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
/>
|
||||
<label htmlFor="passwordConfirm">Re-enter password</label>
|
||||
<input
|
||||
id="passwordConfirm"
|
||||
name="passwordConfirm"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Re-enter password"
|
||||
aria-label="Re-enter password"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</fieldset>
|
||||
{error ? (
|
||||
<p>
|
||||
<mark>
|
||||
<small>{error}</small>
|
||||
</mark>
|
||||
</p>
|
||||
) : (
|
||||
<br />
|
||||
)}
|
||||
<button
|
||||
className="bg-primary text-contrast rounded py-2 px-4 focus:shadow-outline block w-full"
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customeractivate
|
||||
const CUSTOMER_ACTIVATE_MUTATION = `#graphql
|
||||
mutation customerActivate(
|
||||
$id: ID!,
|
||||
$input: CustomerActivateInput!,
|
||||
$country: CountryCode,
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customerActivate(id: $id, input: $input) {
|
||||
customerAccessToken {
|
||||
accessToken
|
||||
expiresAt
|
||||
}
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
143
examples/hydrogen-2/app/routes/account_.login.tsx
Normal file
143
examples/hydrogen-2/app/routes/account_.login.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
json,
|
||||
redirect,
|
||||
type ActionArgs,
|
||||
type LoaderArgs,
|
||||
type V2_MetaFunction,
|
||||
} from '@shopify/remix-oxygen';
|
||||
import {Form, Link, useActionData} from '@remix-run/react';
|
||||
|
||||
type ActionResponse = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: 'Login'}];
|
||||
};
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
if (await context.session.get('customerAccessToken')) {
|
||||
return redirect('/account');
|
||||
}
|
||||
return json({});
|
||||
}
|
||||
|
||||
export async function action({request, context}: ActionArgs) {
|
||||
const {session, storefront} = context;
|
||||
|
||||
if (request.method !== 'POST') {
|
||||
return json({error: 'Method not allowed'}, {status: 405});
|
||||
}
|
||||
|
||||
try {
|
||||
const form = await request.formData();
|
||||
const email = String(form.has('email') ? form.get('email') : '');
|
||||
const password = String(form.has('password') ? form.get('password') : '');
|
||||
const validInputs = Boolean(email && password);
|
||||
|
||||
if (!validInputs) {
|
||||
throw new Error('Please provide both an email and a password.');
|
||||
}
|
||||
|
||||
const {customerAccessTokenCreate} = await storefront.mutate(
|
||||
LOGIN_MUTATION,
|
||||
{
|
||||
variables: {
|
||||
input: {email, password},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!customerAccessTokenCreate?.customerAccessToken?.accessToken) {
|
||||
throw new Error(customerAccessTokenCreate?.customerUserErrors[0].message);
|
||||
}
|
||||
|
||||
const {customerAccessToken} = customerAccessTokenCreate;
|
||||
session.set('customerAccessToken', customerAccessToken);
|
||||
|
||||
return redirect('/account', {
|
||||
headers: {
|
||||
'Set-Cookie': await session.commit(),
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return json({error: error.message}, {status: 400});
|
||||
}
|
||||
return json({error}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const data = useActionData<ActionResponse>();
|
||||
const error = data?.error || null;
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<h1>Sign in.</h1>
|
||||
<Form method="POST">
|
||||
<fieldset>
|
||||
<label htmlFor="email">Email address</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="Email address"
|
||||
aria-label="Email address"
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
/>
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Password"
|
||||
aria-label="Password"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</fieldset>
|
||||
{error ? (
|
||||
<p>
|
||||
<mark>
|
||||
<small>{error}</small>
|
||||
</mark>
|
||||
</p>
|
||||
) : (
|
||||
<br />
|
||||
)}
|
||||
<button type="submit">Sign in</button>
|
||||
</Form>
|
||||
<br />
|
||||
<div>
|
||||
<p>
|
||||
<Link to="/account/recover">Forgot password →</Link>
|
||||
</p>
|
||||
<p>
|
||||
<Link to="/account/register">Register →</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customeraccesstokencreate
|
||||
const LOGIN_MUTATION = `#graphql
|
||||
mutation login($input: CustomerAccessTokenCreateInput!) {
|
||||
customerAccessTokenCreate(input: $input) {
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
customerAccessToken {
|
||||
accessToken
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
33
examples/hydrogen-2/app/routes/account_.logout.tsx
Normal file
33
examples/hydrogen-2/app/routes/account_.logout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
json,
|
||||
redirect,
|
||||
type ActionArgs,
|
||||
type V2_MetaFunction,
|
||||
} from '@shopify/remix-oxygen';
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: 'Logout'}];
|
||||
};
|
||||
|
||||
export async function loader() {
|
||||
return redirect('/account/login');
|
||||
}
|
||||
|
||||
export async function action({request, context}: ActionArgs) {
|
||||
const {session} = context;
|
||||
session.unset('customerAccessToken');
|
||||
|
||||
if (request.method !== 'POST') {
|
||||
return json({error: 'Method not allowed'}, {status: 405});
|
||||
}
|
||||
|
||||
return redirect('/', {
|
||||
headers: {
|
||||
'Set-Cookie': await session.commit(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default function Logout() {
|
||||
return null;
|
||||
}
|
||||
124
examples/hydrogen-2/app/routes/account_.recover.tsx
Normal file
124
examples/hydrogen-2/app/routes/account_.recover.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import {json, redirect, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {Form, Link, useActionData} from '@remix-run/react';
|
||||
|
||||
type ActionResponse = {
|
||||
error?: string;
|
||||
resetRequested?: boolean;
|
||||
};
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
const customerAccessToken = await context.session.get('customerAccessToken');
|
||||
if (customerAccessToken) {
|
||||
return redirect('/account');
|
||||
}
|
||||
|
||||
return json({});
|
||||
}
|
||||
|
||||
export async function action({request, context}: LoaderArgs) {
|
||||
const {storefront} = context;
|
||||
const form = await request.formData();
|
||||
const email = form.has('email') ? String(form.get('email')) : null;
|
||||
|
||||
if (request.method !== 'POST') {
|
||||
return json({error: 'Method not allowed'}, {status: 405});
|
||||
}
|
||||
|
||||
try {
|
||||
if (!email) {
|
||||
throw new Error('Please provide an email.');
|
||||
}
|
||||
await storefront.mutate(CUSTOMER_RECOVER_MUTATION, {
|
||||
variables: {email},
|
||||
});
|
||||
|
||||
return json({resetRequested: true});
|
||||
} catch (error: unknown) {
|
||||
const resetRequested = false;
|
||||
if (error instanceof Error) {
|
||||
return json({error: error.message, resetRequested}, {status: 400});
|
||||
}
|
||||
return json({error, resetRequested}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Recover() {
|
||||
const action = useActionData<ActionResponse>();
|
||||
|
||||
return (
|
||||
<div className="account-recover">
|
||||
<div>
|
||||
{action?.resetRequested ? (
|
||||
<>
|
||||
<h1>Request Sent.</h1>
|
||||
<p>
|
||||
If that email address is in our system, you will receive an email
|
||||
with instructions about how to reset your password in a few
|
||||
minutes.
|
||||
</p>
|
||||
<br />
|
||||
<Link to="/account/login">Return to Login</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1>Forgot Password.</h1>
|
||||
<p>
|
||||
Enter the email address associated with your account to receive a
|
||||
link to reset your password.
|
||||
</p>
|
||||
<br />
|
||||
<Form method="POST">
|
||||
<fieldset>
|
||||
<label htmlFor="email">Email</label>
|
||||
<input
|
||||
aria-label="Email address"
|
||||
autoComplete="email"
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="Email address"
|
||||
required
|
||||
type="email"
|
||||
/>
|
||||
</fieldset>
|
||||
{action?.error ? (
|
||||
<p>
|
||||
<mark>
|
||||
<small>{action.error}</small>
|
||||
</mark>
|
||||
</p>
|
||||
) : (
|
||||
<br />
|
||||
)}
|
||||
<button type="submit">Request Reset Link</button>
|
||||
</Form>
|
||||
<div>
|
||||
<br />
|
||||
<p>
|
||||
<Link to="/account/login">Login →</Link>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customerrecover
|
||||
const CUSTOMER_RECOVER_MUTATION = `#graphql
|
||||
mutation customerRecover(
|
||||
$email: String!,
|
||||
$country: CountryCode,
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customerRecover(email: $email) {
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
207
examples/hydrogen-2/app/routes/account_.register.tsx
Normal file
207
examples/hydrogen-2/app/routes/account_.register.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
json,
|
||||
redirect,
|
||||
type ActionFunction,
|
||||
type LoaderArgs,
|
||||
} from '@shopify/remix-oxygen';
|
||||
import {Form, Link, useActionData} from '@remix-run/react';
|
||||
import type {CustomerCreateMutation} from 'storefrontapi.generated';
|
||||
|
||||
type ActionResponse = {
|
||||
error: string | null;
|
||||
newCustomer:
|
||||
| NonNullable<CustomerCreateMutation['customerCreate']>['customer']
|
||||
| null;
|
||||
};
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
const customerAccessToken = await context.session.get('customerAccessToken');
|
||||
if (customerAccessToken) {
|
||||
return redirect('/account');
|
||||
}
|
||||
|
||||
return json({});
|
||||
}
|
||||
|
||||
export const action: ActionFunction = async ({request, context}) => {
|
||||
if (request.method !== 'POST') {
|
||||
return json({error: 'Method not allowed'}, {status: 405});
|
||||
}
|
||||
|
||||
const {storefront, session} = context;
|
||||
const form = await request.formData();
|
||||
const email = String(form.has('email') ? form.get('email') : '');
|
||||
const password = form.has('password') ? String(form.get('password')) : null;
|
||||
const passwordConfirm = form.has('passwordConfirm')
|
||||
? String(form.get('passwordConfirm'))
|
||||
: null;
|
||||
|
||||
const validPasswords =
|
||||
password && passwordConfirm && password === passwordConfirm;
|
||||
|
||||
const validInputs = Boolean(email && password);
|
||||
try {
|
||||
if (!validPasswords) {
|
||||
throw new Error('Passwords do not match');
|
||||
}
|
||||
|
||||
if (!validInputs) {
|
||||
throw new Error('Please provide both an email and a password.');
|
||||
}
|
||||
|
||||
const {customerCreate} = await storefront.mutate(CUSTOMER_CREATE_MUTATION, {
|
||||
variables: {
|
||||
input: {email, password},
|
||||
},
|
||||
});
|
||||
|
||||
if (customerCreate?.customerUserErrors?.length) {
|
||||
throw new Error(customerCreate?.customerUserErrors[0].message);
|
||||
}
|
||||
|
||||
const newCustomer = customerCreate?.customer;
|
||||
if (!newCustomer?.id) {
|
||||
throw new Error('Could not create customer');
|
||||
}
|
||||
|
||||
// get an access token for the new customer
|
||||
const {customerAccessTokenCreate} = await storefront.mutate(
|
||||
REGISTER_LOGIN_MUTATION,
|
||||
{
|
||||
variables: {
|
||||
input: {
|
||||
email,
|
||||
password,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!customerAccessTokenCreate?.customerAccessToken?.accessToken) {
|
||||
throw new Error('Missing access token');
|
||||
}
|
||||
session.set(
|
||||
'customerAccessToken',
|
||||
customerAccessTokenCreate?.customerAccessToken,
|
||||
);
|
||||
|
||||
return json(
|
||||
{error: null, newCustomer},
|
||||
{
|
||||
status: 302,
|
||||
headers: {
|
||||
'Set-Cookie': await session.commit(),
|
||||
Location: '/account',
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return json({error: error.message}, {status: 400});
|
||||
}
|
||||
return json({error}, {status: 400});
|
||||
}
|
||||
};
|
||||
|
||||
export default function Register() {
|
||||
const data = useActionData<ActionResponse>();
|
||||
const error = data?.error || null;
|
||||
return (
|
||||
<div className="login">
|
||||
<h1>Register.</h1>
|
||||
<Form method="POST">
|
||||
<fieldset>
|
||||
<label htmlFor="email">Email address</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="Email address"
|
||||
aria-label="Email address"
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
/>
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Password"
|
||||
aria-label="Password"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
<label htmlFor="passwordConfirm">Re-enter password</label>
|
||||
<input
|
||||
id="passwordConfirm"
|
||||
name="passwordConfirm"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Re-enter password"
|
||||
aria-label="Re-enter password"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</fieldset>
|
||||
{error ? (
|
||||
<p>
|
||||
<mark>
|
||||
<small>{error}</small>
|
||||
</mark>
|
||||
</p>
|
||||
) : (
|
||||
<br />
|
||||
)}
|
||||
<button type="submit">Register</button>
|
||||
</Form>
|
||||
<br />
|
||||
<p>
|
||||
<Link to="/account/login">Login →</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customerCreate
|
||||
const CUSTOMER_CREATE_MUTATION = `#graphql
|
||||
mutation customerCreate(
|
||||
$input: CustomerCreateInput!,
|
||||
$country: CountryCode,
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customerCreate(input: $input) {
|
||||
customer {
|
||||
id
|
||||
}
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customeraccesstokencreate
|
||||
const REGISTER_LOGIN_MUTATION = `#graphql
|
||||
mutation registerLogin(
|
||||
$input: CustomerAccessTokenCreateInput!,
|
||||
$country: CountryCode,
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customerAccessTokenCreate(input: $input) {
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
customerAccessToken {
|
||||
accessToken
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
@@ -0,0 +1,136 @@
|
||||
import {type ActionArgs, json, redirect} from '@shopify/remix-oxygen';
|
||||
import {Form, useActionData, type V2_MetaFunction} from '@remix-run/react';
|
||||
|
||||
type ActionResponse = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: 'Reset Password'}];
|
||||
};
|
||||
|
||||
export async function action({request, context, params}: ActionArgs) {
|
||||
if (request.method !== 'POST') {
|
||||
return json({error: 'Method not allowed'}, {status: 405});
|
||||
}
|
||||
const {id, resetToken} = params;
|
||||
const {session, storefront} = context;
|
||||
|
||||
try {
|
||||
if (!id || !resetToken) {
|
||||
throw new Error('customer token or id not found');
|
||||
}
|
||||
|
||||
const form = await request.formData();
|
||||
const password = form.has('password') ? String(form.get('password')) : '';
|
||||
const passwordConfirm = form.has('passwordConfirm')
|
||||
? String(form.get('passwordConfirm'))
|
||||
: '';
|
||||
const validInputs = Boolean(password && passwordConfirm);
|
||||
if (validInputs && password !== passwordConfirm) {
|
||||
throw new Error('Please provide matching passwords');
|
||||
}
|
||||
|
||||
const {customerReset} = await storefront.mutate(CUSTOMER_RESET_MUTATION, {
|
||||
variables: {
|
||||
id: `gid://shopify/Customer/${id}`,
|
||||
input: {password, resetToken},
|
||||
},
|
||||
});
|
||||
|
||||
if (customerReset?.customerUserErrors?.length) {
|
||||
throw new Error(customerReset?.customerUserErrors[0].message);
|
||||
}
|
||||
|
||||
if (!customerReset?.customerAccessToken) {
|
||||
throw new Error('Access token not found. Please try again.');
|
||||
}
|
||||
session.set('customerAccessToken', customerReset.customerAccessToken);
|
||||
|
||||
return redirect('/account', {
|
||||
headers: {
|
||||
'Set-Cookie': await session.commit(),
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return json({error: error.message}, {status: 400});
|
||||
}
|
||||
return json({error}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Reset() {
|
||||
const action = useActionData<ActionResponse>();
|
||||
|
||||
return (
|
||||
<div className="account-reset">
|
||||
<h1>Reset Password.</h1>
|
||||
<p>Enter a new password for your account.</p>
|
||||
<Form method="POST">
|
||||
<fieldset>
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
aria-label="Password"
|
||||
autoComplete="current-password"
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
id="password"
|
||||
minLength={8}
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
required
|
||||
type="password"
|
||||
/>
|
||||
<label htmlFor="passwordConfirm">Re-enter password</label>
|
||||
<input
|
||||
aria-label="Re-enter password"
|
||||
autoComplete="current-password"
|
||||
id="passwordConfirm"
|
||||
minLength={8}
|
||||
name="passwordConfirm"
|
||||
placeholder="Re-enter password"
|
||||
required
|
||||
type="password"
|
||||
/>
|
||||
</fieldset>
|
||||
{action?.error ? (
|
||||
<p>
|
||||
<mark>
|
||||
<small>{action.error}</small>
|
||||
</mark>
|
||||
</p>
|
||||
) : (
|
||||
<br />
|
||||
)}
|
||||
<button type="submit">Reset</button>
|
||||
</Form>
|
||||
<br />
|
||||
<p>
|
||||
<a href="/account/login">Back to login →</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customerreset
|
||||
const CUSTOMER_RESET_MUTATION = `#graphql
|
||||
mutation customerReset(
|
||||
$id: ID!,
|
||||
$input: CustomerResetInput!
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customerReset(id: $id, input: $input) {
|
||||
customerAccessToken {
|
||||
accessToken
|
||||
expiresAt
|
||||
}
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
342
examples/hydrogen-2/app/routes/api.predictive-search.tsx
Normal file
342
examples/hydrogen-2/app/routes/api.predictive-search.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import {json, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import type {
|
||||
NormalizedPredictiveSearch,
|
||||
NormalizedPredictiveSearchResults,
|
||||
} from '~/components/Search';
|
||||
import {NO_PREDICTIVE_SEARCH_RESULTS} from '~/components/Search';
|
||||
|
||||
import type {
|
||||
PredictiveArticleFragment,
|
||||
PredictiveCollectionFragment,
|
||||
PredictivePageFragment,
|
||||
PredictiveProductFragment,
|
||||
PredictiveQueryFragment,
|
||||
PredictiveSearchQuery,
|
||||
} from 'storefrontapi.generated';
|
||||
|
||||
type PredictiveSearchResultItem =
|
||||
| PredictiveArticleFragment
|
||||
| PredictiveCollectionFragment
|
||||
| PredictivePageFragment
|
||||
| PredictiveProductFragment;
|
||||
|
||||
type PredictiveSearchTypes =
|
||||
| 'ARTICLE'
|
||||
| 'COLLECTION'
|
||||
| 'PAGE'
|
||||
| 'PRODUCT'
|
||||
| 'QUERY';
|
||||
|
||||
const DEFAULT_SEARCH_TYPES: PredictiveSearchTypes[] = [
|
||||
'ARTICLE',
|
||||
'COLLECTION',
|
||||
'PAGE',
|
||||
'PRODUCT',
|
||||
'QUERY',
|
||||
];
|
||||
|
||||
/**
|
||||
* Fetches the search results from the predictive search API
|
||||
* requested by the SearchForm component
|
||||
*/
|
||||
export async function action({request, params, context}: LoaderArgs) {
|
||||
if (request.method !== 'POST') {
|
||||
throw new Error('Invalid request method');
|
||||
}
|
||||
|
||||
const search = await fetchPredictiveSearchResults({
|
||||
params,
|
||||
request,
|
||||
context,
|
||||
});
|
||||
|
||||
return json(search);
|
||||
}
|
||||
|
||||
async function fetchPredictiveSearchResults({
|
||||
params,
|
||||
request,
|
||||
context,
|
||||
}: Pick<LoaderArgs, 'params' | 'context' | 'request'>) {
|
||||
const url = new URL(request.url);
|
||||
const searchParams = new URLSearchParams(url.search);
|
||||
let body;
|
||||
try {
|
||||
body = await request.formData();
|
||||
} catch (error) {}
|
||||
const searchTerm = String(body?.get('q') || searchParams.get('q') || '');
|
||||
const limit = Number(body?.get('limit') || searchParams.get('limit') || 10);
|
||||
const rawTypes = String(
|
||||
body?.get('type') || searchParams.get('type') || 'ANY',
|
||||
);
|
||||
const searchTypes =
|
||||
rawTypes === 'ANY'
|
||||
? DEFAULT_SEARCH_TYPES
|
||||
: rawTypes
|
||||
.split(',')
|
||||
.map((t) => t.toUpperCase() as PredictiveSearchTypes)
|
||||
.filter((t) => DEFAULT_SEARCH_TYPES.includes(t));
|
||||
|
||||
if (!searchTerm) {
|
||||
return {
|
||||
searchResults: {results: null, totalResults: 0},
|
||||
searchTerm,
|
||||
searchTypes,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await context.storefront.query(PREDICTIVE_SEARCH_QUERY, {
|
||||
variables: {
|
||||
limit,
|
||||
limitScope: 'EACH',
|
||||
searchTerm,
|
||||
types: searchTypes,
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
throw new Error('No data returned from Shopify API');
|
||||
}
|
||||
|
||||
const searchResults = normalizePredictiveSearchResults(
|
||||
data.predictiveSearch,
|
||||
params.locale,
|
||||
);
|
||||
|
||||
return {searchResults, searchTerm, searchTypes};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize results and apply tracking qurery parameters to each result url
|
||||
* @param predictiveSearch
|
||||
* @param locale
|
||||
*/
|
||||
export function normalizePredictiveSearchResults(
|
||||
predictiveSearch: PredictiveSearchQuery['predictiveSearch'],
|
||||
locale: LoaderArgs['params']['locale'],
|
||||
): NormalizedPredictiveSearch {
|
||||
let totalResults = 0;
|
||||
if (!predictiveSearch) {
|
||||
return {
|
||||
results: NO_PREDICTIVE_SEARCH_RESULTS,
|
||||
totalResults,
|
||||
};
|
||||
}
|
||||
|
||||
function applyTrackingParams(
|
||||
resource: PredictiveSearchResultItem | PredictiveQueryFragment,
|
||||
params?: string,
|
||||
) {
|
||||
if (params) {
|
||||
return resource.trackingParameters
|
||||
? `?${params}&${resource.trackingParameters}`
|
||||
: `?${params}`;
|
||||
} else {
|
||||
return resource.trackingParameters
|
||||
? `?${resource.trackingParameters}`
|
||||
: '';
|
||||
}
|
||||
}
|
||||
|
||||
const localePrefix = locale ? `/${locale}` : '';
|
||||
const results: NormalizedPredictiveSearchResults = [];
|
||||
|
||||
if (predictiveSearch.queries.length) {
|
||||
results.push({
|
||||
type: 'queries',
|
||||
items: predictiveSearch.queries.map((query: PredictiveQueryFragment) => {
|
||||
const trackingParams = applyTrackingParams(
|
||||
query,
|
||||
`q=${encodeURIComponent(query.text)}`,
|
||||
);
|
||||
|
||||
totalResults++;
|
||||
return {
|
||||
__typename: query.__typename,
|
||||
handle: '',
|
||||
id: query.text,
|
||||
image: undefined,
|
||||
title: query.text,
|
||||
styledTitle: query.styledText,
|
||||
url: `${localePrefix}/search${trackingParams}`,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (predictiveSearch.products.length) {
|
||||
results.push({
|
||||
type: 'products',
|
||||
items: predictiveSearch.products.map(
|
||||
(product: PredictiveProductFragment) => {
|
||||
totalResults++;
|
||||
const trackingParams = applyTrackingParams(product);
|
||||
return {
|
||||
__typename: product.__typename,
|
||||
handle: product.handle,
|
||||
id: product.id,
|
||||
image: product.variants?.nodes?.[0]?.image,
|
||||
title: product.title,
|
||||
url: `${localePrefix}/products/${product.handle}${trackingParams}`,
|
||||
price: product.variants.nodes[0].price,
|
||||
};
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (predictiveSearch.collections.length) {
|
||||
results.push({
|
||||
type: 'collections',
|
||||
items: predictiveSearch.collections.map(
|
||||
(collection: PredictiveCollectionFragment) => {
|
||||
totalResults++;
|
||||
const trackingParams = applyTrackingParams(collection);
|
||||
return {
|
||||
__typename: collection.__typename,
|
||||
handle: collection.handle,
|
||||
id: collection.id,
|
||||
image: collection.image,
|
||||
title: collection.title,
|
||||
url: `${localePrefix}/collections/${collection.handle}${trackingParams}`,
|
||||
};
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (predictiveSearch.pages.length) {
|
||||
results.push({
|
||||
type: 'pages',
|
||||
items: predictiveSearch.pages.map((page: PredictivePageFragment) => {
|
||||
totalResults++;
|
||||
const trackingParams = applyTrackingParams(page);
|
||||
return {
|
||||
__typename: page.__typename,
|
||||
handle: page.handle,
|
||||
id: page.id,
|
||||
image: undefined,
|
||||
title: page.title,
|
||||
url: `${localePrefix}/pages/${page.handle}${trackingParams}`,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (predictiveSearch.articles.length) {
|
||||
results.push({
|
||||
type: 'articles',
|
||||
items: predictiveSearch.articles.map(
|
||||
(article: PredictiveArticleFragment) => {
|
||||
totalResults++;
|
||||
const trackingParams = applyTrackingParams(article);
|
||||
return {
|
||||
__typename: article.__typename,
|
||||
handle: article.handle,
|
||||
id: article.id,
|
||||
image: article.image,
|
||||
title: article.title,
|
||||
url: `${localePrefix}/blog/${article.handle}${trackingParams}`,
|
||||
};
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return {results, totalResults};
|
||||
}
|
||||
|
||||
const PREDICTIVE_SEARCH_QUERY = `#graphql
|
||||
fragment PredictiveArticle on Article {
|
||||
__typename
|
||||
id
|
||||
title
|
||||
handle
|
||||
image {
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
trackingParameters
|
||||
}
|
||||
fragment PredictiveCollection on Collection {
|
||||
__typename
|
||||
id
|
||||
title
|
||||
handle
|
||||
image {
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
trackingParameters
|
||||
}
|
||||
fragment PredictivePage on Page {
|
||||
__typename
|
||||
id
|
||||
title
|
||||
handle
|
||||
trackingParameters
|
||||
}
|
||||
fragment PredictiveProduct on Product {
|
||||
__typename
|
||||
id
|
||||
title
|
||||
handle
|
||||
trackingParameters
|
||||
variants(first: 1) {
|
||||
nodes {
|
||||
id
|
||||
image {
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
price {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fragment PredictiveQuery on SearchQuerySuggestion {
|
||||
__typename
|
||||
text
|
||||
styledText
|
||||
trackingParameters
|
||||
}
|
||||
query predictiveSearch(
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
$limit: Int!
|
||||
$limitScope: PredictiveSearchLimitScope!
|
||||
$searchTerm: String!
|
||||
$types: [PredictiveSearchType!]
|
||||
) @inContext(country: $country, language: $language) {
|
||||
predictiveSearch(
|
||||
limit: $limit,
|
||||
limitScope: $limitScope,
|
||||
query: $searchTerm,
|
||||
types: $types,
|
||||
) {
|
||||
articles {
|
||||
...PredictiveArticle
|
||||
}
|
||||
collections {
|
||||
...PredictiveCollection
|
||||
}
|
||||
pages {
|
||||
...PredictivePage
|
||||
}
|
||||
products {
|
||||
...PredictiveProduct
|
||||
}
|
||||
queries {
|
||||
...PredictiveQuery
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
@@ -0,0 +1,88 @@
|
||||
import type {V2_MetaFunction} from '@shopify/remix-oxygen';
|
||||
import {json, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {useLoaderData} from '@remix-run/react';
|
||||
import {Image} from '@shopify/hydrogen';
|
||||
|
||||
export const meta: V2_MetaFunction = ({data}) => {
|
||||
return [{title: `Hydrogen | ${data.article.title} article`}];
|
||||
};
|
||||
|
||||
export async function loader({params, context}: LoaderArgs) {
|
||||
const {blogHandle, articleHandle} = params;
|
||||
|
||||
if (!articleHandle || !blogHandle) {
|
||||
throw new Response('Not found', {status: 404});
|
||||
}
|
||||
|
||||
const {blog} = await context.storefront.query(ARTICLE_QUERY, {
|
||||
variables: {blogHandle, articleHandle},
|
||||
});
|
||||
|
||||
if (!blog?.articleByHandle) {
|
||||
throw new Response(null, {status: 404});
|
||||
}
|
||||
|
||||
const article = blog.articleByHandle;
|
||||
|
||||
return json({article});
|
||||
}
|
||||
|
||||
export default function Article() {
|
||||
const {article} = useLoaderData<typeof loader>();
|
||||
const {title, image, contentHtml, author} = article;
|
||||
|
||||
const publishedDate = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(new Date(article.publishedAt));
|
||||
|
||||
return (
|
||||
<div className="article">
|
||||
<h1>
|
||||
{title}
|
||||
<span>
|
||||
{publishedDate} · {author?.name}
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{image && <Image data={image} sizes="90vw" loading="eager" />}
|
||||
<div
|
||||
dangerouslySetInnerHTML={{__html: contentHtml}}
|
||||
className="article"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/objects/blog#field-blog-articlebyhandle
|
||||
const ARTICLE_QUERY = `#graphql
|
||||
query Article(
|
||||
$articleHandle: String!
|
||||
$blogHandle: String!
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
) @inContext(language: $language, country: $country) {
|
||||
blog(handle: $blogHandle) {
|
||||
articleByHandle(handle: $articleHandle) {
|
||||
title
|
||||
contentHtml
|
||||
publishedAt
|
||||
author: authorV2 {
|
||||
name
|
||||
}
|
||||
image {
|
||||
id
|
||||
altText
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
seo {
|
||||
description
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
162
examples/hydrogen-2/app/routes/blogs.$blogHandle._index.tsx
Normal file
162
examples/hydrogen-2/app/routes/blogs.$blogHandle._index.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import type {V2_MetaFunction} from '@shopify/remix-oxygen';
|
||||
import {json, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {Link, useLoaderData} from '@remix-run/react';
|
||||
import {Image, Pagination, getPaginationVariables} from '@shopify/hydrogen';
|
||||
import type {ArticleItemFragment} from 'storefrontapi.generated';
|
||||
|
||||
export const meta: V2_MetaFunction = ({data}) => {
|
||||
return [{title: `Hydrogen | ${data.blog.title} blog`}];
|
||||
};
|
||||
|
||||
export const loader = async ({
|
||||
request,
|
||||
params,
|
||||
context: {storefront},
|
||||
}: LoaderArgs) => {
|
||||
const paginationVariables = getPaginationVariables(request, {
|
||||
pageBy: 4,
|
||||
});
|
||||
|
||||
if (!params.blogHandle) {
|
||||
throw new Response(`blog not found`, {status: 404});
|
||||
}
|
||||
|
||||
const {blog} = await storefront.query(BLOGS_QUERY, {
|
||||
variables: {
|
||||
blogHandle: params.blogHandle,
|
||||
...paginationVariables,
|
||||
},
|
||||
});
|
||||
|
||||
if (!blog?.articles) {
|
||||
throw new Response('Not found', {status: 404});
|
||||
}
|
||||
|
||||
return json({blog});
|
||||
};
|
||||
|
||||
export default function Blog() {
|
||||
const {blog} = useLoaderData<typeof loader>();
|
||||
const {articles} = blog;
|
||||
|
||||
return (
|
||||
<div className="blog">
|
||||
<h1>{blog.title}</h1>
|
||||
<div className="blog-grid">
|
||||
<Pagination connection={articles}>
|
||||
{({nodes, isLoading, PreviousLink, NextLink}) => {
|
||||
return (
|
||||
<>
|
||||
<PreviousLink>
|
||||
{isLoading ? 'Loading...' : <span>↑ Load previous</span>}
|
||||
</PreviousLink>
|
||||
{nodes.map((article, index) => {
|
||||
return (
|
||||
<ArticleItem
|
||||
article={article}
|
||||
key={article.id}
|
||||
loading={index < 2 ? 'eager' : 'lazy'}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<NextLink>
|
||||
{isLoading ? 'Loading...' : <span>Load more ↓</span>}
|
||||
</NextLink>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArticleItem({
|
||||
article,
|
||||
loading,
|
||||
}: {
|
||||
article: ArticleItemFragment;
|
||||
loading?: HTMLImageElement['loading'];
|
||||
}) {
|
||||
const publishedAt = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(new Date(article.publishedAt!));
|
||||
return (
|
||||
<div className="blog-article" key={article.id}>
|
||||
<Link to={`/blogs/${article.blog.handle}/${article.handle}`}>
|
||||
{article.image && (
|
||||
<div className="blog-article-image">
|
||||
<Image
|
||||
alt={article.image.altText || article.title}
|
||||
aspectRatio="3/2"
|
||||
data={article.image}
|
||||
loading={loading}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<h3>{article.title}</h3>
|
||||
<small>{publishedAt}</small>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/objects/blog
|
||||
const BLOGS_QUERY = `#graphql
|
||||
query Blog(
|
||||
$language: LanguageCode
|
||||
$blogHandle: String!
|
||||
$first: Int
|
||||
$last: Int
|
||||
$startCursor: String
|
||||
$endCursor: String
|
||||
) @inContext(language: $language) {
|
||||
blog(handle: $blogHandle) {
|
||||
title
|
||||
seo {
|
||||
title
|
||||
description
|
||||
}
|
||||
articles(
|
||||
first: $first,
|
||||
last: $last,
|
||||
before: $startCursor,
|
||||
after: $endCursor
|
||||
) {
|
||||
nodes {
|
||||
...ArticleItem
|
||||
}
|
||||
pageInfo {
|
||||
hasPreviousPage
|
||||
hasNextPage
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
fragment ArticleItem on Article {
|
||||
author: authorV2 {
|
||||
name
|
||||
}
|
||||
contentHtml
|
||||
handle
|
||||
id
|
||||
image {
|
||||
id
|
||||
altText
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
publishedAt
|
||||
title
|
||||
blog {
|
||||
handle
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
94
examples/hydrogen-2/app/routes/blogs._index.tsx
Normal file
94
examples/hydrogen-2/app/routes/blogs._index.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import type {V2_MetaFunction} from '@shopify/remix-oxygen';
|
||||
import {json, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {Link, useLoaderData} from '@remix-run/react';
|
||||
import {Pagination, getPaginationVariables} from '@shopify/hydrogen';
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: `Hydrogen | Logs`}];
|
||||
};
|
||||
|
||||
export const loader = async ({request, context: {storefront}}: LoaderArgs) => {
|
||||
const paginationVariables = getPaginationVariables(request, {
|
||||
pageBy: 10,
|
||||
});
|
||||
|
||||
const {blogs} = await storefront.query(BLOGS_QUERY, {
|
||||
variables: {
|
||||
...paginationVariables,
|
||||
},
|
||||
});
|
||||
|
||||
return json({blogs});
|
||||
};
|
||||
|
||||
export default function Blogs() {
|
||||
const {blogs} = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="blogs">
|
||||
<h1>Blogs</h1>
|
||||
<div className="blogs-grid">
|
||||
<Pagination connection={blogs}>
|
||||
{({nodes, isLoading, PreviousLink, NextLink}) => {
|
||||
return (
|
||||
<>
|
||||
<PreviousLink>
|
||||
{isLoading ? 'Loading...' : <span>↑ Load previous</span>}
|
||||
</PreviousLink>
|
||||
{nodes.map((blog) => {
|
||||
return (
|
||||
<Link
|
||||
className="blog"
|
||||
key={blog.handle}
|
||||
prefetch="intent"
|
||||
to={`/blogs/${blog.handle}`}
|
||||
>
|
||||
<h2>{blog.title}</h2>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<NextLink>
|
||||
{isLoading ? 'Loading...' : <span>Load more ↓</span>}
|
||||
</NextLink>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/objects/blog
|
||||
const BLOGS_QUERY = `#graphql
|
||||
query Blogs(
|
||||
$country: CountryCode
|
||||
$endCursor: String
|
||||
$first: Int
|
||||
$language: LanguageCode
|
||||
$last: Int
|
||||
$startCursor: String
|
||||
) @inContext(country: $country, language: $language) {
|
||||
blogs(
|
||||
first: $first,
|
||||
last: $last,
|
||||
before: $startCursor,
|
||||
after: $endCursor
|
||||
) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
title
|
||||
handle
|
||||
seo {
|
||||
title
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
104
examples/hydrogen-2/app/routes/cart.tsx
Normal file
104
examples/hydrogen-2/app/routes/cart.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import {Await, useMatches} from '@remix-run/react';
|
||||
import {Suspense} from 'react';
|
||||
import type {CartQueryData} from '@shopify/hydrogen';
|
||||
import {CartForm} from '@shopify/hydrogen';
|
||||
import type {V2_MetaFunction} from '@shopify/remix-oxygen';
|
||||
import {type ActionArgs, json} from '@shopify/remix-oxygen';
|
||||
import type {CartApiQueryFragment} from 'storefrontapi.generated';
|
||||
import {CartMain} from '~/components/Cart';
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: `Hydrogen | Cart`}];
|
||||
};
|
||||
|
||||
export async function action({request, context}: ActionArgs) {
|
||||
const {session, cart} = context;
|
||||
|
||||
const [formData, customerAccessToken] = await Promise.all([
|
||||
request.formData(),
|
||||
session.get('customerAccessToken'),
|
||||
]);
|
||||
|
||||
const {action, inputs} = CartForm.getFormInput(formData);
|
||||
|
||||
if (!action) {
|
||||
throw new Error('No action provided');
|
||||
}
|
||||
|
||||
let status = 200;
|
||||
let result: CartQueryData;
|
||||
|
||||
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,
|
||||
customerAccessToken,
|
||||
});
|
||||
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 [root] = useMatches();
|
||||
const cart = root.data?.cart as Promise<CartApiQueryFragment | null>;
|
||||
|
||||
return (
|
||||
<div className="cart">
|
||||
<h1>Cart</h1>
|
||||
<Suspense fallback={<p>Loading cart ...</p>}>
|
||||
<Await errorElement={<div>An error occurred</div>} resolve={cart}>
|
||||
{(cart) => {
|
||||
return <CartMain layout="page" cart={cart} />;
|
||||
}}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
examples/hydrogen-2/app/routes/collections.$handle.tsx
Normal file
184
examples/hydrogen-2/app/routes/collections.$handle.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import type {V2_MetaFunction} from '@shopify/remix-oxygen';
|
||||
import {json, redirect, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {useLoaderData, Link} from '@remix-run/react';
|
||||
import {
|
||||
Pagination,
|
||||
getPaginationVariables,
|
||||
Image,
|
||||
Money,
|
||||
} from '@shopify/hydrogen';
|
||||
import type {ProductItemFragment} from 'storefrontapi.generated';
|
||||
import {useVariantUrl} from '~/utils';
|
||||
|
||||
export const meta: V2_MetaFunction = ({data}) => {
|
||||
return [{title: `Hydrogen | ${data.collection.title} Collection`}];
|
||||
};
|
||||
|
||||
export async function loader({request, params, context}: LoaderArgs) {
|
||||
const {handle} = params;
|
||||
const {storefront} = context;
|
||||
const paginationVariables = getPaginationVariables(request, {
|
||||
pageBy: 8,
|
||||
});
|
||||
|
||||
if (!handle) {
|
||||
return redirect('/collections');
|
||||
}
|
||||
|
||||
const {collection} = await storefront.query(COLLECTION_QUERY, {
|
||||
variables: {handle, ...paginationVariables},
|
||||
});
|
||||
|
||||
if (!collection) {
|
||||
throw new Response(`Collection ${handle} not found`, {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
return json({collection});
|
||||
}
|
||||
|
||||
export default function Collection() {
|
||||
const {collection} = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="collection">
|
||||
<h1>{collection.title}</h1>
|
||||
<p className="collection-description">{collection.description}</p>
|
||||
<Pagination connection={collection.products}>
|
||||
{({nodes, isLoading, PreviousLink, NextLink}) => (
|
||||
<>
|
||||
<PreviousLink>
|
||||
{isLoading ? 'Loading...' : <span>↑ Load previous</span>}
|
||||
</PreviousLink>
|
||||
<ProductsGrid products={nodes} />
|
||||
<br />
|
||||
<NextLink>
|
||||
{isLoading ? 'Loading...' : <span>Load more ↓</span>}
|
||||
</NextLink>
|
||||
</>
|
||||
)}
|
||||
</Pagination>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductsGrid({products}: {products: ProductItemFragment[]}) {
|
||||
return (
|
||||
<div className="products-grid">
|
||||
{products.map((product, index) => {
|
||||
return (
|
||||
<ProductItem
|
||||
key={product.id}
|
||||
product={product}
|
||||
loading={index < 8 ? 'eager' : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductItem({
|
||||
product,
|
||||
loading,
|
||||
}: {
|
||||
product: ProductItemFragment;
|
||||
loading?: 'eager' | 'lazy';
|
||||
}) {
|
||||
const variant = product.variants.nodes[0];
|
||||
const variantUrl = useVariantUrl(product.handle, variant.selectedOptions);
|
||||
return (
|
||||
<Link
|
||||
className="product-item"
|
||||
key={product.id}
|
||||
prefetch="intent"
|
||||
to={variantUrl}
|
||||
>
|
||||
{product.featuredImage && (
|
||||
<Image
|
||||
alt={product.featuredImage.altText || product.title}
|
||||
aspectRatio="1/1"
|
||||
data={product.featuredImage}
|
||||
loading={loading}
|
||||
sizes="(min-width: 45em) 400px, 100vw"
|
||||
/>
|
||||
)}
|
||||
<h4>{product.title}</h4>
|
||||
<small>
|
||||
<Money data={product.priceRange.minVariantPrice} />
|
||||
</small>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const PRODUCT_ITEM_FRAGMENT = `#graphql
|
||||
fragment MoneyProductItem on MoneyV2 {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
fragment ProductItem on Product {
|
||||
id
|
||||
handle
|
||||
title
|
||||
featuredImage {
|
||||
id
|
||||
altText
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
priceRange {
|
||||
minVariantPrice {
|
||||
...MoneyProductItem
|
||||
}
|
||||
maxVariantPrice {
|
||||
...MoneyProductItem
|
||||
}
|
||||
}
|
||||
variants(first: 1) {
|
||||
nodes {
|
||||
selectedOptions {
|
||||
name
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/2022-04/objects/collection
|
||||
const COLLECTION_QUERY = `#graphql
|
||||
${PRODUCT_ITEM_FRAGMENT}
|
||||
query Collection(
|
||||
$handle: String!
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
$first: Int
|
||||
$last: Int
|
||||
$startCursor: String
|
||||
$endCursor: String
|
||||
) @inContext(country: $country, language: $language) {
|
||||
collection(handle: $handle) {
|
||||
id
|
||||
handle
|
||||
title
|
||||
description
|
||||
products(
|
||||
first: $first,
|
||||
last: $last,
|
||||
before: $startCursor,
|
||||
after: $endCursor
|
||||
) {
|
||||
nodes {
|
||||
...ProductItem
|
||||
}
|
||||
pageInfo {
|
||||
hasPreviousPage
|
||||
hasNextPage
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
120
examples/hydrogen-2/app/routes/collections._index.tsx
Normal file
120
examples/hydrogen-2/app/routes/collections._index.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import {useLoaderData, Link} from '@remix-run/react';
|
||||
import {json, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {Pagination, getPaginationVariables, Image} from '@shopify/hydrogen';
|
||||
import type {CollectionFragment} from 'storefrontapi.generated';
|
||||
|
||||
export async function loader({context, request}: LoaderArgs) {
|
||||
const paginationVariables = getPaginationVariables(request, {
|
||||
pageBy: 4,
|
||||
});
|
||||
|
||||
const {collections} = await context.storefront.query(COLLECTIONS_QUERY, {
|
||||
variables: paginationVariables,
|
||||
});
|
||||
|
||||
return json({collections});
|
||||
}
|
||||
|
||||
export default function Collections() {
|
||||
const {collections} = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="collections">
|
||||
<h1>Collections</h1>
|
||||
<Pagination connection={collections}>
|
||||
{({nodes, isLoading, PreviousLink, NextLink}) => (
|
||||
<div>
|
||||
<PreviousLink>
|
||||
{isLoading ? 'Loading...' : <span>↑ Load previous</span>}
|
||||
</PreviousLink>
|
||||
<CollectionsGrid collections={nodes} />
|
||||
<NextLink>
|
||||
{isLoading ? 'Loading...' : <span>Load more ↓</span>}
|
||||
</NextLink>
|
||||
</div>
|
||||
)}
|
||||
</Pagination>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectionsGrid({collections}: {collections: CollectionFragment[]}) {
|
||||
return (
|
||||
<div className="collections-grid">
|
||||
{collections.map((collection, index) => (
|
||||
<CollectionItem
|
||||
key={collection.id}
|
||||
collection={collection}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectionItem({
|
||||
collection,
|
||||
index,
|
||||
}: {
|
||||
collection: CollectionFragment;
|
||||
index: number;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
className="collection-item"
|
||||
key={collection.id}
|
||||
to={`/collections/${collection.handle}`}
|
||||
prefetch="intent"
|
||||
>
|
||||
{collection.image && (
|
||||
<Image
|
||||
alt={collection.image.altText || collection.title}
|
||||
aspectRatio="1/1"
|
||||
data={collection.image}
|
||||
loading={index < 3 ? 'eager' : undefined}
|
||||
/>
|
||||
)}
|
||||
<h5>{collection.title}</h5>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const COLLECTIONS_QUERY = `#graphql
|
||||
fragment Collection on Collection {
|
||||
id
|
||||
title
|
||||
handle
|
||||
image {
|
||||
id
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
}
|
||||
query StoreCollections(
|
||||
$country: CountryCode
|
||||
$endCursor: String
|
||||
$first: Int
|
||||
$language: LanguageCode
|
||||
$last: Int
|
||||
$startCursor: String
|
||||
) @inContext(country: $country, language: $language) {
|
||||
collections(
|
||||
first: $first,
|
||||
last: $last,
|
||||
before: $startCursor,
|
||||
after: $endCursor
|
||||
) {
|
||||
nodes {
|
||||
...Collection
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
57
examples/hydrogen-2/app/routes/pages.$handle.tsx
Normal file
57
examples/hydrogen-2/app/routes/pages.$handle.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type {V2_MetaFunction} from '@shopify/remix-oxygen';
|
||||
import {json, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {useLoaderData} from '@remix-run/react';
|
||||
|
||||
export const meta: V2_MetaFunction = ({data}) => {
|
||||
return [{title: `Hydrogen | ${data.page.title}`}];
|
||||
};
|
||||
|
||||
export async function loader({params, context}: LoaderArgs) {
|
||||
if (!params.handle) {
|
||||
throw new Error('Missing page handle');
|
||||
}
|
||||
|
||||
const {page} = await context.storefront.query(PAGE_QUERY, {
|
||||
variables: {
|
||||
handle: params.handle,
|
||||
},
|
||||
});
|
||||
|
||||
if (!page) {
|
||||
throw new Response('Not Found', {status: 404});
|
||||
}
|
||||
|
||||
return json({page});
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const {page} = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header>
|
||||
<h1>{page.title}</h1>
|
||||
</header>
|
||||
<main dangerouslySetInnerHTML={{__html: page.body}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PAGE_QUERY = `#graphql
|
||||
query Page(
|
||||
$language: LanguageCode,
|
||||
$country: CountryCode,
|
||||
$handle: String!
|
||||
)
|
||||
@inContext(language: $language, country: $country) {
|
||||
page(handle: $handle) {
|
||||
id
|
||||
title
|
||||
body
|
||||
seo {
|
||||
description
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
94
examples/hydrogen-2/app/routes/policies.$handle.tsx
Normal file
94
examples/hydrogen-2/app/routes/policies.$handle.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import type {V2_MetaFunction} from '@shopify/remix-oxygen';
|
||||
import {json, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {Link, useLoaderData} from '@remix-run/react';
|
||||
import {type Shop} from '@shopify/hydrogen-react/storefront-api-types';
|
||||
|
||||
type SelectedPolicies = keyof Pick<
|
||||
Shop,
|
||||
'privacyPolicy' | 'shippingPolicy' | 'termsOfService' | 'refundPolicy'
|
||||
>;
|
||||
|
||||
export const meta: V2_MetaFunction = ({data}) => {
|
||||
return [{title: `Hydrogen | ${data.policy.title}`}];
|
||||
};
|
||||
|
||||
export async function loader({params, context}: LoaderArgs) {
|
||||
if (!params.handle) {
|
||||
throw new Response('No handle was passed in', {status: 404});
|
||||
}
|
||||
|
||||
const policyName = params.handle.replace(
|
||||
/-([a-z])/g,
|
||||
(_: unknown, m1: string) => m1.toUpperCase(),
|
||||
) as SelectedPolicies;
|
||||
|
||||
const data = await context.storefront.query(POLICY_CONTENT_QUERY, {
|
||||
variables: {
|
||||
privacyPolicy: false,
|
||||
shippingPolicy: false,
|
||||
termsOfService: false,
|
||||
refundPolicy: false,
|
||||
[policyName]: true,
|
||||
language: context.storefront.i18n?.language,
|
||||
},
|
||||
});
|
||||
|
||||
const policy = data.shop?.[policyName];
|
||||
|
||||
if (!policy) {
|
||||
throw new Response('Could not find the policy', {status: 404});
|
||||
}
|
||||
|
||||
return json({policy});
|
||||
}
|
||||
|
||||
export default function Policy() {
|
||||
const {policy} = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="policy">
|
||||
<br />
|
||||
<br />
|
||||
<div>
|
||||
<Link to="/policies">← Back to Policies</Link>
|
||||
</div>
|
||||
<br />
|
||||
<h1>{policy.title}</h1>
|
||||
<div dangerouslySetInnerHTML={{__html: policy.body}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/objects/Shop
|
||||
const POLICY_CONTENT_QUERY = `#graphql
|
||||
fragment Policy on ShopPolicy {
|
||||
body
|
||||
handle
|
||||
id
|
||||
title
|
||||
url
|
||||
}
|
||||
query Policy(
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
$privacyPolicy: Boolean!
|
||||
$refundPolicy: Boolean!
|
||||
$shippingPolicy: Boolean!
|
||||
$termsOfService: Boolean!
|
||||
) @inContext(language: $language, country: $country) {
|
||||
shop {
|
||||
privacyPolicy @include(if: $privacyPolicy) {
|
||||
...Policy
|
||||
}
|
||||
shippingPolicy @include(if: $shippingPolicy) {
|
||||
...Policy
|
||||
}
|
||||
termsOfService @include(if: $termsOfService) {
|
||||
...Policy
|
||||
}
|
||||
refundPolicy @include(if: $refundPolicy) {
|
||||
...Policy
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
63
examples/hydrogen-2/app/routes/policies._index.tsx
Normal file
63
examples/hydrogen-2/app/routes/policies._index.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import {json, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {useLoaderData, Link} from '@remix-run/react';
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
const data = await context.storefront.query(POLICIES_QUERY);
|
||||
const policies = Object.values(data.shop || {});
|
||||
|
||||
if (!policies.length) {
|
||||
throw new Response('No policies found', {status: 404});
|
||||
}
|
||||
|
||||
return json({policies});
|
||||
}
|
||||
|
||||
export default function Policies() {
|
||||
const {policies} = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="policies">
|
||||
<h1>Policies</h1>
|
||||
<div>
|
||||
{policies.map((policy) => {
|
||||
if (!policy) return null;
|
||||
return (
|
||||
<fieldset key={policy.id}>
|
||||
<Link to={`/policies/${policy.handle}`}>{policy.title}</Link>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const POLICIES_QUERY = `#graphql
|
||||
fragment PolicyItem on ShopPolicy {
|
||||
id
|
||||
title
|
||||
handle
|
||||
}
|
||||
query Policies ($country: CountryCode, $language: LanguageCode)
|
||||
@inContext(country: $country, language: $language) {
|
||||
shop {
|
||||
privacyPolicy {
|
||||
...PolicyItem
|
||||
}
|
||||
shippingPolicy {
|
||||
...PolicyItem
|
||||
}
|
||||
termsOfService {
|
||||
...PolicyItem
|
||||
}
|
||||
refundPolicy {
|
||||
...PolicyItem
|
||||
}
|
||||
subscriptionPolicy {
|
||||
id
|
||||
title
|
||||
handle
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
418
examples/hydrogen-2/app/routes/products.$handle.tsx
Normal file
418
examples/hydrogen-2/app/routes/products.$handle.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
import {Suspense} from 'react';
|
||||
import type {V2_MetaFunction} from '@shopify/remix-oxygen';
|
||||
import {defer, redirect, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import type {FetcherWithComponents} from '@remix-run/react';
|
||||
import {Await, Link, useLoaderData} from '@remix-run/react';
|
||||
import type {
|
||||
ProductFragment,
|
||||
ProductVariantsQuery,
|
||||
ProductVariantFragment,
|
||||
} from 'storefrontapi.generated';
|
||||
|
||||
import {
|
||||
Image,
|
||||
Money,
|
||||
VariantSelector,
|
||||
type VariantOption,
|
||||
getSelectedProductOptions,
|
||||
CartForm,
|
||||
} from '@shopify/hydrogen';
|
||||
import type {CartLineInput} from '@shopify/hydrogen/storefront-api-types';
|
||||
import {getVariantUrl} from '~/utils';
|
||||
|
||||
export const meta: V2_MetaFunction = ({data}) => {
|
||||
return [{title: `Hydrogen | ${data.product.title}`}];
|
||||
};
|
||||
|
||||
export async function loader({params, request, context}: LoaderArgs) {
|
||||
const {handle} = params;
|
||||
const {storefront} = context;
|
||||
|
||||
const selectedOptions = getSelectedProductOptions(request).filter(
|
||||
(option) =>
|
||||
// Filter out Shopify predictive search query params
|
||||
!option.name.startsWith('_sid') &&
|
||||
!option.name.startsWith('_pos') &&
|
||||
!option.name.startsWith('_psq') &&
|
||||
!option.name.startsWith('_ss') &&
|
||||
!option.name.startsWith('_v'),
|
||||
);
|
||||
|
||||
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, selectedOptions},
|
||||
});
|
||||
|
||||
// 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},
|
||||
});
|
||||
|
||||
if (!product?.id) {
|
||||
throw new Response(null, {status: 404});
|
||||
}
|
||||
|
||||
const firstVariant = product.variants.nodes[0];
|
||||
const firstVariantIsDefault = Boolean(
|
||||
firstVariant.selectedOptions.find(
|
||||
(option) => option.name === 'Title' && option.value === 'Default Title',
|
||||
),
|
||||
);
|
||||
|
||||
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) {
|
||||
return redirectToFirstVariant({product, request});
|
||||
}
|
||||
}
|
||||
return defer({product, variants});
|
||||
}
|
||||
|
||||
function redirectToFirstVariant({
|
||||
product,
|
||||
request,
|
||||
}: {
|
||||
product: ProductFragment;
|
||||
request: Request;
|
||||
}) {
|
||||
const url = new URL(request.url);
|
||||
const firstVariant = product.variants.nodes[0];
|
||||
|
||||
throw redirect(
|
||||
getVariantUrl({
|
||||
pathname: url.pathname,
|
||||
handle: product.handle,
|
||||
selectedOptions: firstVariant.selectedOptions,
|
||||
searchParams: new URLSearchParams(url.search),
|
||||
}),
|
||||
{
|
||||
status: 302,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export default function Product() {
|
||||
const {product, variants} = useLoaderData<typeof loader>();
|
||||
const {selectedVariant} = product;
|
||||
return (
|
||||
<div className="product">
|
||||
<ProductImage image={selectedVariant?.image} />
|
||||
<ProductMain
|
||||
selectedVariant={selectedVariant}
|
||||
product={product}
|
||||
variants={variants}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductImage({image}: {image: ProductVariantFragment['image']}) {
|
||||
if (!image) {
|
||||
return <div className="product-image" />;
|
||||
}
|
||||
return (
|
||||
<div className="product-image">
|
||||
<Image
|
||||
alt={image.altText || 'Product Image'}
|
||||
aspectRatio="1/1"
|
||||
data={image}
|
||||
key={image.id}
|
||||
sizes="(min-width: 45em) 50vw, 100vw"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductMain({
|
||||
selectedVariant,
|
||||
product,
|
||||
variants,
|
||||
}: {
|
||||
product: ProductFragment;
|
||||
selectedVariant: ProductFragment['selectedVariant'];
|
||||
variants: Promise<ProductVariantsQuery>;
|
||||
}) {
|
||||
const {title, descriptionHtml} = product;
|
||||
return (
|
||||
<div className="product-main">
|
||||
<h1>{title}</h1>
|
||||
<ProductPrice selectedVariant={selectedVariant} />
|
||||
<br />
|
||||
<Suspense
|
||||
fallback={
|
||||
<ProductForm
|
||||
product={product}
|
||||
selectedVariant={selectedVariant}
|
||||
variants={[]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Await
|
||||
errorElement="There was a problem loading product variants"
|
||||
resolve={variants}
|
||||
>
|
||||
{(data) => (
|
||||
<ProductForm
|
||||
product={product}
|
||||
selectedVariant={selectedVariant}
|
||||
variants={data.product?.variants.nodes || []}
|
||||
/>
|
||||
)}
|
||||
</Await>
|
||||
</Suspense>
|
||||
<br />
|
||||
<br />
|
||||
<p>
|
||||
<strong>Description</strong>
|
||||
</p>
|
||||
<br />
|
||||
<div dangerouslySetInnerHTML={{__html: descriptionHtml}} />
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductPrice({
|
||||
selectedVariant,
|
||||
}: {
|
||||
selectedVariant: ProductFragment['selectedVariant'];
|
||||
}) {
|
||||
return (
|
||||
<div className="product-price">
|
||||
{selectedVariant?.compareAtPrice ? (
|
||||
<>
|
||||
<p>Sale</p>
|
||||
<br />
|
||||
<div className="product-price-on-sale">
|
||||
{selectedVariant ? <Money data={selectedVariant.price} /> : null}
|
||||
<s>
|
||||
<Money data={selectedVariant.compareAtPrice} />
|
||||
</s>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
selectedVariant?.price && <Money data={selectedVariant?.price} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductForm({
|
||||
product,
|
||||
selectedVariant,
|
||||
variants,
|
||||
}: {
|
||||
product: ProductFragment;
|
||||
selectedVariant: ProductFragment['selectedVariant'];
|
||||
variants: Array<ProductVariantFragment>;
|
||||
}) {
|
||||
return (
|
||||
<div className="product-form">
|
||||
<VariantSelector
|
||||
handle={product.handle}
|
||||
options={product.options}
|
||||
variants={variants}
|
||||
>
|
||||
{({option}) => <ProductOptions key={option.name} option={option} />}
|
||||
</VariantSelector>
|
||||
<br />
|
||||
<AddToCartButton
|
||||
disabled={!selectedVariant || !selectedVariant.availableForSale}
|
||||
onClick={() => {
|
||||
window.location.href = window.location.href + '#cart-aside';
|
||||
}}
|
||||
lines={
|
||||
selectedVariant
|
||||
? [
|
||||
{
|
||||
merchandiseId: selectedVariant.id,
|
||||
quantity: 1,
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
>
|
||||
{selectedVariant?.availableForSale ? 'Add to cart' : 'Sold out'}
|
||||
</AddToCartButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductOptions({option}: {option: VariantOption}) {
|
||||
return (
|
||||
<div className="product-options" key={option.name}>
|
||||
<h5>{option.name}</h5>
|
||||
<div className="product-options-grid">
|
||||
{option.values.map(({value, isAvailable, isActive, to}) => {
|
||||
return (
|
||||
<Link
|
||||
className="product-options-item"
|
||||
key={option.name + value}
|
||||
prefetch="intent"
|
||||
preventScrollReset
|
||||
replace
|
||||
to={to}
|
||||
style={{
|
||||
border: isActive ? '1px solid black' : '1px solid transparent',
|
||||
opacity: isAvailable ? 1 : 0.3,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddToCartButton({
|
||||
analytics,
|
||||
children,
|
||||
disabled,
|
||||
lines,
|
||||
onClick,
|
||||
}: {
|
||||
analytics?: unknown;
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
lines: CartLineInput[];
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<CartForm route="/cart" inputs={{lines}} action={CartForm.ACTIONS.LinesAdd}>
|
||||
{(fetcher: FetcherWithComponents<any>) => (
|
||||
<>
|
||||
<input
|
||||
name="analytics"
|
||||
type="hidden"
|
||||
value={JSON.stringify(analytics)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
onClick={onClick}
|
||||
disabled={disabled ?? fetcher.state !== 'idle'}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</CartForm>
|
||||
);
|
||||
}
|
||||
|
||||
const PRODUCT_VARIANT_FRAGMENT = `#graphql
|
||||
fragment ProductVariant on ProductVariant {
|
||||
availableForSale
|
||||
compareAtPrice {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
id
|
||||
image {
|
||||
__typename
|
||||
id
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
price {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
product {
|
||||
title
|
||||
handle
|
||||
}
|
||||
quantityAvailable
|
||||
selectedOptions {
|
||||
name
|
||||
value
|
||||
}
|
||||
sku
|
||||
title
|
||||
unitPrice {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
const PRODUCT_FRAGMENT = `#graphql
|
||||
fragment Product on Product {
|
||||
id
|
||||
title
|
||||
vendor
|
||||
handle
|
||||
descriptionHtml
|
||||
description
|
||||
options {
|
||||
name
|
||||
values
|
||||
}
|
||||
selectedVariant: variantBySelectedOptions(selectedOptions: $selectedOptions) {
|
||||
...ProductVariant
|
||||
}
|
||||
variants(first: 1) {
|
||||
nodes {
|
||||
...ProductVariant
|
||||
}
|
||||
}
|
||||
seo {
|
||||
description
|
||||
title
|
||||
}
|
||||
}
|
||||
${PRODUCT_VARIANT_FRAGMENT}
|
||||
` as const;
|
||||
|
||||
const PRODUCT_QUERY = `#graphql
|
||||
query Product(
|
||||
$country: CountryCode
|
||||
$handle: String!
|
||||
$language: LanguageCode
|
||||
$selectedOptions: [SelectedOptionInput!]!
|
||||
) @inContext(country: $country, language: $language) {
|
||||
product(handle: $handle) {
|
||||
...Product
|
||||
}
|
||||
}
|
||||
${PRODUCT_FRAGMENT}
|
||||
` as const;
|
||||
|
||||
const PRODUCT_VARIANTS_FRAGMENT = `#graphql
|
||||
fragment ProductVariants on Product {
|
||||
variants(first: 250) {
|
||||
nodes {
|
||||
...ProductVariant
|
||||
}
|
||||
}
|
||||
}
|
||||
${PRODUCT_VARIANT_FRAGMENT}
|
||||
` as const;
|
||||
|
||||
const VARIANTS_QUERY = `#graphql
|
||||
${PRODUCT_VARIANTS_FRAGMENT}
|
||||
query ProductVariants(
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
$handle: String!
|
||||
) @inContext(country: $country, language: $language) {
|
||||
product(handle: $handle) {
|
||||
...ProductVariants
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
168
examples/hydrogen-2/app/routes/search.tsx
Normal file
168
examples/hydrogen-2/app/routes/search.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import type {V2_MetaFunction} from '@shopify/remix-oxygen';
|
||||
import {defer, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {useLoaderData} from '@remix-run/react';
|
||||
import {getPaginationVariables} from '@shopify/hydrogen';
|
||||
|
||||
import {SearchForm, SearchResults, NoSearchResults} from '~/components/Search';
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: `Hydrogen | Search`}];
|
||||
};
|
||||
|
||||
export async function loader({request, context}: LoaderArgs) {
|
||||
const url = new URL(request.url);
|
||||
const searchParams = new URLSearchParams(url.search);
|
||||
const variables = getPaginationVariables(request, {pageBy: 8});
|
||||
const searchTerm = String(searchParams.get('q') || '');
|
||||
|
||||
if (!searchTerm) {
|
||||
return {
|
||||
searchResults: {results: null, totalResults: 0},
|
||||
searchTerm,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await context.storefront.query(SEARCH_QUERY, {
|
||||
variables: {
|
||||
query: searchTerm,
|
||||
...variables,
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
throw new Error('No search data returned from Shopify API');
|
||||
}
|
||||
|
||||
const totalResults = Object.values(data).reduce((total, value) => {
|
||||
return total + value.nodes.length;
|
||||
}, 0);
|
||||
|
||||
const searchResults = {
|
||||
results: data,
|
||||
totalResults,
|
||||
};
|
||||
|
||||
return defer({searchTerm, searchResults});
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
const {searchTerm, searchResults} = useLoaderData<typeof loader>();
|
||||
return (
|
||||
<div className="search">
|
||||
<h1>Search</h1>
|
||||
<SearchForm searchTerm={searchTerm} />
|
||||
{!searchTerm || !searchResults.totalResults ? (
|
||||
<NoSearchResults />
|
||||
) : (
|
||||
<SearchResults results={searchResults.results} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SEARCH_QUERY = `#graphql
|
||||
fragment SearchProduct on Product {
|
||||
__typename
|
||||
handle
|
||||
id
|
||||
publishedAt
|
||||
title
|
||||
trackingParameters
|
||||
vendor
|
||||
variants(first: 1) {
|
||||
nodes {
|
||||
id
|
||||
image {
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
price {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
compareAtPrice {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
selectedOptions {
|
||||
name
|
||||
value
|
||||
}
|
||||
product {
|
||||
handle
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fragment SearchPage on Page {
|
||||
__typename
|
||||
handle
|
||||
id
|
||||
title
|
||||
trackingParameters
|
||||
}
|
||||
fragment SearchArticle on Article {
|
||||
__typename
|
||||
handle
|
||||
id
|
||||
title
|
||||
trackingParameters
|
||||
}
|
||||
query search(
|
||||
$country: CountryCode
|
||||
$endCursor: String
|
||||
$first: Int
|
||||
$language: LanguageCode
|
||||
$last: Int
|
||||
$query: String!
|
||||
$startCursor: String
|
||||
) @inContext(country: $country, language: $language) {
|
||||
products: search(
|
||||
query: $query,
|
||||
unavailableProducts: HIDE,
|
||||
types: [PRODUCT],
|
||||
first: $first,
|
||||
sortKey: RELEVANCE,
|
||||
last: $last,
|
||||
before: $startCursor,
|
||||
after: $endCursor
|
||||
) {
|
||||
nodes {
|
||||
...on Product {
|
||||
...SearchProduct
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
pages: search(
|
||||
query: $query,
|
||||
types: [PAGE],
|
||||
first: 10
|
||||
) {
|
||||
nodes {
|
||||
...on Page {
|
||||
...SearchPage
|
||||
}
|
||||
}
|
||||
}
|
||||
articles: search(
|
||||
query: $query,
|
||||
types: [ARTICLE],
|
||||
first: 10
|
||||
) {
|
||||
nodes {
|
||||
...on Article {
|
||||
...SearchArticle
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
473
examples/hydrogen-2/app/styles/app.css
Normal file
473
examples/hydrogen-2/app/styles/app.css
Normal file
@@ -0,0 +1,473 @@
|
||||
:root {
|
||||
--aside-width: 400px;
|
||||
--cart-aside-summary-height-with-discount: 300px;
|
||||
--cart-aside-summary-height: 250px;
|
||||
--grid-item-width: 355px;
|
||||
--header-height: 64px;
|
||||
--color-dark: #000;
|
||||
--color-light: #fff;
|
||||
}
|
||||
|
||||
img {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/*
|
||||
* --------------------------------------------------
|
||||
* components/Aside
|
||||
* --------------------------------------------------
|
||||
*/
|
||||
aside {
|
||||
background: var(--color-light);
|
||||
box-shadow: 0 0 50px rgba(0, 0, 0, 0.3);
|
||||
height: 100vh;
|
||||
max-width: var(--aside-width);
|
||||
min-width: var(--aside-width);
|
||||
position: fixed;
|
||||
right: calc(-1 * var(--aside-width));
|
||||
top: 0;
|
||||
transition: transform 200ms ease-in-out;
|
||||
}
|
||||
|
||||
aside header {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--color-dark);
|
||||
display: flex;
|
||||
height: var(--header-height);
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
aside header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
aside header .close {
|
||||
font-weight: bold;
|
||||
opacity: 0.8;
|
||||
text-decoration: none;
|
||||
transition: all 200ms;
|
||||
width: 20px;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
aside header h2 {
|
||||
margin-bottom: 0.6rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
aside main {
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
aside p {
|
||||
margin: 0 0 0.25rem;
|
||||
&:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
aside li {
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transition: opacity 400ms ease-in-out;
|
||||
transition: opacity 400ms;
|
||||
visibility: hidden;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.overlay .close-outside {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: transparent;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: calc(100% - var(--aside-width));
|
||||
}
|
||||
|
||||
.overlay .light {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.overlay .cancel {
|
||||
cursor: default;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
&:target {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
visibility: visible;
|
||||
}
|
||||
/* reveal aside */
|
||||
&:target aside {
|
||||
transform: translateX(calc(var(--aside-width) * -1));
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* --------------------------------------------------
|
||||
* components/Header
|
||||
* --------------------------------------------------
|
||||
*/
|
||||
.header {
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
height: var(--header-height);
|
||||
padding: 0 1rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.header-menu-mobile-toggle {
|
||||
@media (min-width: 48em) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.header-menu-mobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-gap: 1rem;
|
||||
}
|
||||
|
||||
.header-menu-desktop {
|
||||
display: none;
|
||||
grid-gap: 1rem;
|
||||
@media (min-width: 45em) {
|
||||
display: flex;
|
||||
grid-gap: 1rem;
|
||||
margin-left: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.header-menu-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header-ctas {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
grid-gap: 1rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* --------------------------------------------------
|
||||
* components/Footer
|
||||
* --------------------------------------------------
|
||||
*/
|
||||
.footer {
|
||||
background: var(--color-dark);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.footer-menu-missing {
|
||||
display: inline-block;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.footer-menu {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
grid-gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.footer-menu a {
|
||||
color: var(--color-light);
|
||||
}
|
||||
|
||||
/*
|
||||
* --------------------------------------------------
|
||||
* components/Cart
|
||||
* --------------------------------------------------
|
||||
*/
|
||||
.cart-main {
|
||||
height: 100%;
|
||||
max-height: calc(100vh - var(--cart-aside-summary-height));
|
||||
overflow-y: auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.cart-main.with-discount {
|
||||
max-height: calc(100vh - var(--cart-aside-summary-height-with-discount));
|
||||
}
|
||||
|
||||
.cart-line {
|
||||
display: flex;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.cart-line img {
|
||||
height: 100%;
|
||||
display: block;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.cart-summary-page {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cart-summary-aside {
|
||||
background: white;
|
||||
border-top: 1px solid var(--color-dark);
|
||||
bottom: 0;
|
||||
padding-top: 0.75rem;
|
||||
position: absolute;
|
||||
width: calc(var(--aside-width) - 40px);
|
||||
}
|
||||
|
||||
.cart-line-quantiy {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cart-discount {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.cart-subtotal {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
/*
|
||||
* --------------------------------------------------
|
||||
* components/Search
|
||||
* --------------------------------------------------
|
||||
*/
|
||||
.predictive-search {
|
||||
height: calc(100vh - var(--header-height) - 40px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.predictive-search-form {
|
||||
background: var(--color-light);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.predictive-search-result {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.predictive-search-result h5 {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.predictive-search-result-item {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.predictive-search-result-item a {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.predictive-search-result-item a img {
|
||||
margin-right: 0.75rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.search-result {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.search-results-item {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* --------------------------------------------------
|
||||
* routes/__index
|
||||
* --------------------------------------------------
|
||||
*/
|
||||
.featured-collection {
|
||||
display: block;
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.featured-collection-image {
|
||||
aspect-ratio: 1 / 1;
|
||||
@media (min-width: 45em) {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
}
|
||||
|
||||
.featured-collection img {
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.recommended-products-grid {
|
||||
display: grid;
|
||||
grid-gap: 1.5rem;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
@media (min-width: 45em) {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.recommended-product img {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* --------------------------------------------------
|
||||
* routes/collections._index.tsx
|
||||
* --------------------------------------------------
|
||||
*/
|
||||
.collections-grid {
|
||||
display: grid;
|
||||
grid-gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(var(--grid-item-width), 1fr));
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.collection-item img {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* --------------------------------------------------
|
||||
* routes/collections.$handle.tsx
|
||||
* --------------------------------------------------
|
||||
*/
|
||||
.collection-description {
|
||||
margin-bottom: 1rem;
|
||||
max-width: 95%;
|
||||
@media (min-width: 45em) {
|
||||
max-width: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
.products-grid {
|
||||
display: grid;
|
||||
grid-gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(var(--grid-item-width), 1fr));
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.product-item img {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
* --------------------------------------------------
|
||||
* routes/products.$handle.tsx
|
||||
* --------------------------------------------------
|
||||
*/
|
||||
.product {
|
||||
display: grid;
|
||||
@media (min-width: 45em) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-gap: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.product h1 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.product-images {
|
||||
display: grid;
|
||||
grid-gap: 1rem;
|
||||
}
|
||||
|
||||
.product-image img {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-main {
|
||||
align-self: start;
|
||||
position: sticky;
|
||||
top: 6rem;
|
||||
}
|
||||
|
||||
.product-price-on-sale {
|
||||
display: flex;
|
||||
grid-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.product-price-on-sale s {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.product-options-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
grid-gap: 0.75rem;
|
||||
}
|
||||
|
||||
.product-options-item {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* --------------------------------------------------
|
||||
* routes/blog._index.tsx
|
||||
* --------------------------------------------------
|
||||
*/
|
||||
.blog-grid {
|
||||
display: grid;
|
||||
grid-gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(var(--grid-item-width), 1fr));
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.blog-article-image {
|
||||
aspect-ratio: 3/2;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.blog-article-image img {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
* --------------------------------------------------
|
||||
* routes/blog.$articlehandle.tsx
|
||||
* --------------------------------------------------
|
||||
*/
|
||||
.article img {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
* --------------------------------------------------
|
||||
* routes/account
|
||||
* --------------------------------------------------
|
||||
*/
|
||||
.account-profile-marketing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.account-logout {
|
||||
display: inline-block;
|
||||
}
|
||||
129
examples/hydrogen-2/app/styles/reset.css
Normal file
129
examples/hydrogen-2/app/styles/reset.css
Normal file
@@ -0,0 +1,129 @@
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 2rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
h5 {
|
||||
margin-bottom: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #000;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-bottom: none;
|
||||
border-top: 1px solid #000;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body > main {
|
||||
margin: 0 1rem 1rem 1rem;
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 1rem 0;
|
||||
@media (min-width: 768px) {
|
||||
padding: 2rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
fieldset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
form {
|
||||
max-width: 100%;
|
||||
@media (min-width: 768px) {
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
border-radius: 4px;
|
||||
border: 1px solid #000;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
legend {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
dl {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #ddd;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
3
examples/hydrogen-2/app/styles/tailwind.css
Normal file
3
examples/hydrogen-2/app/styles/tailwind.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
46
examples/hydrogen-2/app/utils.ts
Normal file
46
examples/hydrogen-2/app/utils.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {useLocation} from '@remix-run/react';
|
||||
import type {SelectedOption} from '@shopify/hydrogen/storefront-api-types';
|
||||
import {useMemo} from 'react';
|
||||
|
||||
export function useVariantUrl(
|
||||
handle: string,
|
||||
selectedOptions: SelectedOption[],
|
||||
) {
|
||||
const {pathname} = useLocation();
|
||||
|
||||
return useMemo(() => {
|
||||
return getVariantUrl({
|
||||
handle,
|
||||
pathname,
|
||||
searchParams: new URLSearchParams(),
|
||||
selectedOptions,
|
||||
});
|
||||
}, [handle, selectedOptions, pathname]);
|
||||
}
|
||||
|
||||
export function getVariantUrl({
|
||||
handle,
|
||||
pathname,
|
||||
searchParams,
|
||||
selectedOptions,
|
||||
}: {
|
||||
handle: string;
|
||||
pathname: string;
|
||||
searchParams: URLSearchParams;
|
||||
selectedOptions: SelectedOption[];
|
||||
}) {
|
||||
const match = /(\/[a-zA-Z]{2}-[a-zA-Z]{2}\/)/g.exec(pathname);
|
||||
const isLocalePathname = match && match.length > 0;
|
||||
|
||||
const path = isLocalePathname
|
||||
? `${match![0]}products/${handle}`
|
||||
: `/products/${handle}`;
|
||||
|
||||
selectedOptions.forEach((option) => {
|
||||
searchParams.set(option.name, option.value);
|
||||
});
|
||||
|
||||
const searchString = searchParams.toString();
|
||||
|
||||
return path + (searchString ? '?' + searchParams.toString() : '');
|
||||
}
|
||||
52
examples/hydrogen-2/package.json
Normal file
52
examples/hydrogen-2/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "hydrogen-2",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"build": "shopify hydrogen build",
|
||||
"dev": "shopify hydrogen dev --codegen-unstable",
|
||||
"preview": "npm run build && shopify hydrogen preview",
|
||||
"lint": "eslint --no-error-on-unmatched-pattern --ext .js,.ts,.jsx,.tsx .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"codegen": "shopify hydrogen codegen-unstable"
|
||||
},
|
||||
"prettier": "@shopify/prettier-config",
|
||||
"dependencies": {
|
||||
"@remix-run/react": "1.19.1",
|
||||
"@shopify/cli": "3.48.0",
|
||||
"@shopify/cli-hydrogen": "^5.1.2",
|
||||
"@shopify/hydrogen": "^2023.7.2",
|
||||
"@shopify/remix-oxygen": "^1.1.3",
|
||||
"graphql": "^16.6.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"isbot": "^3.6.6",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@remix-run/dev": "1.19.1",
|
||||
"@shopify/oxygen-workers-types": "^3.17.3",
|
||||
"@shopify/prettier-config": "^1.1.2",
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@total-typescript/ts-reset": "^0.4.2",
|
||||
"@types/eslint": "^8.4.10",
|
||||
"@types/react": "^18.0.20",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"eslint": "^8.20.0",
|
||||
"eslint-plugin-hydrogen": "0.12.2",
|
||||
"postcss": "^8.4.21",
|
||||
"postcss-import": "^15.1.0",
|
||||
"postcss-preset-env": "^8.2.0",
|
||||
"prettier": "^2.8.4",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.13"
|
||||
},
|
||||
"browserslist": [
|
||||
"defaults"
|
||||
]
|
||||
}
|
||||
11546
examples/hydrogen-2/pnpm-lock.yaml
generated
Normal file
11546
examples/hydrogen-2/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
10
examples/hydrogen-2/postcss.config.js
Normal file
10
examples/hydrogen-2/postcss.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'postcss-import': {},
|
||||
'tailwindcss/nesting': {},
|
||||
tailwindcss: {},
|
||||
'postcss-preset-env': {
|
||||
features: {'nesting-rules': false},
|
||||
},
|
||||
},
|
||||
};
|
||||
28
examples/hydrogen-2/public/favicon.svg
Normal file
28
examples/hydrogen-2/public/favicon.svg
Normal file
@@ -0,0 +1,28 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none">
|
||||
<style>
|
||||
.stroke {
|
||||
stroke: #000;
|
||||
}
|
||||
.fill {
|
||||
fill: #000;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.stroke {
|
||||
stroke: #fff;
|
||||
}
|
||||
.fill {
|
||||
fill: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path
|
||||
class="stroke"
|
||||
fill-rule="evenodd"
|
||||
d="M16.1 16.04 1 8.02 6.16 5.3l5.82 3.09 4.88-2.57-5.82-3.1L16.21 0l15.1 8.02-5.17 2.72-5.5-2.91-4.88 2.57 5.5 2.92-5.16 2.72Z"
|
||||
/>
|
||||
<path
|
||||
class="fill"
|
||||
fill-rule="evenodd"
|
||||
d="M16.1 32 1 23.98l5.16-2.72 5.82 3.08 4.88-2.57-5.82-3.08 5.17-2.73 15.1 8.02-5.17 2.72-5.5-2.92-4.88 2.58 5.5 2.92L16.1 32Z"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 690 B |
28
examples/hydrogen-2/remix.config.js
Normal file
28
examples/hydrogen-2/remix.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/** @type {import('@remix-run/dev').AppConfig} */
|
||||
module.exports = {
|
||||
appDirectory: 'app',
|
||||
ignoredRouteFiles: ['**/.*'],
|
||||
watchPaths: ['./public', './.env'],
|
||||
server: './server.ts',
|
||||
/**
|
||||
* The following settings are required to deploy Hydrogen apps to Oxygen:
|
||||
*/
|
||||
publicPath: (process.env.HYDROGEN_ASSET_BASE_URL ?? '/') + 'build/',
|
||||
assetsBuildDirectory: 'dist/client/build',
|
||||
serverBuildPath: 'dist/worker/index.js',
|
||||
serverMainFields: ['browser', 'module', 'main'],
|
||||
serverConditions: ['worker', process.env.NODE_ENV],
|
||||
serverDependenciesToBundle: 'all',
|
||||
serverModuleFormat: 'esm',
|
||||
serverPlatform: 'neutral',
|
||||
serverMinify: process.env.NODE_ENV === 'production',
|
||||
tailwind: true,
|
||||
postcss: true,
|
||||
future: {
|
||||
v2_meta: true,
|
||||
v2_headers: true,
|
||||
v2_errorBoundary: true,
|
||||
v2_routeConvention: true,
|
||||
v2_normalizeFormMethod: true,
|
||||
},
|
||||
};
|
||||
39
examples/hydrogen-2/remix.env.d.ts
vendored
Normal file
39
examples/hydrogen-2/remix.env.d.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
/// <reference types="@remix-run/dev" />
|
||||
/// <reference types="@shopify/remix-oxygen" />
|
||||
/// <reference types="@shopify/oxygen-workers-types" />
|
||||
|
||||
// Enhance TypeScript's built-in typings.
|
||||
import '@total-typescript/ts-reset';
|
||||
|
||||
import type {Storefront, HydrogenCart} from '@shopify/hydrogen';
|
||||
import type {HydrogenSession} from './server';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare local additions to `AppLoadContext` to include the session utilities we injected in `server.ts`.
|
||||
*/
|
||||
declare module '@shopify/remix-oxygen' {
|
||||
export interface AppLoadContext {
|
||||
env: Env;
|
||||
cart: HydrogenCart;
|
||||
storefront: Storefront;
|
||||
session: HydrogenSession;
|
||||
}
|
||||
}
|
||||
253
examples/hydrogen-2/server.ts
Normal file
253
examples/hydrogen-2/server.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
// Virtual entry point for the app
|
||||
import * as remixBuild from '@remix-run/dev/server-build';
|
||||
import {
|
||||
cartGetIdDefault,
|
||||
cartSetIdDefault,
|
||||
createCartHandler,
|
||||
createStorefrontClient,
|
||||
storefrontRedirect,
|
||||
} from '@shopify/hydrogen';
|
||||
import {
|
||||
createRequestHandler,
|
||||
getStorefrontHeaders,
|
||||
createCookieSessionStorage,
|
||||
type SessionStorage,
|
||||
type Session,
|
||||
} from '@shopify/remix-oxygen';
|
||||
|
||||
/**
|
||||
* Export a fetch handler in module format.
|
||||
*/
|
||||
export default {
|
||||
async fetch(
|
||||
request: Request,
|
||||
env: Env,
|
||||
executionContext: ExecutionContext,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
/**
|
||||
* Open a cache instance in the worker and a custom session instance.
|
||||
*/
|
||||
if (!env?.SESSION_SECRET) {
|
||||
throw new Error('SESSION_SECRET environment variable is not set');
|
||||
}
|
||||
|
||||
const waitUntil = (p: Promise<any>) => executionContext.waitUntil(p);
|
||||
const [cache, session] = await Promise.all([
|
||||
caches.open('hydrogen'),
|
||||
HydrogenSession.init(request, [env.SESSION_SECRET]),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Create Hydrogen's Storefront client.
|
||||
*/
|
||||
const {storefront} = createStorefrontClient({
|
||||
cache,
|
||||
waitUntil,
|
||||
i18n: {language: 'EN', country: 'US'},
|
||||
publicStorefrontToken: env.PUBLIC_STOREFRONT_API_TOKEN,
|
||||
privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN,
|
||||
storeDomain: env.PUBLIC_STORE_DOMAIN,
|
||||
storefrontId: env.PUBLIC_STOREFRONT_ID,
|
||||
storefrontHeaders: getStorefrontHeaders(request),
|
||||
});
|
||||
|
||||
/*
|
||||
* Create a cart handler that will be used to
|
||||
* create and update the cart in the session.
|
||||
*/
|
||||
const cart = createCartHandler({
|
||||
storefront,
|
||||
getCartId: cartGetIdDefault(request.headers),
|
||||
setCartId: cartSetIdDefault(),
|
||||
cartQueryFragment: CART_QUERY_FRAGMENT,
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a Remix request handler and pass
|
||||
* Hydrogen's Storefront client to the loader context.
|
||||
*/
|
||||
const handleRequest = createRequestHandler({
|
||||
build: remixBuild,
|
||||
mode: process.env.NODE_ENV,
|
||||
getLoadContext: () => ({session, storefront, env, cart}),
|
||||
});
|
||||
|
||||
const response = await handleRequest(request);
|
||||
|
||||
if (response.status === 404) {
|
||||
/**
|
||||
* Check for redirects only when there's a 404 from the app.
|
||||
* If the redirect doesn't exist, then `storefrontRedirect`
|
||||
* will pass through the 404 response.
|
||||
*/
|
||||
return storefrontRedirect({request, response, storefront});
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
return new Response('An unexpected error occurred', {status: 500});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* This is a custom session implementation for your Hydrogen shop.
|
||||
* Feel free to customize it to your needs, add helper methods, or
|
||||
* swap out the cookie-based implementation with something else!
|
||||
*/
|
||||
export class HydrogenSession {
|
||||
constructor(
|
||||
private sessionStorage: SessionStorage,
|
||||
private session: Session,
|
||||
) {}
|
||||
|
||||
static async init(request: Request, secrets: string[]) {
|
||||
const storage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
name: 'session',
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
secrets,
|
||||
},
|
||||
});
|
||||
|
||||
const session = await storage.getSession(request.headers.get('Cookie'));
|
||||
|
||||
return new this(storage, session);
|
||||
}
|
||||
|
||||
has(key: string) {
|
||||
return this.session.has(key);
|
||||
}
|
||||
|
||||
get(key: string) {
|
||||
return this.session.get(key);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
return this.sessionStorage.destroySession(this.session);
|
||||
}
|
||||
|
||||
flash(key: string, value: any) {
|
||||
this.session.flash(key, value);
|
||||
}
|
||||
|
||||
unset(key: string) {
|
||||
this.session.unset(key);
|
||||
}
|
||||
|
||||
set(key: string, value: any) {
|
||||
this.session.set(key, value);
|
||||
}
|
||||
|
||||
commit() {
|
||||
return this.sessionStorage.commitSession(this.session);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/queries/cart
|
||||
const CART_QUERY_FRAGMENT = `#graphql
|
||||
fragment Money on MoneyV2 {
|
||||
currencyCode
|
||||
amount
|
||||
}
|
||||
fragment CartLine on CartLine {
|
||||
id
|
||||
quantity
|
||||
attributes {
|
||||
key
|
||||
value
|
||||
}
|
||||
cost {
|
||||
totalAmount {
|
||||
...Money
|
||||
}
|
||||
amountPerQuantity {
|
||||
...Money
|
||||
}
|
||||
compareAtAmountPerQuantity {
|
||||
...Money
|
||||
}
|
||||
}
|
||||
merchandise {
|
||||
... on ProductVariant {
|
||||
id
|
||||
availableForSale
|
||||
compareAtPrice {
|
||||
...Money
|
||||
}
|
||||
price {
|
||||
...Money
|
||||
}
|
||||
requiresShipping
|
||||
title
|
||||
image {
|
||||
id
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
|
||||
}
|
||||
product {
|
||||
handle
|
||||
title
|
||||
id
|
||||
}
|
||||
selectedOptions {
|
||||
name
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fragment CartApiQuery on Cart {
|
||||
id
|
||||
checkoutUrl
|
||||
totalQuantity
|
||||
buyerIdentity {
|
||||
countryCode
|
||||
customer {
|
||||
id
|
||||
email
|
||||
firstName
|
||||
lastName
|
||||
displayName
|
||||
}
|
||||
email
|
||||
phone
|
||||
}
|
||||
lines(first: $numCartLines) {
|
||||
nodes {
|
||||
...CartLine
|
||||
}
|
||||
}
|
||||
cost {
|
||||
subtotalAmount {
|
||||
...Money
|
||||
}
|
||||
totalAmount {
|
||||
...Money
|
||||
}
|
||||
totalDutyAmount {
|
||||
...Money
|
||||
}
|
||||
totalTaxAmount {
|
||||
...Money
|
||||
}
|
||||
}
|
||||
note
|
||||
attributes {
|
||||
key
|
||||
value
|
||||
}
|
||||
discountCodes {
|
||||
code
|
||||
applicable
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
1906
examples/hydrogen-2/storefrontapi.generated.d.ts
vendored
Normal file
1906
examples/hydrogen-2/storefrontapi.generated.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8
examples/hydrogen-2/tailwind.config.js
Normal file
8
examples/hydrogen-2/tailwind.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import formsPlugin from '@tailwindcss/forms';
|
||||
import typographyPlugin from '@tailwindcss/typography';
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./app/**/*.{js,ts,jsx,tsx}'],
|
||||
plugins: [formsPlugin, typographyPlugin],
|
||||
};
|
||||
22
examples/hydrogen-2/tsconfig.json
Normal file
22
examples/hydrogen-2/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"include": ["./**/*.d.ts", "./**/*.ts", "./**/*.tsx"],
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"target": "ES2022",
|
||||
"strict": true,
|
||||
"allowJs": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"types": ["@shopify/oxygen-workers-types"],
|
||||
"paths": {
|
||||
"~/*": ["app/*"]
|
||||
},
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
6
examples/hydrogen-2/vercel.json
Normal file
6
examples/hydrogen-2/vercel.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"env": {
|
||||
"SESSION_SECRET": "foobar",
|
||||
"PUBLIC_STORE_DOMAIN": "mock.shop"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user