mirror of
https://github.com/LukeHagar/vercel.git
synced 2025-12-09 12:57:46 +00:00
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)
246 lines
5.5 KiB
TypeScript
246 lines
5.5 KiB
TypeScript
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;
|