mirror of
https://github.com/LukeHagar/vercel.git
synced 2025-12-11 12:57:46 +00:00
Compare commits
73 Commits
add-middle
...
@vercel/py
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2f8d178f7 | ||
|
|
f9a747764c | ||
|
|
27d80f13cd | ||
|
|
8c668c925d | ||
|
|
4b1b33c143 | ||
|
|
a8d4147554 | ||
|
|
09339f494d | ||
|
|
ee4d772ae9 | ||
|
|
61e8103404 | ||
|
|
fb4f477325 | ||
|
|
016bff848e | ||
|
|
183e411f7c | ||
|
|
070e300148 | ||
|
|
cbdf9b4a88 | ||
|
|
ec9b55dc81 | ||
|
|
06829bc21a | ||
|
|
628071f659 | ||
|
|
5a7461dfe3 | ||
|
|
599f8f675c | ||
|
|
0a8bc494fc | ||
|
|
34e008f42e | ||
|
|
037633b3f1 | ||
|
|
1a6f3c0270 | ||
|
|
0a2af4fb94 | ||
|
|
3fb1c50142 | ||
|
|
de033c43fd | ||
|
|
f8e5df749c | ||
|
|
5670acc2cc | ||
|
|
5205047851 | ||
|
|
1edc2d06c9 | ||
|
|
fdb15b2539 | ||
|
|
32ebcd83a7 | ||
|
|
2e43b2b88a | ||
|
|
f83d432fcd | ||
|
|
87fc38e860 | ||
|
|
afc4388fc0 | ||
|
|
3c48b40b43 | ||
|
|
ce89f00328 | ||
|
|
621b53bc49 | ||
|
|
728b620355 | ||
|
|
7d16395038 | ||
|
|
59e1259688 | ||
|
|
169242157e | ||
|
|
db10ffd679 | ||
|
|
c0d0744c4e | ||
|
|
9da67423a5 | ||
|
|
51fe09d5e9 | ||
|
|
695bfbdd60 | ||
|
|
547e88228e | ||
|
|
9bfb5dd535 | ||
|
|
81ea84fae8 | ||
|
|
fa8bf07be4 | ||
|
|
cc9dce73ad | ||
|
|
bba7cbd411 | ||
|
|
9a3739bebd | ||
|
|
8c62de16ce | ||
|
|
e9333988d7 | ||
|
|
fb001ce7eb | ||
|
|
b399fe7037 | ||
|
|
88385b3c84 | ||
|
|
eed39913e1 | ||
|
|
03e9047bc9 | ||
|
|
0e35205bf1 | ||
|
|
e42fe34c4a | ||
|
|
3ece7ac969 | ||
|
|
4f832acf90 | ||
|
|
918726e01d | ||
|
|
dc2ddf867b | ||
|
|
ee1211416f | ||
|
|
570fd24e29 | ||
|
|
40681ad0f4 | ||
|
|
f20703b15d | ||
|
|
68eb197112 |
@@ -19,6 +19,9 @@ packages/cli/src/util/dev/templates/*.ts
|
||||
packages/client/tests/fixtures
|
||||
packages/client/lib
|
||||
|
||||
# hydrogen
|
||||
packages/hydrogen/edge-entry.js
|
||||
|
||||
# next
|
||||
packages/next/test/integration/middleware
|
||||
packages/next/test/integration/middleware-eval
|
||||
|
||||
4
.prettierignore
Normal file
4
.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
||||
# https://prettier.io/docs/en/ignore.html
|
||||
|
||||
# ignore this file with an intentional syntax error
|
||||
packages/cli/test/dev/fixtures/edge-function-error/api/edge-error-syntax.js
|
||||
@@ -5,7 +5,8 @@
|
||||
"description": "API for the vercel/vercel repo",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"vercel-build": "node ../utils/run.js build all"
|
||||
"//TODO": "We should add this pkg to yarn workspaces",
|
||||
"vercel-build": "cd .. && yarn install && yarn vercel-build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/node": "5.11.1",
|
||||
|
||||
@@ -24,7 +24,6 @@ export function sendToVercelAnalytics(metric) {
|
||||
speed: getConnectionSpeed(),
|
||||
};
|
||||
|
||||
console.log({ body });
|
||||
const blob = new Blob([new URLSearchParams(body).toString()], {
|
||||
// This content type is necessary for `sendBeacon`
|
||||
type: 'application/x-www-form-urlencoded',
|
||||
|
||||
18
examples/hydrogen/.devcontainer/devcontainer.json
Normal file
18
examples/hydrogen/.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Shopify Hydrogen",
|
||||
"image": "mcr.microsoft.com/vscode/devcontainers/javascript-node:0-16",
|
||||
"settings": {},
|
||||
"extensions": [
|
||||
"graphql.vscode-graphql",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"esbenp.prettier-vscode"
|
||||
],
|
||||
"forwardPorts": [3000],
|
||||
"postCreateCommand": "yarn install",
|
||||
"postStartCommand": "yarn dev",
|
||||
"remoteUser": "node",
|
||||
"features": {
|
||||
"git": "latest"
|
||||
}
|
||||
}
|
||||
8
examples/hydrogen/.eslintrc.js
Normal file
8
examples/hydrogen/.eslintrc.js
Normal file
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
extends: ['plugin:hydrogen/recommended', 'plugin:hydrogen/typescript'],
|
||||
rules: {
|
||||
'node/no-missing-import': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/naming-convention': 'off',
|
||||
},
|
||||
};
|
||||
79
examples/hydrogen/.gitignore
vendored
Normal file
79
examples/hydrogen/.gitignore
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# Vite output
|
||||
dist
|
||||
|
||||
.vercel
|
||||
50
examples/hydrogen/README.md
Normal file
50
examples/hydrogen/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Hydrogen
|
||||
|
||||
[Hydrogen](https://shopify.dev/custom-storefronts/hydrogen) is a React framework and SDK that you can use to build fast and dynamic Shopify custom storefronts.
|
||||
|
||||
## Deploy Your Own
|
||||
|
||||
Deploy your own Hydrogen project with Vercel.
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/vercel/vercel/tree/main/examples/hydrogen&template=hydrogen)
|
||||
|
||||
_Live Example: https://hydrogen-template.vercel.app_
|
||||
|
||||
## Getting started
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- Node.js version 16.5.0 or higher
|
||||
- Yarn
|
||||
|
||||
To create a new Hydrogen app, run:
|
||||
|
||||
```bash
|
||||
npm init @shopify/hydrogen
|
||||
```
|
||||
|
||||
## Running the dev server
|
||||
|
||||
Then `cd` into the new directory and run:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Remember to update `hydrogen.config.js` with your shop's domain and Storefront API token!
|
||||
|
||||
## Building for production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Previewing a production build
|
||||
|
||||
To run a local preview of your Hydrogen app in an environment similar to Oxygen, build your Hydrogen app and then run `npm run preview`:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
18
examples/hydrogen/hydrogen.config.ts
Normal file
18
examples/hydrogen/hydrogen.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import {defineConfig, CookieSessionStorage} from '@shopify/hydrogen/config';
|
||||
|
||||
export default defineConfig({
|
||||
shopify: {
|
||||
defaultCountryCode: 'US',
|
||||
defaultLanguageCode: 'EN',
|
||||
storeDomain: 'hydrogen-preview.myshopify.com',
|
||||
storefrontToken: '3b580e70970c4528da70c98e097c2fa0',
|
||||
storefrontApiVersion: '2022-07',
|
||||
},
|
||||
session: CookieSessionStorage('__session', {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: import.meta.env.PROD,
|
||||
sameSite: 'Strict',
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
}),
|
||||
});
|
||||
17
examples/hydrogen/index.html
Normal file
17
examples/hydrogen/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/src/assets/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hydrogen</title>
|
||||
<link rel="stylesheet" href="/src/styles/index.css" />
|
||||
<link rel="preconnect" href="https://cdn.shopify.com" />
|
||||
<link rel="preconnect" href="https://shop.app/" />
|
||||
<link rel="preconnect" href="https://hydrogen-preview.myshopify.com/" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/@shopify/hydrogen/entry-client"></script>
|
||||
</body>
|
||||
</html>
|
||||
49
examples/hydrogen/package.json
Normal file
49
examples/hydrogen/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "hydrogen",
|
||||
"description": "Demo store template for @shopify/hydrogen",
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "shopify hydrogen dev",
|
||||
"build": "shopify hydrogen build",
|
||||
"preview": "shopify hydrogen preview",
|
||||
"lint": "eslint --ext .js,.jsx,.ts,.tsx src",
|
||||
"lint-ts": "tsc --noEmit",
|
||||
"test": "WATCH=true vitest",
|
||||
"test:ci": "yarn build -t node && vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@shopify/cli": "3.0.27",
|
||||
"@shopify/cli-hydrogen": "3.0.27",
|
||||
"@shopify/prettier-config": "^1.1.2",
|
||||
"@tailwindcss/forms": "^0.5.2",
|
||||
"@tailwindcss/typography": "^0.5.2",
|
||||
"@types/react": "^18.0.14",
|
||||
"eslint": "^8.18.0",
|
||||
"eslint-plugin-hydrogen": "^0.12.2",
|
||||
"playwright": "^1.22.2",
|
||||
"postcss": "^8.4.14",
|
||||
"postcss-import": "^14.1.0",
|
||||
"postcss-preset-env": "^7.6.0",
|
||||
"prettier": "^2.3.2",
|
||||
"tailwindcss": "^3.0.24",
|
||||
"typescript": "^4.7.2",
|
||||
"vite": "^2.9.0",
|
||||
"vitest": "^0.15.2"
|
||||
},
|
||||
"prettier": "@shopify/prettier-config",
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.6.4",
|
||||
"@heroicons/react": "^1.0.6",
|
||||
"@shopify/hydrogen": "^1.0.2",
|
||||
"clsx": "^1.1.1",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-use": "^17.4.0",
|
||||
"title": "^3.4.4",
|
||||
"typographic-base": "^1.0.4"
|
||||
},
|
||||
"author": "nrajlich"
|
||||
}
|
||||
10
examples/hydrogen/postcss.config.js
Normal file
10
examples/hydrogen/postcss.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'postcss-import': {},
|
||||
'tailwindcss/nesting': {},
|
||||
tailwindcss: {},
|
||||
'postcss-preset-env': {
|
||||
features: {'nesting-rules': false},
|
||||
},
|
||||
},
|
||||
};
|
||||
BIN
examples/hydrogen/public/fonts/IBMPlexSerif-Text.woff2
Normal file
BIN
examples/hydrogen/public/fonts/IBMPlexSerif-Text.woff2
Normal file
Binary file not shown.
BIN
examples/hydrogen/public/fonts/IBMPlexSerif-TextItalic.woff2
Normal file
BIN
examples/hydrogen/public/fonts/IBMPlexSerif-TextItalic.woff2
Normal file
Binary file not shown.
48
examples/hydrogen/src/App.server.tsx
Normal file
48
examples/hydrogen/src/App.server.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import {Suspense} from 'react';
|
||||
import renderHydrogen from '@shopify/hydrogen/entry-server';
|
||||
import {
|
||||
FileRoutes,
|
||||
type HydrogenRouteProps,
|
||||
PerformanceMetrics,
|
||||
PerformanceMetricsDebug,
|
||||
Route,
|
||||
Router,
|
||||
ShopifyAnalytics,
|
||||
ShopifyProvider,
|
||||
CartProvider,
|
||||
} from '@shopify/hydrogen';
|
||||
|
||||
import {HeaderFallback} from '~/components';
|
||||
import type {CountryCode} from '@shopify/hydrogen/storefront-api-types';
|
||||
import {DefaultSeo, NotFound} from '~/components/index.server';
|
||||
|
||||
function App({request}: HydrogenRouteProps) {
|
||||
const pathname = new URL(request.normalizedUrl).pathname;
|
||||
const localeMatch = /^\/([a-z]{2})(\/|$)/i.exec(pathname);
|
||||
const countryCode = localeMatch ? (localeMatch[1] as CountryCode) : undefined;
|
||||
|
||||
const isHome = pathname === `/${countryCode ? countryCode + '/' : ''}`;
|
||||
|
||||
return (
|
||||
<Suspense fallback={<HeaderFallback isHome={isHome} />}>
|
||||
<ShopifyProvider countryCode={countryCode}>
|
||||
<CartProvider countryCode={countryCode}>
|
||||
<Suspense>
|
||||
<DefaultSeo />
|
||||
</Suspense>
|
||||
<Router>
|
||||
<FileRoutes
|
||||
basePath={countryCode ? `/${countryCode}/` : undefined}
|
||||
/>
|
||||
<Route path="*" page={<NotFound />} />
|
||||
</Router>
|
||||
</CartProvider>
|
||||
<PerformanceMetrics />
|
||||
{import.meta.env.DEV && <PerformanceMetricsDebug />}
|
||||
<ShopifyAnalytics />
|
||||
</ShopifyProvider>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export default renderHydrogen(App);
|
||||
28
examples/hydrogen/src/assets/favicon.svg
Normal file
28
examples/hydrogen/src/assets/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 |
135
examples/hydrogen/src/components/CountrySelector.client.tsx
Normal file
135
examples/hydrogen/src/components/CountrySelector.client.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import {useCallback, useState, Suspense} from 'react';
|
||||
import {useLocalization, fetchSync} from '@shopify/hydrogen';
|
||||
// @ts-expect-error @headlessui/react incompatibility with node16 resolution
|
||||
import {Listbox} from '@headlessui/react';
|
||||
|
||||
import {IconCheck, IconCaret} from '~/components';
|
||||
import {useMemo} from 'react';
|
||||
import type {
|
||||
Country,
|
||||
CountryCode,
|
||||
} from '@shopify/hydrogen/storefront-api-types';
|
||||
|
||||
/**
|
||||
* A client component that selects the appropriate country to display for products on a website
|
||||
*/
|
||||
export function CountrySelector() {
|
||||
const [listboxOpen, setListboxOpen] = useState(false);
|
||||
const {
|
||||
country: {isoCode},
|
||||
} = useLocalization();
|
||||
const currentCountry = useMemo<{name: string; isoCode: CountryCode}>(() => {
|
||||
const regionNamesInEnglish = new Intl.DisplayNames(['en'], {
|
||||
type: 'region',
|
||||
});
|
||||
|
||||
return {
|
||||
name: regionNamesInEnglish.of(isoCode)!,
|
||||
isoCode: isoCode as CountryCode,
|
||||
};
|
||||
}, [isoCode]);
|
||||
|
||||
const setCountry = useCallback<(country: Country) => void>(
|
||||
({isoCode: newIsoCode}) => {
|
||||
const currentPath = window.location.pathname;
|
||||
let redirectPath;
|
||||
|
||||
if (newIsoCode !== 'US') {
|
||||
if (currentCountry.isoCode === 'US') {
|
||||
redirectPath = `/${newIsoCode.toLowerCase()}${currentPath}`;
|
||||
} else {
|
||||
redirectPath = `/${newIsoCode.toLowerCase()}${currentPath.substring(
|
||||
currentPath.indexOf('/', 1),
|
||||
)}`;
|
||||
}
|
||||
} else {
|
||||
redirectPath = `${currentPath.substring(currentPath.indexOf('/', 1))}`;
|
||||
}
|
||||
|
||||
window.location.href = redirectPath;
|
||||
},
|
||||
[currentCountry],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Listbox onChange={setCountry}>
|
||||
{/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
|
||||
{({open}) => {
|
||||
setTimeout(() => setListboxOpen(open));
|
||||
return (
|
||||
<>
|
||||
<Listbox.Button
|
||||
className={`flex items-center justify-between w-full py-3 px-4 border ${
|
||||
open ? 'rounded-b md:rounded-t md:rounded-b-none' : 'rounded'
|
||||
} border-contrast/30 dark:border-white`}
|
||||
>
|
||||
<span className="">{currentCountry.name}</span>
|
||||
<IconCaret direction={open ? 'up' : 'down'} />
|
||||
</Listbox.Button>
|
||||
|
||||
<Listbox.Options
|
||||
className={`border-t-contrast/30 border-contrast/30 bg-primary dark:bg-contrast absolute bottom-12 z-10 grid
|
||||
h-48 w-full overflow-y-scroll rounded-t border dark:border-white px-2 py-2
|
||||
transition-[max-height] duration-150 sm:bottom-auto md:rounded-b md:rounded-t-none
|
||||
md:border-t-0 md:border-b ${
|
||||
listboxOpen ? 'max-h-48' : 'max-h-0'
|
||||
}`}
|
||||
>
|
||||
{listboxOpen && (
|
||||
<Suspense fallback={<div className="p-2">Loading…</div>}>
|
||||
{/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
|
||||
<Countries
|
||||
selectedCountry={currentCountry}
|
||||
getClassName={(active) => {
|
||||
return `text-contrast dark:text-primary bg-primary
|
||||
dark:bg-contrast w-full p-2 transition rounded
|
||||
flex justify-start items-center text-left cursor-pointer ${
|
||||
active ? 'bg-primary/10' : null
|
||||
}`;
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</Listbox.Options>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Listbox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Countries({
|
||||
selectedCountry,
|
||||
getClassName,
|
||||
}: {
|
||||
selectedCountry: Pick<Country, 'isoCode' | 'name'>;
|
||||
getClassName: (active: boolean) => string;
|
||||
}) {
|
||||
const countries: Country[] = fetchSync('/api/countries').json();
|
||||
|
||||
return (countries || []).map((country) => {
|
||||
const isSelected = country.isoCode === selectedCountry.isoCode;
|
||||
|
||||
return (
|
||||
<Listbox.Option key={country.isoCode} value={country}>
|
||||
{/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
|
||||
{({active}) => (
|
||||
<div
|
||||
className={`text-contrast dark:text-primary ${getClassName(
|
||||
active,
|
||||
)}`}
|
||||
>
|
||||
{country.name}
|
||||
{isSelected ? (
|
||||
<span className="ml-2">
|
||||
<IconCheck />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
);
|
||||
});
|
||||
}
|
||||
22
examples/hydrogen/src/components/CustomFont.client.tsx
Normal file
22
examples/hydrogen/src/components/CustomFont.client.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
// When making building your custom storefront, you will most likely want to
|
||||
// use custom fonts as well. These are often implemented without critical
|
||||
// performance optimizations.
|
||||
|
||||
// Below, you'll find the markup needed to optimally render a pair of web fonts
|
||||
// that we will use on our journal articles. This typeface, IBM Plex,
|
||||
// can be found at: https://www.ibm.com/plex/, as well as on
|
||||
// Google Fonts: https://fonts.google.com/specimen/IBM+Plex+Serif. We included
|
||||
// these locally since you’ll most likely be using commercially licensed fonts.
|
||||
|
||||
// When implementing a custom font, specifying the Unicode range you need,
|
||||
// and using `font-display: swap` will help you improve your performance.
|
||||
|
||||
// For fonts that appear in the critical rendering path, you can speed up
|
||||
// performance even more by including a <link> tag in your HTML.
|
||||
|
||||
// In a production environment, you will likely want to include the below
|
||||
// markup right in your index.html and index.css files.
|
||||
|
||||
import '../styles/custom-font.css';
|
||||
|
||||
export function CustomFont() {}
|
||||
37
examples/hydrogen/src/components/DefaultSeo.server.tsx
Normal file
37
examples/hydrogen/src/components/DefaultSeo.server.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import {CacheLong, gql, Seo, useShopQuery} from '@shopify/hydrogen';
|
||||
|
||||
/**
|
||||
* A server component that fetches a `shop.name` and sets default values and templates for every page on a website
|
||||
*/
|
||||
export function DefaultSeo() {
|
||||
const {
|
||||
data: {
|
||||
shop: {name, description},
|
||||
},
|
||||
} = useShopQuery({
|
||||
query: SHOP_QUERY,
|
||||
cache: CacheLong(),
|
||||
preload: '*',
|
||||
});
|
||||
|
||||
return (
|
||||
// @ts-ignore TODO: Fix types
|
||||
<Seo
|
||||
type="defaultSeo"
|
||||
data={{
|
||||
title: name,
|
||||
description,
|
||||
titleTemplate: `%s · ${name}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const SHOP_QUERY = gql`
|
||||
query shopInfo {
|
||||
shop {
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
`;
|
||||
30
examples/hydrogen/src/components/HeaderFallback.tsx
Normal file
30
examples/hydrogen/src/components/HeaderFallback.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
export function HeaderFallback({isHome}: {isHome?: boolean}) {
|
||||
const styles = isHome
|
||||
? 'bg-primary/80 dark:bg-contrast/60 text-contrast dark:text-primary shadow-darkHeader'
|
||||
: 'bg-contrast/80 text-primary';
|
||||
return (
|
||||
<header
|
||||
role="banner"
|
||||
className={`${styles} flex h-nav items-center backdrop-blur-lg z-40 top-0 justify-between w-full leading-none gap-8 px-12 py-8`}
|
||||
>
|
||||
<div className="flex space-x-4">
|
||||
<Box isHome={isHome} />
|
||||
<Box isHome={isHome} />
|
||||
<Box isHome={isHome} />
|
||||
<Box isHome={isHome} />
|
||||
<Box isHome={isHome} />
|
||||
</div>
|
||||
<Box isHome={isHome} wide={true} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function Box({wide, isHome}: {wide?: boolean; isHome?: boolean}) {
|
||||
return (
|
||||
<div
|
||||
className={`h-6 rounded-sm ${wide ? 'w-32' : 'w-16'} ${
|
||||
isHome ? 'bg-primary/60' : 'bg-primary/20'
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import {useState} from 'react';
|
||||
import {useNavigate} from '@shopify/hydrogen/client';
|
||||
|
||||
export function AccountActivateForm({
|
||||
id,
|
||||
activationToken,
|
||||
}: {
|
||||
id: string;
|
||||
activationToken: string;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [submitError, setSubmitError] = useState<null | string>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordError, setPasswordError] = useState<null | string>(null);
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||
const [passwordConfirmError, setPasswordConfirmError] = useState<
|
||||
null | string
|
||||
>(null);
|
||||
|
||||
function passwordValidation(
|
||||
form: HTMLFormElement & {password: HTMLInputElement},
|
||||
) {
|
||||
setPasswordError(null);
|
||||
setPasswordConfirmError(null);
|
||||
|
||||
let hasError = false;
|
||||
|
||||
if (!form.password.validity.valid) {
|
||||
hasError = true;
|
||||
setPasswordError(
|
||||
form.password.validity.valueMissing
|
||||
? 'Please enter a password'
|
||||
: 'Passwords must be at least 6 characters',
|
||||
);
|
||||
}
|
||||
|
||||
if (!form.passwordConfirm.validity.valid) {
|
||||
hasError = true;
|
||||
setPasswordConfirmError(
|
||||
form.password.validity.valueMissing
|
||||
? 'Please re-enter a password'
|
||||
: 'Passwords must be at least 6 characters',
|
||||
);
|
||||
}
|
||||
|
||||
if (password !== passwordConfirm) {
|
||||
hasError = true;
|
||||
setPasswordConfirmError('The two passwords entered did not match.');
|
||||
}
|
||||
|
||||
return hasError;
|
||||
}
|
||||
|
||||
async function onSubmit(
|
||||
event: React.FormEvent<HTMLFormElement & {password: HTMLInputElement}>,
|
||||
) {
|
||||
event.preventDefault();
|
||||
|
||||
if (passwordValidation(event.currentTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await callActivateApi({
|
||||
id,
|
||||
activationToken,
|
||||
password,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
setSubmitError(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/account');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div className="w-full max-w-md">
|
||||
<h1 className="text-4xl">Activate Account.</h1>
|
||||
<p className="mt-4">Create your password to activate your account.</p>
|
||||
<form noValidate className="pt-6 pb-8 mt-4 mb-4" onSubmit={onSubmit}>
|
||||
{submitError && (
|
||||
<div className="flex items-center justify-center mb-6 bg-primary/30">
|
||||
<p className="m-4 text-s text-contrast">{submitError}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4">
|
||||
<input
|
||||
className={`mb-1 appearance-none border w-full py-2 px-3 text-primary placeholder:text-primary/30 leading-tight focus:shadow-outline ${
|
||||
passwordError ? ' border-notice' : 'border-primary'
|
||||
}`}
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Password"
|
||||
aria-label="Password"
|
||||
value={password}
|
||||
minLength={8}
|
||||
required
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
className={`text-red-500 text-xs ${
|
||||
!passwordError ? 'invisible' : ''
|
||||
}`}
|
||||
>
|
||||
{passwordError}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<input
|
||||
className={`mb-1 appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline ${
|
||||
passwordConfirmError ? ' border-red-500' : 'border-gray-900'
|
||||
}`}
|
||||
id="passwordConfirm"
|
||||
name="passwordConfirm"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Re-enter password"
|
||||
aria-label="Re-enter password"
|
||||
value={passwordConfirm}
|
||||
required
|
||||
minLength={8}
|
||||
onChange={(event) => {
|
||||
setPasswordConfirm(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
className={`text-red-500 text-xs ${
|
||||
!passwordConfirmError ? 'invisible' : ''
|
||||
}`}
|
||||
>
|
||||
{passwordConfirmError}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
className="block w-full px-4 py-2 text-contrast uppercase bg-gray-900 focus:shadow-outline"
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function callActivateApi({
|
||||
id,
|
||||
activationToken,
|
||||
password,
|
||||
}: {
|
||||
id: string;
|
||||
activationToken: string;
|
||||
password: string;
|
||||
}) {
|
||||
try {
|
||||
const res = await fetch(`/account/activate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({id, activationToken, password}),
|
||||
});
|
||||
if (res.ok) {
|
||||
return {};
|
||||
} else {
|
||||
return res.json();
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
error: error.toString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import {useState, useMemo, MouseEventHandler} from 'react';
|
||||
|
||||
import {Text, Button} from '~/components/elements';
|
||||
import {Modal} from '../index';
|
||||
import {AccountAddressEdit, AccountDeleteAddress} from '../index';
|
||||
|
||||
export function AccountAddressBook({
|
||||
addresses,
|
||||
defaultAddress,
|
||||
}: {
|
||||
addresses: any[];
|
||||
defaultAddress: any;
|
||||
}) {
|
||||
const [editingAddress, setEditingAddress] = useState(null);
|
||||
const [deletingAddress, setDeletingAddress] = useState(null);
|
||||
|
||||
const {fullDefaultAddress, addressesWithoutDefault} = useMemo(() => {
|
||||
const defaultAddressIndex = addresses.findIndex(
|
||||
(address) => address.id === defaultAddress,
|
||||
);
|
||||
return {
|
||||
addressesWithoutDefault: [
|
||||
...addresses.slice(0, defaultAddressIndex),
|
||||
...addresses.slice(defaultAddressIndex + 1, addresses.length),
|
||||
],
|
||||
fullDefaultAddress: addresses[defaultAddressIndex],
|
||||
};
|
||||
}, [addresses, defaultAddress]);
|
||||
|
||||
function close() {
|
||||
setEditingAddress(null);
|
||||
setDeletingAddress(null);
|
||||
}
|
||||
|
||||
function editAddress(address: any) {
|
||||
setEditingAddress(address);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{deletingAddress ? (
|
||||
<Modal close={close}>
|
||||
<AccountDeleteAddress addressId={deletingAddress} close={close} />
|
||||
</Modal>
|
||||
) : null}
|
||||
{editingAddress ? (
|
||||
<Modal close={close}>
|
||||
<AccountAddressEdit
|
||||
address={editingAddress}
|
||||
defaultAddress={fullDefaultAddress === editingAddress}
|
||||
close={close}
|
||||
/>
|
||||
</Modal>
|
||||
) : null}
|
||||
<div className="grid w-full gap-4 p-4 py-6 md:gap-8 md:p-8 lg:p-12">
|
||||
<h3 className="font-bold text-lead">Address Book</h3>
|
||||
<div>
|
||||
{!addresses?.length ? (
|
||||
<Text className="mb-1" width="narrow" as="p" size="copy">
|
||||
You haven't saved any addresses yet.
|
||||
</Text>
|
||||
) : null}
|
||||
<div className="w-48">
|
||||
<Button
|
||||
className="mt-2 text-sm w-full mb-6"
|
||||
onClick={() => {
|
||||
editAddress({
|
||||
/** empty address */
|
||||
});
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
Add an Address
|
||||
</Button>
|
||||
</div>
|
||||
{addresses?.length ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
|
||||
{fullDefaultAddress ? (
|
||||
<Address
|
||||
address={fullDefaultAddress}
|
||||
defaultAddress
|
||||
setDeletingAddress={setDeletingAddress.bind(
|
||||
null,
|
||||
fullDefaultAddress.originalId,
|
||||
)}
|
||||
editAddress={editAddress}
|
||||
/>
|
||||
) : null}
|
||||
{addressesWithoutDefault.map((address) => (
|
||||
<Address
|
||||
key={address.id}
|
||||
address={address}
|
||||
setDeletingAddress={setDeletingAddress.bind(
|
||||
null,
|
||||
address.originalId,
|
||||
)}
|
||||
editAddress={editAddress}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Address({
|
||||
address,
|
||||
defaultAddress,
|
||||
editAddress,
|
||||
setDeletingAddress,
|
||||
}: {
|
||||
address: any;
|
||||
defaultAddress?: boolean;
|
||||
editAddress: (address: any) => void;
|
||||
setDeletingAddress: MouseEventHandler<HTMLButtonElement>;
|
||||
}) {
|
||||
return (
|
||||
<div className="lg:p-8 p-6 border border-gray-200 rounded flex flex-col">
|
||||
{defaultAddress ? (
|
||||
<div className="mb-3 flex flex-row">
|
||||
<span className="px-3 py-1 text-xs font-medium rounded-full bg-primary/20 text-primary/50">
|
||||
Default
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<ul className="flex-1 flex-row">
|
||||
{address.firstName || address.lastName ? (
|
||||
<li>
|
||||
{(address.firstName && address.firstName + ' ') + address.lastName}
|
||||
</li>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{address.formatted ? (
|
||||
address.formatted.map((line: string) => <li key={line}>{line}</li>)
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-row font-medium mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
editAddress(address);
|
||||
}}
|
||||
className="text-left underline text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={setDeletingAddress}
|
||||
className="text-left text-primary/50 ml-6 text-sm"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import {useMemo, useState} from 'react';
|
||||
import {useRenderServerComponents} from '~/lib/utils';
|
||||
|
||||
import {Button, Text} from '~/components';
|
||||
|
||||
export function AccountAddressEdit({
|
||||
address,
|
||||
defaultAddress,
|
||||
close,
|
||||
}: {
|
||||
address: any;
|
||||
defaultAddress: boolean;
|
||||
close: () => void;
|
||||
}) {
|
||||
const isNewAddress = useMemo(() => !Object.keys(address).length, [address]);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<null | string>(null);
|
||||
const [address1, setAddress1] = useState(address?.address1 || '');
|
||||
const [address2, setAddress2] = useState(address?.address2 || '');
|
||||
const [firstName, setFirstName] = useState(address?.firstName || '');
|
||||
const [lastName, setLastName] = useState(address?.lastName || '');
|
||||
const [company, setCompany] = useState(address?.company || '');
|
||||
const [country, setCountry] = useState(address?.country || '');
|
||||
const [province, setProvince] = useState(address?.province || '');
|
||||
const [city, setCity] = useState(address?.city || '');
|
||||
const [zip, setZip] = useState(address?.zip || '');
|
||||
const [phone, setPhone] = useState(address?.phone || '');
|
||||
const [isDefaultAddress, setIsDefaultAddress] = useState(defaultAddress);
|
||||
|
||||
// Necessary for edits to show up on the main page
|
||||
const renderServerComponents = useRenderServerComponents();
|
||||
|
||||
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
setSaving(true);
|
||||
|
||||
const response = await callUpdateAddressApi({
|
||||
id: address?.originalId,
|
||||
firstName,
|
||||
lastName,
|
||||
company,
|
||||
address1,
|
||||
address2,
|
||||
country,
|
||||
province,
|
||||
city,
|
||||
zip,
|
||||
phone,
|
||||
isDefaultAddress,
|
||||
});
|
||||
|
||||
setSaving(false);
|
||||
|
||||
if (response.error) {
|
||||
setSubmitError(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
renderServerComponents();
|
||||
close();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text className="mt-4 mb-6" as="h3" size="lead">
|
||||
{isNewAddress ? 'Add address' : 'Edit address'}
|
||||
</Text>
|
||||
<div className="max-w-lg">
|
||||
<form noValidate onSubmit={onSubmit}>
|
||||
{submitError && (
|
||||
<div className="flex items-center justify-center mb-6 bg-red-100 rounded">
|
||||
<p className="m-4 text-sm text-red-900">{submitError}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="firstname"
|
||||
name="firstname"
|
||||
required
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
placeholder="First name"
|
||||
aria-label="First name"
|
||||
value={firstName}
|
||||
onChange={(event) => {
|
||||
setFirstName(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="lastname"
|
||||
name="lastname"
|
||||
required
|
||||
type="text"
|
||||
autoComplete="family-name"
|
||||
placeholder="Last name"
|
||||
aria-label="Last name"
|
||||
value={lastName}
|
||||
onChange={(event) => {
|
||||
setLastName(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="company"
|
||||
name="company"
|
||||
type="text"
|
||||
autoComplete="organization"
|
||||
placeholder="Company"
|
||||
aria-label="Company"
|
||||
value={company}
|
||||
onChange={(event) => {
|
||||
setCompany(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="street1"
|
||||
name="street1"
|
||||
type="text"
|
||||
autoComplete="address-line1"
|
||||
placeholder="Address line 1*"
|
||||
required
|
||||
aria-label="Address line 1"
|
||||
value={address1}
|
||||
onChange={(event) => {
|
||||
setAddress1(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="address2"
|
||||
name="address2"
|
||||
type="text"
|
||||
autoComplete="address-line2"
|
||||
placeholder="Addresss line 2"
|
||||
aria-label="Address line 2"
|
||||
value={address2}
|
||||
onChange={(event) => {
|
||||
setAddress2(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="city"
|
||||
name="city"
|
||||
type="text"
|
||||
required
|
||||
autoComplete="address-level2"
|
||||
placeholder="City"
|
||||
aria-label="City"
|
||||
value={city}
|
||||
onChange={(event) => {
|
||||
setCity(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="state"
|
||||
name="state"
|
||||
type="text"
|
||||
autoComplete="address-level1"
|
||||
placeholder="State / Province"
|
||||
required
|
||||
aria-label="State"
|
||||
value={province}
|
||||
onChange={(event) => {
|
||||
setProvince(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="zip"
|
||||
name="zip"
|
||||
type="text"
|
||||
autoComplete="postal-code"
|
||||
placeholder="Zip / Postal Code"
|
||||
required
|
||||
aria-label="Zip"
|
||||
value={zip}
|
||||
onChange={(event) => {
|
||||
setZip(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="country"
|
||||
name="country"
|
||||
type="text"
|
||||
autoComplete="country-name"
|
||||
placeholder="Country"
|
||||
required
|
||||
aria-label="Country"
|
||||
value={country}
|
||||
onChange={(event) => {
|
||||
setCountry(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
placeholder="Phone"
|
||||
aria-label="Phone"
|
||||
value={phone}
|
||||
onChange={(event) => {
|
||||
setPhone(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
value=""
|
||||
name="defaultAddress"
|
||||
id="defaultAddress"
|
||||
checked={isDefaultAddress}
|
||||
className="border-gray-500 rounded-sm cursor-pointer border-1"
|
||||
onChange={() => setIsDefaultAddress(!isDefaultAddress)}
|
||||
/>
|
||||
<label
|
||||
className="inline-block ml-2 text-sm cursor-pointer"
|
||||
htmlFor="defaultAddress"
|
||||
>
|
||||
Set as default address
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<Button
|
||||
className="w-full rounded focus:shadow-outline"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={saving}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
className="w-full mt-2 rounded focus:shadow-outline"
|
||||
variant="secondary"
|
||||
onClick={close}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export async function callUpdateAddressApi({
|
||||
id,
|
||||
firstName,
|
||||
lastName,
|
||||
company,
|
||||
address1,
|
||||
address2,
|
||||
country,
|
||||
province,
|
||||
city,
|
||||
phone,
|
||||
zip,
|
||||
isDefaultAddress,
|
||||
}: {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
company: string;
|
||||
address1: string;
|
||||
address2: string;
|
||||
country: string;
|
||||
province: string;
|
||||
city: string;
|
||||
phone: string;
|
||||
zip: string;
|
||||
isDefaultAddress: boolean;
|
||||
}) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
id ? `/account/address/${encodeURIComponent(id)}` : '/account/address',
|
||||
{
|
||||
method: id ? 'PATCH' : 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
firstName,
|
||||
lastName,
|
||||
company,
|
||||
address1,
|
||||
address2,
|
||||
country,
|
||||
province,
|
||||
city,
|
||||
phone,
|
||||
zip,
|
||||
isDefaultAddress,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (res.ok) {
|
||||
return {};
|
||||
} else {
|
||||
return res.json();
|
||||
}
|
||||
} catch (_e) {
|
||||
return {
|
||||
error: 'Error saving address. Please try again.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import {useState} from 'react';
|
||||
import {useNavigate, Link} from '@shopify/hydrogen/client';
|
||||
|
||||
import {emailValidation, passwordValidation} from '~/lib/utils';
|
||||
|
||||
import {callLoginApi} from './AccountLoginForm.client';
|
||||
|
||||
interface FormElements {
|
||||
email: HTMLInputElement;
|
||||
password: HTMLInputElement;
|
||||
}
|
||||
|
||||
export function AccountCreateForm() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [submitError, setSubmitError] = useState<null | string>(null);
|
||||
const [email, setEmail] = useState('');
|
||||
const [emailError, setEmailError] = useState<null | string>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordError, setPasswordError] = useState<null | string>(null);
|
||||
|
||||
async function onSubmit(
|
||||
event: React.FormEvent<HTMLFormElement & FormElements>,
|
||||
) {
|
||||
event.preventDefault();
|
||||
|
||||
setEmailError(null);
|
||||
setPasswordError(null);
|
||||
setSubmitError(null);
|
||||
|
||||
const newEmailError = emailValidation(event.currentTarget.email);
|
||||
if (newEmailError) {
|
||||
setEmailError(newEmailError);
|
||||
}
|
||||
|
||||
const newPasswordError = passwordValidation(event.currentTarget.password);
|
||||
if (newPasswordError) {
|
||||
setPasswordError(newPasswordError);
|
||||
}
|
||||
|
||||
if (newEmailError || newPasswordError) {
|
||||
return;
|
||||
}
|
||||
|
||||
const accountCreateResponse = await callAccountCreateApi({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (accountCreateResponse.error) {
|
||||
setSubmitError(accountCreateResponse.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// this can be avoided if customerCreate mutation returns customerAccessToken
|
||||
await callLoginApi({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
navigate('/account');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center my-24 px-4">
|
||||
<div className="max-w-md w-full">
|
||||
<h1 className="text-4xl">Create an Account.</h1>
|
||||
<form noValidate className="pt-6 pb-8 mt-4 mb-4" onSubmit={onSubmit}>
|
||||
{submitError && (
|
||||
<div className="flex items-center justify-center mb-6 bg-zinc-500">
|
||||
<p className="m-4 text-s text-contrast">{submitError}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3">
|
||||
<input
|
||||
className={`mb-1 appearance-none rounded border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline ${
|
||||
emailError ? ' border-red-500' : 'border-gray-900'
|
||||
}`}
|
||||
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
|
||||
value={email}
|
||||
onChange={(event) => {
|
||||
setEmail(event.target.value);
|
||||
}}
|
||||
/>
|
||||
{!emailError ? (
|
||||
''
|
||||
) : (
|
||||
<p className={`text-red-500 text-xs`}>{emailError} </p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<input
|
||||
className={`mb-1 appearance-none rounded border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline ${
|
||||
passwordError ? ' border-red-500' : 'border-gray-900'
|
||||
}`}
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Password"
|
||||
aria-label="Password"
|
||||
value={password}
|
||||
minLength={8}
|
||||
required
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
}}
|
||||
/>
|
||||
{!passwordError ? (
|
||||
''
|
||||
) : (
|
||||
<p className={`text-red-500 text-xs`}>{passwordError} </p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
className="bg-gray-900 text-contrast rounded py-2 px-4 focus:shadow-outline block w-full"
|
||||
type="submit"
|
||||
>
|
||||
Create Account
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center mt-4">
|
||||
<p className="align-baseline text-sm">
|
||||
Already have an account?
|
||||
<Link className="inline underline" to="/account">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function callAccountCreateApi({
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
}: {
|
||||
email: string;
|
||||
password: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
}) {
|
||||
try {
|
||||
const res = await fetch(`/account/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({email, password, firstName, lastName}),
|
||||
});
|
||||
if (res.status === 200) {
|
||||
return {};
|
||||
} else {
|
||||
return res.json();
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
error: error.toString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {Text, Button} from '~/components/elements';
|
||||
import {useRenderServerComponents} from '~/lib/utils';
|
||||
|
||||
export function AccountDeleteAddress({
|
||||
addressId,
|
||||
close,
|
||||
}: {
|
||||
addressId: string;
|
||||
close: () => void;
|
||||
}) {
|
||||
// Necessary for edits to show up on the main page
|
||||
const renderServerComponents = useRenderServerComponents();
|
||||
|
||||
async function deleteAddress(id: string) {
|
||||
const response = await callDeleteAddressApi(id);
|
||||
if (response.error) {
|
||||
alert(response.error);
|
||||
return;
|
||||
}
|
||||
renderServerComponents();
|
||||
close();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text className="mb-4" as="h3" size="lead">
|
||||
Confirm removal
|
||||
</Text>
|
||||
<Text as="p">Are you sure you wish to remove this address?</Text>
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
className="text-sm"
|
||||
onClick={() => {
|
||||
deleteAddress(addressId);
|
||||
}}
|
||||
variant="primary"
|
||||
width="full"
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
className="text-sm mt-2"
|
||||
onClick={close}
|
||||
variant="secondary"
|
||||
width="full"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export async function callDeleteAddressApi(id: string) {
|
||||
try {
|
||||
const res = await fetch(`/account/address/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
return {};
|
||||
} else {
|
||||
return res.json();
|
||||
}
|
||||
} catch (_e) {
|
||||
return {
|
||||
error: 'Error removing address. Please try again.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {Seo} from '@shopify/hydrogen';
|
||||
import {useState} from 'react';
|
||||
import {Modal} from '../index';
|
||||
import {AccountDetailsEdit} from './AccountDetailsEdit.client';
|
||||
|
||||
export function AccountDetails({
|
||||
firstName,
|
||||
lastName,
|
||||
phone,
|
||||
email,
|
||||
}: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const close = () => setIsEditing(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isEditing ? (
|
||||
<Modal close={close}>
|
||||
<Seo type="noindex" data={{title: 'Account details'}} />
|
||||
<AccountDetailsEdit
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
phone={phone}
|
||||
email={email}
|
||||
close={close}
|
||||
/>
|
||||
</Modal>
|
||||
) : null}
|
||||
<div className="grid w-full gap-4 p-4 py-6 md:gap-8 md:p-8 lg:p-12">
|
||||
<h3 className="font-bold text-lead">Account Details</h3>
|
||||
<div className="lg:p-8 p-6 border border-gray-200 rounded">
|
||||
<div className="flex">
|
||||
<h3 className="font-bold text-base flex-1">Profile & Security</h3>
|
||||
<button
|
||||
className="underline text-sm font-normal"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-primary/50">Name</div>
|
||||
<p className="mt-1">
|
||||
{firstName || lastName
|
||||
? (firstName ? firstName + ' ' : '') + lastName
|
||||
: 'Add name'}{' '}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 text-sm text-primary/50">Contact</div>
|
||||
<p className="mt-1">{phone ?? 'Add mobile'}</p>
|
||||
|
||||
<div className="mt-4 text-sm text-primary/50">Email address</div>
|
||||
<p className="mt-1">{email}</p>
|
||||
|
||||
<div className="mt-4 text-sm text-primary/50">Password</div>
|
||||
<p className="mt-1">**************</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import {useState} from 'react';
|
||||
|
||||
import {Text, Button} from '~/components';
|
||||
import {
|
||||
emailValidation,
|
||||
passwordValidation,
|
||||
useRenderServerComponents,
|
||||
} from '~/lib/utils';
|
||||
|
||||
interface FormElements {
|
||||
firstName: HTMLInputElement;
|
||||
lastName: HTMLInputElement;
|
||||
phone: HTMLInputElement;
|
||||
email: HTMLInputElement;
|
||||
currentPassword: HTMLInputElement;
|
||||
newPassword: HTMLInputElement;
|
||||
newPassword2: HTMLInputElement;
|
||||
}
|
||||
|
||||
export function AccountDetailsEdit({
|
||||
firstName: _firstName = '',
|
||||
lastName: _lastName = '',
|
||||
phone: _phone = '',
|
||||
email: _email = '',
|
||||
close,
|
||||
}: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
close: () => void;
|
||||
}) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [firstName, setFirstName] = useState(_firstName);
|
||||
const [lastName, setLastName] = useState(_lastName);
|
||||
const [phone, setPhone] = useState(_phone);
|
||||
const [email, setEmail] = useState(_email);
|
||||
const [emailError, setEmailError] = useState<null | string>(null);
|
||||
const [currentPasswordError, setCurrentPasswordError] = useState<
|
||||
null | string
|
||||
>(null);
|
||||
const [newPasswordError, setNewPasswordError] = useState<null | string>(null);
|
||||
const [newPassword2Error, setNewPassword2Error] = useState<null | string>(
|
||||
null,
|
||||
);
|
||||
const [submitError, setSubmitError] = useState<null | string>(null);
|
||||
|
||||
// Necessary for edits to show up on the main page
|
||||
const renderServerComponents = useRenderServerComponents();
|
||||
|
||||
async function onSubmit(
|
||||
event: React.FormEvent<HTMLFormElement & FormElements>,
|
||||
) {
|
||||
event.preventDefault();
|
||||
|
||||
setEmailError(null);
|
||||
setCurrentPasswordError(null);
|
||||
setNewPasswordError(null);
|
||||
setNewPassword2Error(null);
|
||||
|
||||
const emailError = emailValidation(event.currentTarget.email);
|
||||
if (emailError) {
|
||||
setEmailError(emailError);
|
||||
}
|
||||
|
||||
let currentPasswordError, newPasswordError, newPassword2Error;
|
||||
|
||||
// Only validate the password fields if the current password has a value
|
||||
if (event.currentTarget.currentPassword.value) {
|
||||
currentPasswordError = passwordValidation(
|
||||
event.currentTarget.currentPassword,
|
||||
);
|
||||
if (currentPasswordError) {
|
||||
setCurrentPasswordError(currentPasswordError);
|
||||
}
|
||||
|
||||
newPasswordError = passwordValidation(event.currentTarget.newPassword);
|
||||
if (newPasswordError) {
|
||||
setNewPasswordError(newPasswordError);
|
||||
}
|
||||
|
||||
newPassword2Error =
|
||||
event.currentTarget.newPassword.value !==
|
||||
event.currentTarget.newPassword2.value
|
||||
? 'The two passwords entered did not match'
|
||||
: null;
|
||||
if (newPassword2Error) {
|
||||
setNewPassword2Error(newPassword2Error);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
emailError ||
|
||||
currentPasswordError ||
|
||||
newPasswordError ||
|
||||
newPassword2Error
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
const accountUpdateResponse = await callAccountUpdateApi({
|
||||
email,
|
||||
newPassword: event.currentTarget.newPassword.value,
|
||||
currentPassword: event.currentTarget.currentPassword.value,
|
||||
phone,
|
||||
firstName,
|
||||
lastName,
|
||||
});
|
||||
|
||||
setSaving(false);
|
||||
|
||||
if (accountUpdateResponse.error) {
|
||||
setSubmitError(accountUpdateResponse.error);
|
||||
return;
|
||||
}
|
||||
|
||||
renderServerComponents();
|
||||
close();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text className="mt-4 mb-6" as="h3" size="lead">
|
||||
Update your profile
|
||||
</Text>
|
||||
<form noValidate onSubmit={onSubmit}>
|
||||
{submitError && (
|
||||
<div className="flex items-center justify-center mb-6 bg-red-100 rounded">
|
||||
<p className="m-4 text-sm text-red-900">{submitError}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="firstname"
|
||||
name="firstname"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
placeholder="First name"
|
||||
aria-label="First name"
|
||||
value={firstName}
|
||||
onChange={(event) => {
|
||||
setFirstName(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="lastname"
|
||||
name="lastname"
|
||||
type="text"
|
||||
autoComplete="family-name"
|
||||
placeholder="Last name"
|
||||
aria-label="Last name"
|
||||
value={lastName}
|
||||
onChange={(event) => {
|
||||
setLastName(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
placeholder="Mobile"
|
||||
aria-label="Mobile"
|
||||
value={phone}
|
||||
onChange={(event) => {
|
||||
setPhone(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline rounded ${
|
||||
emailError ? ' border-red-500' : 'border-gray-500'
|
||||
}`}
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="Email address"
|
||||
aria-label="Email address"
|
||||
value={email}
|
||||
onChange={(event) => {
|
||||
setEmail(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
className={`text-red-500 text-xs ${!emailError ? 'invisible' : ''}`}
|
||||
>
|
||||
{emailError}
|
||||
</p>
|
||||
</div>
|
||||
<Text className="mb-6 mt-6" as="h3" size="lead">
|
||||
Change your password
|
||||
</Text>
|
||||
<Password
|
||||
name="currentPassword"
|
||||
label="Current password"
|
||||
passwordError={currentPasswordError}
|
||||
/>
|
||||
<Password
|
||||
name="newPassword"
|
||||
label="New password"
|
||||
passwordError={newPasswordError}
|
||||
/>
|
||||
<Password
|
||||
name="newPassword2"
|
||||
label="Re-enter new password"
|
||||
passwordError={newPassword2Error}
|
||||
/>
|
||||
<Text
|
||||
size="fine"
|
||||
color="subtle"
|
||||
className={`mt-1 ${
|
||||
currentPasswordError || newPasswordError ? 'text-red-500' : ''
|
||||
}`}
|
||||
>
|
||||
Passwords must be at least 6 characters.
|
||||
</Text>
|
||||
{newPassword2Error ? <br /> : null}
|
||||
<Text
|
||||
size="fine"
|
||||
className={`mt-1 text-red-500 ${
|
||||
newPassword2Error ? '' : 'invisible'
|
||||
}`}
|
||||
>
|
||||
{newPassword2Error}
|
||||
</Text>
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
className="text-sm mb-2"
|
||||
variant="primary"
|
||||
width="full"
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="text-sm"
|
||||
variant="secondary"
|
||||
width="full"
|
||||
onClick={close}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Password({
|
||||
name,
|
||||
passwordError,
|
||||
label,
|
||||
}: {
|
||||
name: string;
|
||||
passwordError: string | null;
|
||||
label: string;
|
||||
}) {
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline rounded ${
|
||||
passwordError ? ' border-red-500' : 'border-gray-500'
|
||||
}`}
|
||||
id={name}
|
||||
name={name}
|
||||
type="password"
|
||||
autoComplete={
|
||||
name === 'currentPassword' ? 'current-password' : undefined
|
||||
}
|
||||
placeholder={label}
|
||||
aria-label={label}
|
||||
value={password}
|
||||
minLength={8}
|
||||
required
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function callAccountUpdateApi({
|
||||
email,
|
||||
phone,
|
||||
firstName,
|
||||
lastName,
|
||||
currentPassword,
|
||||
newPassword,
|
||||
}: {
|
||||
email: string;
|
||||
phone: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
}) {
|
||||
try {
|
||||
const res = await fetch(`/account`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
phone,
|
||||
firstName,
|
||||
lastName,
|
||||
currentPassword,
|
||||
newPassword,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
return {};
|
||||
} else {
|
||||
return res.json();
|
||||
}
|
||||
} catch (_e) {
|
||||
return {
|
||||
error: 'Error saving account. Please try again.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import {useState} from 'react';
|
||||
import {useNavigate, Link} from '@shopify/hydrogen/client';
|
||||
|
||||
interface FormElements {
|
||||
email: HTMLInputElement;
|
||||
password: HTMLInputElement;
|
||||
}
|
||||
|
||||
export function AccountLoginForm({shopName}: {shopName: string}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [hasSubmitError, setHasSubmitError] = useState(false);
|
||||
const [showEmailField, setShowEmailField] = useState(true);
|
||||
const [email, setEmail] = useState('');
|
||||
const [emailError, setEmailError] = useState<null | string>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordError, setPasswordError] = useState<null | string>(null);
|
||||
|
||||
function onSubmit(event: React.FormEvent<HTMLFormElement & FormElements>) {
|
||||
event.preventDefault();
|
||||
|
||||
setEmailError(null);
|
||||
setHasSubmitError(false);
|
||||
setPasswordError(null);
|
||||
|
||||
if (showEmailField) {
|
||||
checkEmail(event);
|
||||
} else {
|
||||
checkPassword(event);
|
||||
}
|
||||
}
|
||||
|
||||
function checkEmail(event: React.FormEvent<HTMLFormElement & FormElements>) {
|
||||
if (event.currentTarget.email.validity.valid) {
|
||||
setShowEmailField(false);
|
||||
} else {
|
||||
setEmailError('Please enter a valid email');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkPassword(
|
||||
event: React.FormEvent<HTMLFormElement & FormElements>,
|
||||
) {
|
||||
const validity = event.currentTarget.password.validity;
|
||||
if (validity.valid) {
|
||||
const response = await callLoginApi({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
setHasSubmitError(true);
|
||||
resetForm();
|
||||
} else {
|
||||
navigate('/account');
|
||||
}
|
||||
} else {
|
||||
setPasswordError(
|
||||
validity.valueMissing
|
||||
? 'Please enter a password'
|
||||
: 'Passwords must be at least 6 characters',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setShowEmailField(true);
|
||||
setEmail('');
|
||||
setEmailError(null);
|
||||
setPassword('');
|
||||
setPasswordError(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center my-24 px-4">
|
||||
<div className="max-w-md w-full">
|
||||
<h1 className="text-4xl">Sign in.</h1>
|
||||
<form noValidate className="pt-6 pb-8 mt-4 mb-4" onSubmit={onSubmit}>
|
||||
{hasSubmitError && (
|
||||
<div className="flex items-center justify-center mb-6 bg-zinc-500">
|
||||
<p className="m-4 text-s text-contrast">
|
||||
Sorry we did not recognize either your email or password. Please
|
||||
try to sign in again or create a new account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{showEmailField && (
|
||||
<EmailField
|
||||
shopName={shopName}
|
||||
email={email}
|
||||
setEmail={setEmail}
|
||||
emailError={emailError}
|
||||
/>
|
||||
)}
|
||||
{!showEmailField && (
|
||||
<ValidEmail email={email} resetForm={resetForm} />
|
||||
)}
|
||||
{!showEmailField && (
|
||||
<PasswordField
|
||||
password={password}
|
||||
setPassword={setPassword}
|
||||
passwordError={passwordError}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function callLoginApi({
|
||||
email,
|
||||
password,
|
||||
}: {
|
||||
email: string;
|
||||
password: string;
|
||||
}) {
|
||||
try {
|
||||
const res = await fetch(`/account/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({email, password}),
|
||||
});
|
||||
if (res.ok) {
|
||||
return {};
|
||||
} else {
|
||||
return res.json();
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
error: error.toString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function EmailField({
|
||||
email,
|
||||
setEmail,
|
||||
emailError,
|
||||
shopName,
|
||||
}: {
|
||||
email: string;
|
||||
setEmail: (email: string) => void;
|
||||
emailError: null | string;
|
||||
shopName: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<input
|
||||
className={`mb-1 appearance-none rounded border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline ${
|
||||
emailError ? ' border-red-500' : 'border-gray-900'
|
||||
}`}
|
||||
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
|
||||
value={email}
|
||||
onChange={(event) => {
|
||||
setEmail(event.target.value);
|
||||
}}
|
||||
/>
|
||||
{!emailError ? (
|
||||
''
|
||||
) : (
|
||||
<p className={`text-red-500 text-xs`}>{emailError} </p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
className="bg-gray-900 rounded text-contrast py-2 px-4 focus:shadow-outline block w-full"
|
||||
type="submit"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center mt-8 border-t border-gray-300">
|
||||
<p className="align-baseline text-sm mt-6">
|
||||
New to {shopName}?
|
||||
<Link className="inline underline" to="/account/register">
|
||||
Create an account
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ValidEmail({
|
||||
email,
|
||||
resetForm,
|
||||
}: {
|
||||
email: string;
|
||||
resetForm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<p>{email}</p>
|
||||
<input
|
||||
className="hidden"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={email}
|
||||
readOnly
|
||||
></input>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
className="inline-block align-baseline text-sm underline"
|
||||
type="button"
|
||||
onClick={resetForm}
|
||||
>
|
||||
Change email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordField({
|
||||
password,
|
||||
setPassword,
|
||||
passwordError,
|
||||
}: {
|
||||
password: string;
|
||||
setPassword: (password: string) => void;
|
||||
passwordError: null | string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<input
|
||||
className={`mb-1 appearance-none rounded border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline ${
|
||||
passwordError ? ' border-red-500' : 'border-gray-900'
|
||||
}`}
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Password"
|
||||
aria-label="Password"
|
||||
value={password}
|
||||
minLength={8}
|
||||
required
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
}}
|
||||
/>
|
||||
{!passwordError ? (
|
||||
''
|
||||
) : (
|
||||
<p className={`text-red-500 text-xs`}> {passwordError} </p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
className="bg-gray-900 text-contrast rounded py-2 px-4 focus:shadow-outline block w-full"
|
||||
type="submit"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="flex-1"></div>
|
||||
<Link
|
||||
className="inline-block align-baseline text-sm text-primary/50"
|
||||
to="/account/recover"
|
||||
>
|
||||
Forgot password
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type {Order} from '@shopify/hydrogen/storefront-api-types';
|
||||
import {Button, Text, OrderCard} from '~/components';
|
||||
|
||||
export function AccountOrderHistory({orders}: {orders: Order[]}) {
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<div className="grid w-full gap-4 p-4 py-6 md:gap-8 md:p-8 lg:p-12">
|
||||
<h2 className="font-bold text-lead">Order History</h2>
|
||||
{orders?.length ? <Orders orders={orders} /> : <EmptyOrders />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyOrders() {
|
||||
return (
|
||||
<div>
|
||||
<Text className="mb-1" size="fine" width="narrow" as="p">
|
||||
You haven't placed any orders yet.
|
||||
</Text>
|
||||
<div className="w-48">
|
||||
<Button className="text-sm mt-2 w-full" variant="secondary" to={'/'}>
|
||||
Start Shopping
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Orders({orders}: {orders: Order[]}) {
|
||||
return (
|
||||
<ul className="grid-flow-row grid gap-2 gap-y-6 md:gap-4 lg:gap-6 grid-cols-1 false sm:grid-cols-3">
|
||||
{orders.map((order) => (
|
||||
<OrderCard order={order} key={order.id} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import {useState} from 'react';
|
||||
import {useNavigate} from '@shopify/hydrogen/client';
|
||||
|
||||
interface FormElements {
|
||||
password: HTMLInputElement;
|
||||
passwordConfirm: HTMLInputElement;
|
||||
}
|
||||
|
||||
export function AccountPasswordResetForm({
|
||||
id,
|
||||
resetToken,
|
||||
}: {
|
||||
id: string;
|
||||
resetToken: string;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||
const [passwordConfirmError, setPasswordConfirmError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
function passwordValidation(form: HTMLFormElement & FormElements) {
|
||||
setPasswordError(null);
|
||||
setPasswordConfirmError(null);
|
||||
|
||||
let hasError = false;
|
||||
|
||||
if (!form.password.validity.valid) {
|
||||
hasError = true;
|
||||
setPasswordError(
|
||||
form.password.validity.valueMissing
|
||||
? 'Please enter a password'
|
||||
: 'Passwords must be at least 6 characters',
|
||||
);
|
||||
}
|
||||
|
||||
if (!form.passwordConfirm.validity.valid) {
|
||||
hasError = true;
|
||||
setPasswordConfirmError(
|
||||
form.password.validity.valueMissing
|
||||
? 'Please re-enter a password'
|
||||
: 'Passwords must be at least 6 characters',
|
||||
);
|
||||
}
|
||||
|
||||
if (password !== passwordConfirm) {
|
||||
hasError = true;
|
||||
setPasswordConfirmError('The two password entered did not match.');
|
||||
}
|
||||
|
||||
return hasError;
|
||||
}
|
||||
|
||||
async function onSubmit(
|
||||
event: React.FormEvent<HTMLFormElement & FormElements>,
|
||||
) {
|
||||
event.preventDefault();
|
||||
|
||||
if (passwordValidation(event.currentTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await callPasswordResetApi({
|
||||
id,
|
||||
resetToken,
|
||||
password,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
setSubmitError(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/account');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center my-24 px-4">
|
||||
<div className="max-w-md w-full">
|
||||
<h1 className="text-4xl">Reset Password.</h1>
|
||||
<p className="mt-4">Enter a new password for your account.</p>
|
||||
<form noValidate className="pt-6 pb-8 mt-4 mb-4" onSubmit={onSubmit}>
|
||||
{submitError && (
|
||||
<div className="flex items-center justify-center mb-6 bg-zinc-500">
|
||||
<p className="m-4 text-s text-contrast">{submitError}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3">
|
||||
<input
|
||||
className={`mb-1 appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline ${
|
||||
passwordError ? ' border-red-500' : 'border-gray-900'
|
||||
}`}
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Password"
|
||||
aria-label="Password"
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
value={password}
|
||||
minLength={8}
|
||||
required
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
className={`text-red-500 text-xs ${
|
||||
!passwordError ? 'invisible' : ''
|
||||
}`}
|
||||
>
|
||||
{passwordError}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<input
|
||||
className={`mb-1 appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline ${
|
||||
passwordConfirmError ? ' border-red-500' : 'border-gray-900'
|
||||
}`}
|
||||
id="passwordConfirm"
|
||||
name="passwordConfirm"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Re-enter password"
|
||||
aria-label="Re-enter password"
|
||||
value={passwordConfirm}
|
||||
required
|
||||
minLength={8}
|
||||
onChange={(event) => {
|
||||
setPasswordConfirm(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
className={`text-red-500 text-xs ${
|
||||
!passwordConfirmError ? 'invisible' : ''
|
||||
}`}
|
||||
>
|
||||
{passwordConfirmError}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
className="bg-gray-900 text-contrast rounded py-2 px-4 focus:shadow-outline block w-full"
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function callPasswordResetApi({
|
||||
id,
|
||||
resetToken,
|
||||
password,
|
||||
}: {
|
||||
id: string;
|
||||
resetToken: string;
|
||||
password: string;
|
||||
}) {
|
||||
try {
|
||||
const res = await fetch(`/account/reset`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({id, resetToken, password}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
return {};
|
||||
} else {
|
||||
return res.json();
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
error: error.toString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import {useState} from 'react';
|
||||
|
||||
import {emailValidation} from '~/lib/utils';
|
||||
|
||||
interface FormElements {
|
||||
email: HTMLInputElement;
|
||||
}
|
||||
|
||||
export function AccountRecoverForm() {
|
||||
const [submitSuccess, setSubmitSuccess] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState('');
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
|
||||
async function onSubmit(
|
||||
event: React.FormEvent<HTMLFormElement & FormElements>,
|
||||
) {
|
||||
event.preventDefault();
|
||||
|
||||
setEmailError(null);
|
||||
setSubmitError(null);
|
||||
|
||||
const newEmailError = emailValidation(event.currentTarget.email);
|
||||
|
||||
if (newEmailError) {
|
||||
setEmailError(newEmailError);
|
||||
return;
|
||||
}
|
||||
|
||||
await callAccountRecoverApi({
|
||||
email,
|
||||
});
|
||||
|
||||
setEmail('');
|
||||
setSubmitSuccess(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center my-24 px-4">
|
||||
<div className="max-w-md w-full">
|
||||
{submitSuccess ? (
|
||||
<>
|
||||
<h1 className="text-4xl">Request Sent.</h1>
|
||||
<p className="mt-4">
|
||||
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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-4xl">Forgot Password.</h1>
|
||||
<p className="mt-4">
|
||||
Enter the email address associated with your account to receive a
|
||||
link to reset your password.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<form noValidate className="pt-6 pb-8 mt-4 mb-4" onSubmit={onSubmit}>
|
||||
{submitError && (
|
||||
<div className="flex items-center justify-center mb-6 bg-zinc-500">
|
||||
<p className="m-4 text-s text-contrast">{submitError}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3">
|
||||
<input
|
||||
className={`mb-1 rounded appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline ${
|
||||
emailError ? ' border-red-500' : 'border-gray-900'
|
||||
}`}
|
||||
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
|
||||
value={email}
|
||||
onChange={(event) => {
|
||||
setEmail(event.target.value);
|
||||
}}
|
||||
/>
|
||||
{!emailError ? (
|
||||
''
|
||||
) : (
|
||||
<p className={`text-red-500 text-xs`}>{emailError} </p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
className="bg-gray-900 text-contrast rounded py-2 px-4 focus:shadow-outline block w-full"
|
||||
type="submit"
|
||||
>
|
||||
Request Reset Link
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function callAccountRecoverApi({
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
}: {
|
||||
email: string;
|
||||
password?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
}) {
|
||||
try {
|
||||
const res = await fetch(`/account/recover`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({email, password, firstName, lastName}),
|
||||
});
|
||||
if (res.status === 200) {
|
||||
return {};
|
||||
} else {
|
||||
return res.json();
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
error: error.toString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
11
examples/hydrogen/src/components/account/index.ts
Normal file
11
examples/hydrogen/src/components/account/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export {AccountActivateForm} from './AccountActivateForm.client';
|
||||
export {AccountAddressBook} from './AccountAddressBook.client';
|
||||
export {AccountAddressEdit} from './AccountAddressEdit.client';
|
||||
export {AccountCreateForm} from './AccountCreateForm.client';
|
||||
export {AccountDeleteAddress} from './AccountDeleteAddress.client';
|
||||
export {AccountDetails} from './AccountDetails.client';
|
||||
export {AccountDetailsEdit} from './AccountDetailsEdit.client';
|
||||
export {AccountLoginForm} from './AccountLoginForm.client';
|
||||
export {AccountOrderHistory} from './AccountOrderHistory.client';
|
||||
export {AccountPasswordResetForm} from './AccountPasswordResetForm.client';
|
||||
export {AccountRecoverForm} from './AccountRecoverForm.client';
|
||||
38
examples/hydrogen/src/components/cards/ArticleCard.tsx
Normal file
38
examples/hydrogen/src/components/cards/ArticleCard.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import {Image, Link} from '@shopify/hydrogen';
|
||||
import type {Article} from '@shopify/hydrogen/storefront-api-types';
|
||||
|
||||
export function ArticleCard({
|
||||
blogHandle,
|
||||
article,
|
||||
loading,
|
||||
}: {
|
||||
blogHandle: string;
|
||||
article: Article;
|
||||
loading?: HTMLImageElement['loading'];
|
||||
}) {
|
||||
return (
|
||||
<li key={article.id}>
|
||||
<Link to={`/${blogHandle}/${article.handle}`}>
|
||||
{article.image && (
|
||||
<div className="card-image aspect-[3/2]">
|
||||
<Image
|
||||
alt={article.image.altText || article.title}
|
||||
className="object-cover w-full"
|
||||
data={article.image}
|
||||
height={400}
|
||||
loading={loading}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
width={600}
|
||||
loaderOptions={{
|
||||
scale: 2,
|
||||
crop: 'center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="mt-4 font-medium">{article.title}</h2>
|
||||
<span className="block mt-1">{article.publishedAt}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {Image, Link} from '@shopify/hydrogen';
|
||||
import type {Collection} from '@shopify/hydrogen/storefront-api-types';
|
||||
|
||||
import {Heading} from '~/components';
|
||||
|
||||
export function CollectionCard({
|
||||
collection,
|
||||
loading,
|
||||
}: {
|
||||
collection: Collection;
|
||||
loading?: HTMLImageElement['loading'];
|
||||
}) {
|
||||
return (
|
||||
<Link to={`/collections/${collection.handle}`} className="grid gap-4">
|
||||
<div className="card-image bg-primary/5 aspect-[3/2]">
|
||||
{collection?.image && (
|
||||
<Image
|
||||
alt={`Image of ${collection.title}`}
|
||||
data={collection.image}
|
||||
height={400}
|
||||
sizes="(max-width: 32em) 100vw, 33vw"
|
||||
width={600}
|
||||
widths={[400, 500, 600, 700, 800, 900]}
|
||||
loaderOptions={{
|
||||
scale: 2,
|
||||
crop: 'center',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Heading as="h3" size="copy">
|
||||
{collection.title}
|
||||
</Heading>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
87
examples/hydrogen/src/components/cards/OrderCard.client.tsx
Normal file
87
examples/hydrogen/src/components/cards/OrderCard.client.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import {Image, Link, flattenConnection} from '@shopify/hydrogen';
|
||||
import type {
|
||||
Order,
|
||||
OrderLineItem,
|
||||
} from '@shopify/hydrogen/storefront-api-types';
|
||||
|
||||
import {Heading, Text} from '~/components';
|
||||
import {statusMessage} from '~/lib/utils';
|
||||
|
||||
export function OrderCard({order}: {order: Order}) {
|
||||
if (!order?.id) return null;
|
||||
const legacyOrderId = order!.id!.split('/').pop()!.split('?')[0];
|
||||
const lineItems = flattenConnection<OrderLineItem>(order?.lineItems);
|
||||
|
||||
return (
|
||||
<li className="grid text-center border rounded">
|
||||
<Link
|
||||
className="grid items-center gap-4 p-4 md:gap-6 md:p-6 md:grid-cols-2"
|
||||
to={`/account/orders/${legacyOrderId}`}
|
||||
>
|
||||
{lineItems[0].variant?.image && (
|
||||
<div className="card-image aspect-square bg-primary/5">
|
||||
<Image
|
||||
width={168}
|
||||
height={168}
|
||||
widths={[168]}
|
||||
className="w-full fadeIn cover"
|
||||
alt={lineItems[0].variant?.image?.altText ?? 'Order image'}
|
||||
// @ts-expect-error Stock line item variant image type has `url` as optional
|
||||
data={lineItems[0].variant?.image}
|
||||
loaderOptions={{scale: 2, crop: 'center'}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`flex-col justify-center text-left ${
|
||||
!lineItems[0].variant?.image && 'md:col-span-2'
|
||||
}`}
|
||||
>
|
||||
<Heading as="h3" format size="copy">
|
||||
{lineItems.length > 1
|
||||
? `${lineItems[0].title} +${lineItems.length - 1} more`
|
||||
: lineItems[0].title}
|
||||
</Heading>
|
||||
<dl className="grid grid-gap-1">
|
||||
<dt className="sr-only">Order ID</dt>
|
||||
<dd>
|
||||
<Text size="fine" color="subtle">
|
||||
Order No. {order.orderNumber}
|
||||
</Text>
|
||||
</dd>
|
||||
<dt className="sr-only">Order Date</dt>
|
||||
<dd>
|
||||
<Text size="fine" color="subtle">
|
||||
{new Date(order.processedAt).toDateString()}
|
||||
</Text>
|
||||
</dd>
|
||||
<dt className="sr-only">Fulfillment Status</dt>
|
||||
<dd className="mt-2">
|
||||
<span
|
||||
className={`px-3 py-1 text-xs font-medium rounded-full ${
|
||||
order.fulfillmentStatus === 'FULFILLED'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-primary/5 text-primary/50'
|
||||
}`}
|
||||
>
|
||||
<Text size="fine">
|
||||
{statusMessage(order.fulfillmentStatus)}
|
||||
</Text>
|
||||
</span>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="self-end border-t">
|
||||
<Link
|
||||
className="block w-full p-2 text-center"
|
||||
to={`/account/orders/${legacyOrderId}`}
|
||||
>
|
||||
<Text color="subtle" className="ml-3">
|
||||
View Details
|
||||
</Text>
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
126
examples/hydrogen/src/components/cards/ProductCard.client.tsx
Normal file
126
examples/hydrogen/src/components/cards/ProductCard.client.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
flattenConnection,
|
||||
Image,
|
||||
Link,
|
||||
Money,
|
||||
useMoney,
|
||||
} from '@shopify/hydrogen';
|
||||
|
||||
import {Text} from '~/components';
|
||||
import {isDiscounted, isNewArrival} from '~/lib/utils';
|
||||
import {getProductPlaceholder} from '~/lib/placeholders';
|
||||
import type {
|
||||
MoneyV2,
|
||||
Product,
|
||||
ProductVariant,
|
||||
ProductVariantConnection,
|
||||
} from '@shopify/hydrogen/storefront-api-types';
|
||||
|
||||
export function ProductCard({
|
||||
product,
|
||||
label,
|
||||
className,
|
||||
loading,
|
||||
onClick,
|
||||
}: {
|
||||
product: Product;
|
||||
label?: string;
|
||||
className?: string;
|
||||
loading?: HTMLImageElement['loading'];
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
let cardLabel;
|
||||
|
||||
const cardData = product?.variants ? product : getProductPlaceholder();
|
||||
|
||||
const {
|
||||
image,
|
||||
priceV2: price,
|
||||
compareAtPriceV2: compareAtPrice,
|
||||
} = flattenConnection<ProductVariant>(
|
||||
cardData?.variants as ProductVariantConnection,
|
||||
)[0] || {};
|
||||
|
||||
if (label) {
|
||||
cardLabel = label;
|
||||
} else if (isDiscounted(price as MoneyV2, compareAtPrice as MoneyV2)) {
|
||||
cardLabel = 'Sale';
|
||||
} else if (isNewArrival(product.publishedAt)) {
|
||||
cardLabel = 'New';
|
||||
}
|
||||
|
||||
const styles = clsx('grid gap-6', className);
|
||||
|
||||
return (
|
||||
<Link onClick={onClick} to={`/products/${product.handle}`}>
|
||||
<div className={styles}>
|
||||
<div className="card-image aspect-[4/5] bg-primary/5">
|
||||
<Text
|
||||
as="label"
|
||||
size="fine"
|
||||
className="absolute top-0 right-0 m-4 text-right text-notice"
|
||||
>
|
||||
{cardLabel}
|
||||
</Text>
|
||||
{image && (
|
||||
<Image
|
||||
className="aspect-[4/5] w-full object-cover fadeIn"
|
||||
widths={[320]}
|
||||
sizes="320px"
|
||||
loaderOptions={{
|
||||
crop: 'center',
|
||||
scale: 2,
|
||||
width: 320,
|
||||
height: 400,
|
||||
}}
|
||||
// @ts-ignore Stock type has `src` as optional
|
||||
data={image}
|
||||
alt={image.altText || `Picture of ${product.title}`}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Text
|
||||
className="w-full overflow-hidden whitespace-nowrap text-ellipsis "
|
||||
as="h3"
|
||||
>
|
||||
{product.title}
|
||||
</Text>
|
||||
<div className="flex gap-4">
|
||||
<Text className="flex gap-4">
|
||||
<Money withoutTrailingZeros data={price!} />
|
||||
{isDiscounted(price as MoneyV2, compareAtPrice as MoneyV2) && (
|
||||
<CompareAtPrice
|
||||
className={'opacity-50'}
|
||||
data={compareAtPrice as MoneyV2}
|
||||
/>
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function CompareAtPrice({
|
||||
data,
|
||||
className,
|
||||
}: {
|
||||
data: MoneyV2;
|
||||
className?: string;
|
||||
}) {
|
||||
const {currencyNarrowSymbol, withoutTrailingZerosAndCurrency} =
|
||||
useMoney(data);
|
||||
|
||||
const styles = clsx('strike', className);
|
||||
|
||||
return (
|
||||
<span className={styles}>
|
||||
{currencyNarrowSymbol}
|
||||
{withoutTrailingZerosAndCurrency}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
1
examples/hydrogen/src/components/cards/index.server.ts
Normal file
1
examples/hydrogen/src/components/cards/index.server.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {CollectionCard} from './CollectionCard.server';
|
||||
3
examples/hydrogen/src/components/cards/index.ts
Normal file
3
examples/hydrogen/src/components/cards/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export {ArticleCard} from './ArticleCard';
|
||||
export {OrderCard} from './OrderCard.client';
|
||||
export {ProductCard} from './ProductCard.client';
|
||||
100
examples/hydrogen/src/components/cart/CartDetails.client.tsx
Normal file
100
examples/hydrogen/src/components/cart/CartDetails.client.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import {useRef} from 'react';
|
||||
import {useScroll} from 'react-use';
|
||||
import {
|
||||
useCart,
|
||||
CartLineProvider,
|
||||
CartShopPayButton,
|
||||
Money,
|
||||
} from '@shopify/hydrogen';
|
||||
|
||||
import {Button, Text, CartLineItem, CartEmpty} from '~/components';
|
||||
|
||||
export function CartDetails({
|
||||
layout,
|
||||
onClose,
|
||||
}: {
|
||||
layout: 'drawer' | 'page';
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const {lines} = useCart();
|
||||
const scrollRef = useRef(null);
|
||||
const {y} = useScroll(scrollRef);
|
||||
|
||||
if (lines.length === 0) {
|
||||
return <CartEmpty onClose={onClose} layout={layout} />;
|
||||
}
|
||||
|
||||
const container = {
|
||||
drawer: 'grid grid-cols-1 h-screen-no-nav grid-rows-[1fr_auto]',
|
||||
page: 'pb-12 grid md:grid-cols-2 md:items-start gap-8 md:gap-8 lg:gap-12',
|
||||
};
|
||||
|
||||
const content = {
|
||||
drawer: 'px-6 pb-6 sm-max:pt-2 overflow-auto transition md:px-12',
|
||||
page: 'flex-grow md:translate-y-4',
|
||||
};
|
||||
|
||||
const summary = {
|
||||
drawer: 'grid gap-6 p-6 border-t md:px-12',
|
||||
page: 'sticky top-nav grid gap-6 p-4 md:px-6 md:translate-y-4 bg-primary/5 rounded w-full',
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={container[layout]}>
|
||||
<section
|
||||
ref={scrollRef}
|
||||
aria-labelledby="cart-contents"
|
||||
className={`${content[layout]} ${y > 0 ? 'border-t' : ''}`}
|
||||
>
|
||||
<ul className="grid gap-6 md:gap-10">
|
||||
{lines.map((line) => {
|
||||
return (
|
||||
<CartLineProvider key={line.id} line={line}>
|
||||
<CartLineItem />
|
||||
</CartLineProvider>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
<section aria-labelledby="summary-heading" className={summary[layout]}>
|
||||
<h2 id="summary-heading" className="sr-only">
|
||||
Order summary
|
||||
</h2>
|
||||
<OrderSummary />
|
||||
<CartCheckoutActions />
|
||||
</section>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function CartCheckoutActions() {
|
||||
const {checkoutUrl} = useCart();
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-4">
|
||||
<Button to={checkoutUrl}>Continue to Checkout</Button>
|
||||
<CartShopPayButton />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderSummary() {
|
||||
const {cost} = useCart();
|
||||
return (
|
||||
<>
|
||||
<dl className="grid">
|
||||
<div className="flex items-center justify-between font-medium">
|
||||
<Text as="dt">Subtotal</Text>
|
||||
<Text as="dd">
|
||||
{cost?.subtotalAmount?.amount ? (
|
||||
<Money data={cost?.subtotalAmount} />
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
</dl>
|
||||
</>
|
||||
);
|
||||
}
|
||||
85
examples/hydrogen/src/components/cart/CartEmpty.client.tsx
Normal file
85
examples/hydrogen/src/components/cart/CartEmpty.client.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import {useRef} from 'react';
|
||||
import {useScroll} from 'react-use';
|
||||
import {fetchSync} from '@shopify/hydrogen';
|
||||
import {Button, Text, ProductCard, Heading, Skeleton} from '~/components';
|
||||
import type {Product} from '@shopify/hydrogen/storefront-api-types';
|
||||
import {Suspense} from 'react';
|
||||
|
||||
export function CartEmpty({
|
||||
onClose,
|
||||
layout = 'drawer',
|
||||
}: {
|
||||
onClose?: () => void;
|
||||
layout?: 'page' | 'drawer';
|
||||
}) {
|
||||
const scrollRef = useRef(null);
|
||||
const {y} = useScroll(scrollRef);
|
||||
|
||||
const container = {
|
||||
drawer: `grid content-start gap-4 px-6 pb-8 transition overflow-y-scroll md:gap-12 md:px-12 h-screen-no-nav md:pb-12 ${
|
||||
y > 0 ? 'border-t' : ''
|
||||
}`,
|
||||
page: `grid pb-12 w-full md:items-start gap-4 md:gap-8 lg:gap-12`,
|
||||
};
|
||||
|
||||
const topProductsContainer = {
|
||||
drawer: '',
|
||||
page: 'md:grid-cols-4 sm:grid-col-4',
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className={container[layout]}>
|
||||
<section className="grid gap-6">
|
||||
<Text format>
|
||||
Looks like you haven’t added anything yet, let’s get you
|
||||
started!
|
||||
</Text>
|
||||
<div>
|
||||
<Button onClick={onClose}>Continue shopping</Button>
|
||||
</div>
|
||||
</section>
|
||||
<section className="grid gap-8 pt-4">
|
||||
<Heading format size="copy">
|
||||
Shop Best Sellers
|
||||
</Heading>
|
||||
<div
|
||||
className={`grid grid-cols-2 gap-x-6 gap-y-8 ${topProductsContainer[layout]}`}
|
||||
>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<TopProducts onClose={onClose} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopProducts({onClose}: {onClose?: () => void}) {
|
||||
const products: Product[] = fetchSync('/api/bestSellers').json();
|
||||
|
||||
if (products.length === 0) {
|
||||
return <Text format>No products found.</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{products.map((product) => (
|
||||
<ProductCard product={product} key={product.id} onClick={onClose} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<>
|
||||
{[...new Array(4)].map((_, i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div key={i} className="grid gap-2">
|
||||
<Skeleton className="aspect-[3/4]" />
|
||||
<Skeleton className="w-32 h-4" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
103
examples/hydrogen/src/components/cart/CartLineItem.client.tsx
Normal file
103
examples/hydrogen/src/components/cart/CartLineItem.client.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
useCart,
|
||||
useCartLine,
|
||||
CartLineQuantityAdjustButton,
|
||||
CartLinePrice,
|
||||
CartLineQuantity,
|
||||
Image,
|
||||
Link,
|
||||
} from '@shopify/hydrogen';
|
||||
import type {Image as ImageType} from '@shopify/hydrogen/storefront-api-types';
|
||||
|
||||
import {Heading, IconRemove, Text} from '~/components';
|
||||
|
||||
export function CartLineItem() {
|
||||
const {linesRemove} = useCart();
|
||||
const {id: lineId, quantity, merchandise} = useCartLine();
|
||||
|
||||
return (
|
||||
<li key={lineId} className="flex gap-4">
|
||||
<div className="flex-shrink">
|
||||
<Image
|
||||
width={112}
|
||||
height={112}
|
||||
widths={[112]}
|
||||
data={merchandise.image as ImageType}
|
||||
loaderOptions={{
|
||||
scale: 2,
|
||||
crop: 'center',
|
||||
}}
|
||||
className="object-cover object-center w-24 h-24 border rounded md:w-28 md:h-28"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between flex-grow">
|
||||
<div className="grid gap-2">
|
||||
<Heading as="h3" size="copy">
|
||||
<Link to={`/products/${merchandise.product.handle}`}>
|
||||
{merchandise.product.title}
|
||||
</Link>
|
||||
</Heading>
|
||||
|
||||
<div className="grid pb-2">
|
||||
{(merchandise?.selectedOptions || []).map((option) => (
|
||||
<Text color="subtle" key={option.name}>
|
||||
{option.name}: {option.value}
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex justify-start text-copy">
|
||||
<CartLineQuantityAdjust lineId={lineId} quantity={quantity} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => linesRemove([lineId])}
|
||||
className="flex items-center justify-center w-10 h-10 border rounded"
|
||||
>
|
||||
<span className="sr-only">Remove</span>
|
||||
<IconRemove aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Text>
|
||||
<CartLinePrice as="span" />
|
||||
</Text>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function CartLineQuantityAdjust({
|
||||
lineId,
|
||||
quantity,
|
||||
}: {
|
||||
lineId: string;
|
||||
quantity: number;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<label htmlFor={`quantity-${lineId}`} className="sr-only">
|
||||
Quantity, {quantity}
|
||||
</label>
|
||||
<div className="flex items-center border rounded">
|
||||
<CartLineQuantityAdjustButton
|
||||
adjust="decrease"
|
||||
aria-label="Decrease quantity"
|
||||
className="w-10 h-10 transition text-primary/50 hover:text-primary disabled:cursor-wait"
|
||||
>
|
||||
−
|
||||
</CartLineQuantityAdjustButton>
|
||||
<CartLineQuantity as="div" className="px-2 text-center" />
|
||||
<CartLineQuantityAdjustButton
|
||||
adjust="increase"
|
||||
aria-label="Increase quantity"
|
||||
className="w-10 h-10 transition text-primary/50 hover:text-primary disabled:cursor-wait"
|
||||
>
|
||||
+
|
||||
</CartLineQuantityAdjustButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
3
examples/hydrogen/src/components/cart/index.ts
Normal file
3
examples/hydrogen/src/components/cart/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export {CartDetails} from './CartDetails.client';
|
||||
export {CartEmpty} from './CartEmpty.client';
|
||||
export {CartLineItem} from './CartLineItem.client';
|
||||
42
examples/hydrogen/src/components/elements/Button.tsx
Normal file
42
examples/hydrogen/src/components/elements/Button.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import clsx from 'clsx';
|
||||
import {Link} from '@shopify/hydrogen';
|
||||
|
||||
import {missingClass} from '~/lib/utils';
|
||||
|
||||
export function Button({
|
||||
as = 'button',
|
||||
className = '',
|
||||
variant = 'primary',
|
||||
width = 'auto',
|
||||
...props
|
||||
}: {
|
||||
as?: React.ElementType;
|
||||
className?: string;
|
||||
variant?: 'primary' | 'secondary' | 'inline';
|
||||
width?: 'auto' | 'full';
|
||||
[key: string]: any;
|
||||
}) {
|
||||
const Component = props?.to ? Link : as;
|
||||
|
||||
const baseButtonClasses =
|
||||
'inline-block rounded font-medium text-center py-3 px-6';
|
||||
|
||||
const variants = {
|
||||
primary: `${baseButtonClasses} bg-primary text-contrast`,
|
||||
secondary: `${baseButtonClasses} border border-primary/10 bg-contrast text-primary`,
|
||||
inline: 'border-b border-primary/10 leading-none pb-1',
|
||||
};
|
||||
|
||||
const widths = {
|
||||
auto: 'w-auto',
|
||||
full: 'w-full',
|
||||
};
|
||||
|
||||
const styles = clsx(
|
||||
missingClass(className, 'bg-') && variants[variant],
|
||||
missingClass(className, 'w-') && widths[width],
|
||||
className,
|
||||
);
|
||||
|
||||
return <Component className={styles} {...props} />;
|
||||
}
|
||||
44
examples/hydrogen/src/components/elements/Grid.tsx
Normal file
44
examples/hydrogen/src/components/elements/Grid.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
export function Grid({
|
||||
as: Component = 'div',
|
||||
className,
|
||||
flow = 'row',
|
||||
gap = 'default',
|
||||
items = 4,
|
||||
layout = 'default',
|
||||
...props
|
||||
}: {
|
||||
as?: React.ElementType;
|
||||
className?: string;
|
||||
flow?: 'row' | 'col';
|
||||
gap?: 'default' | 'blog';
|
||||
items?: number;
|
||||
layout?: 'default' | 'products' | 'auto' | 'blog';
|
||||
[key: string]: any;
|
||||
}) {
|
||||
const layouts = {
|
||||
default: `grid-cols-1 ${items === 2 && 'md:grid-cols-2'} ${
|
||||
items === 3 && 'sm:grid-cols-3'
|
||||
} ${items > 3 && 'md:grid-cols-3'} ${items >= 4 && 'lg:grid-cols-4'}`,
|
||||
products: `grid-cols-2 ${items >= 3 && 'md:grid-cols-3'} ${
|
||||
items >= 4 && 'lg:grid-cols-4'
|
||||
}`,
|
||||
auto: 'auto-cols-auto',
|
||||
blog: `grid-cols-2 pt-24`,
|
||||
};
|
||||
|
||||
const gaps = {
|
||||
default: 'grid gap-2 gap-y-6 md:gap-4 lg:gap-6',
|
||||
blog: 'grid gap-6',
|
||||
};
|
||||
|
||||
const flows = {
|
||||
row: 'grid-flow-row',
|
||||
col: 'grid-flow-col',
|
||||
};
|
||||
|
||||
const styles = clsx(flows[flow], gaps[gap], layouts[layout], className);
|
||||
|
||||
return <Component {...props} className={styles} />;
|
||||
}
|
||||
45
examples/hydrogen/src/components/elements/Heading.tsx
Normal file
45
examples/hydrogen/src/components/elements/Heading.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
import {missingClass, formatText} from '~/lib/utils';
|
||||
|
||||
export function Heading({
|
||||
as: Component = 'h2',
|
||||
children,
|
||||
className = '',
|
||||
format,
|
||||
size = 'heading',
|
||||
width = 'default',
|
||||
...props
|
||||
}: {
|
||||
as?: React.ElementType;
|
||||
children: React.ReactNode;
|
||||
format?: boolean;
|
||||
size?: 'display' | 'heading' | 'lead' | 'copy';
|
||||
width?: 'default' | 'narrow' | 'wide';
|
||||
} & React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
const sizes = {
|
||||
display: 'font-bold text-display',
|
||||
heading: 'font-bold text-heading',
|
||||
lead: 'font-bold text-lead',
|
||||
copy: 'font-medium text-copy',
|
||||
};
|
||||
|
||||
const widths = {
|
||||
default: 'max-w-prose',
|
||||
narrow: 'max-w-prose-narrow',
|
||||
wide: 'max-w-prose-wide',
|
||||
};
|
||||
|
||||
const styles = clsx(
|
||||
missingClass(className, 'whitespace-') && 'whitespace-pre-wrap',
|
||||
missingClass(className, 'max-w-') && widths[width],
|
||||
missingClass(className, 'font-') && sizes[size],
|
||||
className,
|
||||
);
|
||||
|
||||
return (
|
||||
<Component {...props} className={styles}>
|
||||
{format ? formatText(children) : children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
236
examples/hydrogen/src/components/elements/Icon.tsx
Normal file
236
examples/hydrogen/src/components/elements/Icon.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
type IconProps = JSX.IntrinsicElements['svg'] & {
|
||||
direction?: 'up' | 'right' | 'down' | 'left';
|
||||
};
|
||||
|
||||
function Icon({
|
||||
children,
|
||||
className,
|
||||
fill = 'currentColor',
|
||||
stroke,
|
||||
...props
|
||||
}: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
{...props}
|
||||
fill={fill}
|
||||
stroke={stroke}
|
||||
className={clsx('w-5 h-5', className)}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountIcon(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<title>Accounts</title>
|
||||
<circle cx="20" cy="10.5" r="4.5" strokeWidth="2" />
|
||||
<path
|
||||
d="M20 19C13.4375 19 9.5 20.2857 9.5 28H30.5C30.5 20.2857 26.5625 19 20 19Z"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconMenu(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props} stroke={props.stroke || 'currentColor'}>
|
||||
<title>Menu</title>
|
||||
<line x1="3" y1="6.375" x2="17" y2="6.375" strokeWidth="1.25" />
|
||||
<line x1="3" y1="10.375" x2="17" y2="10.375" strokeWidth="1.25" />
|
||||
<line x1="3" y1="14.375" x2="17" y2="14.375" strokeWidth="1.25" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconClose(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props} stroke={props.stroke || 'currentColor'}>
|
||||
<title>Close</title>
|
||||
<line
|
||||
x1="4.44194"
|
||||
y1="4.30806"
|
||||
x2="15.7556"
|
||||
y2="15.6218"
|
||||
strokeWidth="1.25"
|
||||
/>
|
||||
<line
|
||||
y1="-0.625"
|
||||
x2="16"
|
||||
y2="-0.625"
|
||||
transform="matrix(-0.707107 0.707107 0.707107 0.707107 16 4.75)"
|
||||
strokeWidth="1.25"
|
||||
/>
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconArrow({direction = 'right'}: IconProps) {
|
||||
let rotate;
|
||||
|
||||
switch (direction) {
|
||||
case 'right':
|
||||
rotate = 'rotate-0';
|
||||
break;
|
||||
case 'left':
|
||||
rotate = 'rotate-180';
|
||||
break;
|
||||
case 'up':
|
||||
rotate = '-rotate-90';
|
||||
break;
|
||||
case 'down':
|
||||
rotate = 'rotate-90';
|
||||
break;
|
||||
default:
|
||||
rotate = 'rotate-0';
|
||||
}
|
||||
|
||||
return (
|
||||
<Icon className={`w-5 h-5 ${rotate}`}>
|
||||
<title>Arrow</title>
|
||||
<path d="M7 3L14 10L7 17" strokeWidth="1.25" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCaret({
|
||||
direction = 'down',
|
||||
stroke = 'currentColor',
|
||||
...props
|
||||
}: IconProps) {
|
||||
let rotate;
|
||||
|
||||
switch (direction) {
|
||||
case 'down':
|
||||
rotate = 'rotate-0';
|
||||
break;
|
||||
case 'up':
|
||||
rotate = 'rotate-180';
|
||||
break;
|
||||
case 'left':
|
||||
rotate = '-rotate-90';
|
||||
break;
|
||||
case 'right':
|
||||
rotate = 'rotate-90';
|
||||
break;
|
||||
default:
|
||||
rotate = 'rotate-0';
|
||||
}
|
||||
|
||||
return (
|
||||
<Icon
|
||||
{...props}
|
||||
className={`w-5 h-5 transition ${rotate}`}
|
||||
fill="transparent"
|
||||
stroke={stroke}
|
||||
>
|
||||
<title>Caret</title>
|
||||
<path d="M14 8L10 12L6 8" strokeWidth="1.25" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSelect(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<title>Select</title>
|
||||
<path d="M7 8.5L10 6.5L13 8.5" strokeWidth="1.25" />
|
||||
<path d="M13 11.5L10 13.5L7 11.5" strokeWidth="1.25" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconBag(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<title>Bag</title>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.125 5a1.875 1.875 0 0 1 3.75 0v.375h-3.75V5Zm-1.25.375V5a3.125 3.125 0 1 1 6.25 0v.375h3.5V15A2.625 2.625 0 0 1 14 17.625H6A2.625 2.625 0 0 1 3.375 15V5.375h3.5ZM4.625 15V6.625h10.75V15c0 .76-.616 1.375-1.375 1.375H6c-.76 0-1.375-.616-1.375-1.375Z"
|
||||
/>
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconAccount(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<title>Account</title>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9.9998 12.625c-1.9141 0-3.6628.698-5.0435 1.8611C3.895 13.2935 3.25 11.7221 3.25 10c0-3.728 3.022-6.75 6.75-6.75 3.7279 0 6.75 3.022 6.75 6.75 0 1.7222-.645 3.2937-1.7065 4.4863-1.3807-1.1632-3.1295-1.8613-5.0437-1.8613ZM10 18c-2.3556 0-4.4734-1.0181-5.9374-2.6382C2.7806 13.9431 2 12.0627 2 10c0-4.4183 3.5817-8 8-8s8 3.5817 8 8-3.5817 8-8 8Zm0-12.5c-1.567 0-2.75 1.394-2.75 3s1.183 3 2.75 3 2.75-1.394 2.75-3-1.183-3-2.75-3Z"
|
||||
/>
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconHelp(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<title>Help</title>
|
||||
<path d="M3.375 10a6.625 6.625 0 1 1 13.25 0 6.625 6.625 0 0 1-13.25 0ZM10 2.125a7.875 7.875 0 1 0 0 15.75 7.875 7.875 0 0 0 0-15.75Zm.699 10.507H9.236V14h1.463v-1.368ZM7.675 7.576A3.256 3.256 0 0 0 7.5 8.67h1.245c0-.496.105-.89.316-1.182.218-.299.553-.448 1.005-.448a1 1 0 0 1 .327.065c.124.044.24.113.35.208.108.095.2.223.272.383.08.154.12.34.12.558a1.3 1.3 0 0 1-.076.471c-.044.131-.11.252-.197.361-.08.102-.174.197-.283.285-.102.087-.212.182-.328.284a3.157 3.157 0 0 0-.382.383c-.102.124-.19.27-.262.438a2.476 2.476 0 0 0-.164.591 6.333 6.333 0 0 0-.043.81h1.179c0-.263.021-.485.065-.668a1.65 1.65 0 0 1 .207-.47c.088-.139.19-.263.306-.372.117-.11.244-.223.382-.34l.35-.306c.116-.11.218-.23.305-.361.095-.139.168-.3.219-.482.058-.19.087-.412.087-.667 0-.35-.062-.664-.186-.942a1.881 1.881 0 0 0-.513-.689 2.07 2.07 0 0 0-.753-.427A2.721 2.721 0 0 0 10.12 6c-.4 0-.764.066-1.092.197a2.36 2.36 0 0 0-.83.536c-.225.234-.4.515-.523.843Z" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSearch(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<title>Search</title>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M13.3 8.52a4.77 4.77 0 1 1-9.55 0 4.77 4.77 0 0 1 9.55 0Zm-.98 4.68a6.02 6.02 0 1 1 .88-.88l4.3 4.3-.89.88-4.3-4.3Z"
|
||||
/>
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCheck({
|
||||
stroke = 'currentColor',
|
||||
...props
|
||||
}: React.ComponentProps<typeof Icon>) {
|
||||
return (
|
||||
<Icon {...props} fill="transparent" stroke={stroke}>
|
||||
<title>Check</title>
|
||||
<circle cx="10" cy="10" r="7.25" strokeWidth="1.25" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="m7.04 10.37 2.42 2.41 3.5-5.56"
|
||||
/>
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconRemove(props: IconProps) {
|
||||
return (
|
||||
<Icon {...props} fill="transparent" stroke={props.stroke || 'currentColor'}>
|
||||
<title>Remove</title>
|
||||
<path
|
||||
d="M4 6H16"
|
||||
strokeWidth="1.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M8.5 9V14" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M11.5 9V14" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path
|
||||
d="M5.5 6L6 17H14L14.5 6"
|
||||
strokeWidth="1.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8 6L8 5C8 4 8.75 3 10 3C11.25 3 12 4 12 5V6"
|
||||
strokeWidth="1.25"
|
||||
/>
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
24
examples/hydrogen/src/components/elements/Input.tsx
Normal file
24
examples/hydrogen/src/components/elements/Input.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
export function Input({
|
||||
className = '',
|
||||
type,
|
||||
variant,
|
||||
...props
|
||||
}: {
|
||||
className?: string;
|
||||
type?: string;
|
||||
variant: 'search' | 'minisearch';
|
||||
[key: string]: any;
|
||||
}) {
|
||||
const variants = {
|
||||
search:
|
||||
'bg-transparent px-0 py-2 text-heading w-full focus:ring-0 border-x-0 border-t-0 transition border-b-2 border-primary/10 focus:border-primary/90',
|
||||
minisearch:
|
||||
'bg-transparent hidden md:inline-block text-left lg:text-right border-b transition border-transparent -mb-px border-x-0 border-t-0 appearance-none px-0 py-1 focus:ring-transparent placeholder:opacity-20 placeholder:text-inherit',
|
||||
};
|
||||
|
||||
const styles = clsx(variants[variant], className);
|
||||
|
||||
return <input type={type} {...props} className={styles} />;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function LogoutButton(props: ButtonProps) {
|
||||
const logout = () => {
|
||||
fetch('/account/logout', {method: 'POST'}).then(() => {
|
||||
if (typeof props?.onClick === 'function') {
|
||||
props.onClick();
|
||||
}
|
||||
window.location.href = '/';
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button className="text-primary/50" {...props} onClick={logout}>
|
||||
Logout
|
||||
</button>
|
||||
);
|
||||
}
|
||||
62
examples/hydrogen/src/components/elements/Section.tsx
Normal file
62
examples/hydrogen/src/components/elements/Section.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
import {Heading} from '~/components';
|
||||
import {missingClass} from '~/lib/utils';
|
||||
|
||||
export function Section({
|
||||
as: Component = 'section',
|
||||
children,
|
||||
className,
|
||||
divider = 'none',
|
||||
display = 'grid',
|
||||
heading,
|
||||
padding = 'all',
|
||||
...props
|
||||
}: {
|
||||
as?: React.ElementType;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
divider?: 'none' | 'top' | 'bottom' | 'both';
|
||||
display?: 'grid' | 'flex';
|
||||
heading?: string;
|
||||
padding?: 'x' | 'y' | 'swimlane' | 'all';
|
||||
[key: string]: any;
|
||||
}) {
|
||||
const paddings = {
|
||||
x: 'px-6 md:px-8 lg:px-12',
|
||||
y: 'py-6 md:py-8 lg:py-12',
|
||||
swimlane: 'pt-4 md:pt-8 lg:pt-12 md:pb-4 lg:pb-8',
|
||||
all: 'p-6 md:p-8 lg:p-12',
|
||||
};
|
||||
|
||||
const dividers = {
|
||||
none: 'border-none',
|
||||
top: 'border-t border-primary/05',
|
||||
bottom: 'border-b border-primary/05',
|
||||
both: 'border-y border-primary/05',
|
||||
};
|
||||
|
||||
const displays = {
|
||||
flex: 'flex',
|
||||
grid: 'grid',
|
||||
};
|
||||
|
||||
const styles = clsx(
|
||||
'w-full gap-4 md:gap-8',
|
||||
displays[display],
|
||||
missingClass(className, '\\mp[xy]?-') && paddings[padding],
|
||||
dividers[divider],
|
||||
className,
|
||||
);
|
||||
|
||||
return (
|
||||
<Component {...props} className={styles}>
|
||||
{heading && (
|
||||
<Heading size="lead" className={padding === 'y' ? paddings['x'] : ''}>
|
||||
{heading}
|
||||
</Heading>
|
||||
)}
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
24
examples/hydrogen/src/components/elements/Skeleton.tsx
Normal file
24
examples/hydrogen/src/components/elements/Skeleton.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
/**
|
||||
* A shared component and Suspense call that's used in `App.server.jsx` to let your app wait for code to load while declaring a loading state
|
||||
*/
|
||||
export function Skeleton({
|
||||
as: Component = 'div',
|
||||
width,
|
||||
height,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
as?: React.ElementType;
|
||||
width?: string;
|
||||
height?: string;
|
||||
className?: string;
|
||||
[key: string]: any;
|
||||
}) {
|
||||
const styles = clsx('rounded bg-primary/10', className);
|
||||
|
||||
return (
|
||||
<Component {...props} width={width} height={height} className={styles} />
|
||||
);
|
||||
}
|
||||
57
examples/hydrogen/src/components/elements/Text.tsx
Normal file
57
examples/hydrogen/src/components/elements/Text.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
import {missingClass, formatText} from '~/lib/utils';
|
||||
|
||||
export function Text({
|
||||
as: Component = 'span',
|
||||
className,
|
||||
color = 'default',
|
||||
format,
|
||||
size = 'copy',
|
||||
width = 'default',
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
as?: React.ElementType;
|
||||
className?: string;
|
||||
color?: 'default' | 'primary' | 'subtle' | 'notice' | 'contrast';
|
||||
format?: boolean;
|
||||
size?: 'lead' | 'copy' | 'fine';
|
||||
width?: 'default' | 'narrow' | 'wide';
|
||||
children: React.ReactNode;
|
||||
[key: string]: any;
|
||||
}) {
|
||||
const colors: Record<string, string> = {
|
||||
default: 'inherit',
|
||||
primary: 'text-primary/90',
|
||||
subtle: 'text-primary/50',
|
||||
notice: 'text-notice',
|
||||
contrast: 'text-contrast/90',
|
||||
};
|
||||
|
||||
const sizes: Record<string, string> = {
|
||||
lead: 'text-lead font-medium',
|
||||
copy: 'text-copy',
|
||||
fine: 'text-fine subpixel-antialiased',
|
||||
};
|
||||
|
||||
const widths: Record<string, string> = {
|
||||
default: 'max-w-prose',
|
||||
narrow: 'max-w-prose-narrow',
|
||||
wide: 'max-w-prose-wide',
|
||||
};
|
||||
|
||||
const styles = clsx(
|
||||
missingClass(className, 'max-w-') && widths[width],
|
||||
missingClass(className, 'whitespace-') && 'whitespace-pre-wrap',
|
||||
missingClass(className, 'text-') && colors[color],
|
||||
sizes[size],
|
||||
className,
|
||||
);
|
||||
|
||||
return (
|
||||
<Component {...props} className={styles}>
|
||||
{format ? formatText(children) : children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
9
examples/hydrogen/src/components/elements/index.ts
Normal file
9
examples/hydrogen/src/components/elements/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from './Icon';
|
||||
export {Button} from './Button';
|
||||
export {Grid} from './Grid';
|
||||
export {Heading} from './Heading';
|
||||
export {Input} from './Input';
|
||||
export {LogoutButton} from './LogoutButton.client';
|
||||
export {Section} from './Section';
|
||||
export {Skeleton} from './Skeleton';
|
||||
export {Text} from './Text';
|
||||
@@ -0,0 +1,18 @@
|
||||
import {CartDetails} from '~/components/cart';
|
||||
import {Drawer} from './Drawer.client';
|
||||
|
||||
export function CartDrawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Drawer open={isOpen} onClose={onClose} heading="Cart" openFrom="right">
|
||||
<div className="grid">
|
||||
<CartDetails layout="drawer" onClose={onClose} />
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
117
examples/hydrogen/src/components/global/Drawer.client.tsx
Normal file
117
examples/hydrogen/src/components/global/Drawer.client.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import {Fragment, useState} from 'react';
|
||||
// @ts-expect-error @headlessui/react incompatibility with node16 resolution
|
||||
import {Dialog, Transition} from '@headlessui/react';
|
||||
|
||||
import {Heading, IconClose} from '~/components';
|
||||
|
||||
/**
|
||||
* Drawer component that opens on user click.
|
||||
* @param heading - string. Shown at the top of the drawer.
|
||||
* @param open - boolean state. if true opens the drawer.
|
||||
* @param onClose - function should set the open state.
|
||||
* @param openFrom - right, left
|
||||
* @param children - react children node.
|
||||
*/
|
||||
function Drawer({
|
||||
heading,
|
||||
open,
|
||||
onClose,
|
||||
openFrom = 'right',
|
||||
children,
|
||||
}: {
|
||||
heading?: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
openFrom: 'right' | 'left';
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const offScreen = {
|
||||
right: 'translate-x-full',
|
||||
left: '-translate-x-full',
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition appear show={open} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-50" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 left-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0">
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div
|
||||
className={`fixed inset-y-0 flex max-w-full ${
|
||||
openFrom === 'right' ? 'right-0' : ''
|
||||
}`}
|
||||
>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transform transition ease-in-out duration-300"
|
||||
enterFrom={offScreen[openFrom]}
|
||||
enterTo="translate-x-0"
|
||||
leave="transform transition ease-in-out duration-300"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo={offScreen[openFrom]}
|
||||
>
|
||||
<Dialog.Panel className="w-screen h-screen max-w-lg text-left align-middle transition-all transform shadow-xl bg-contrast">
|
||||
<header
|
||||
className={`sticky top-0 flex items-center px-6 h-nav sm:px-8 md:px-12 ${
|
||||
heading ? 'justify-between' : 'justify-end'
|
||||
}`}
|
||||
>
|
||||
{heading !== null && (
|
||||
<Dialog.Title>
|
||||
<Heading as="span" size="lead" id="cart-contents">
|
||||
{heading}
|
||||
</Heading>
|
||||
</Dialog.Title>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="p-4 -m-4 transition text-primary hover:text-primary/50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<IconClose aria-label="Close panel" />
|
||||
</button>
|
||||
</header>
|
||||
{children}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
/* Use for associating arialabelledby with the title*/
|
||||
Drawer.Title = Dialog.Title;
|
||||
|
||||
export {Drawer};
|
||||
|
||||
export function useDrawer(openDefault = false) {
|
||||
const [isOpen, setIsOpen] = useState(openDefault);
|
||||
|
||||
function openDrawer() {
|
||||
setIsOpen(true);
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
};
|
||||
}
|
||||
46
examples/hydrogen/src/components/global/Footer.server.tsx
Normal file
46
examples/hydrogen/src/components/global/Footer.server.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import {useUrl} from '@shopify/hydrogen';
|
||||
|
||||
import {Section, Heading, FooterMenu, CountrySelector} from '~/components';
|
||||
import type {EnhancedMenu} from '~/lib/utils';
|
||||
|
||||
/**
|
||||
* A server component that specifies the content of the footer on the website
|
||||
*/
|
||||
export function Footer({menu}: {menu?: EnhancedMenu}) {
|
||||
const {pathname} = useUrl();
|
||||
|
||||
const localeMatch = /^\/([a-z]{2})(\/|$)/i.exec(pathname);
|
||||
const countryCode = localeMatch ? localeMatch[1] : null;
|
||||
|
||||
const isHome = pathname === `/${countryCode ? countryCode + '/' : ''}`;
|
||||
const itemsCount = menu
|
||||
? menu?.items?.length + 1 > 4
|
||||
? 4
|
||||
: menu?.items?.length + 1
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Section
|
||||
divider={isHome ? 'none' : 'top'}
|
||||
as="footer"
|
||||
role="contentinfo"
|
||||
className={`grid min-h-[25rem] items-start grid-flow-row w-full gap-6 py-8 px-6 md:px-8 lg:px-12
|
||||
border-b md:gap-8 lg:gap-12 grid-cols-1 md:grid-cols-2 lg:grid-cols-${itemsCount}
|
||||
bg-primary dark:bg-contrast dark:text-primary text-contrast overflow-hidden`}
|
||||
>
|
||||
<FooterMenu menu={menu} />
|
||||
<section className="grid gap-4 w-full md:max-w-[335px] md:ml-auto">
|
||||
<Heading size="lead" className="cursor-default" as="h3">
|
||||
Country
|
||||
</Heading>
|
||||
<CountrySelector />
|
||||
</section>
|
||||
<div
|
||||
className={`self-end pt-8 opacity-50 md:col-span-2 lg:col-span-${itemsCount}`}
|
||||
>
|
||||
© {new Date().getFullYear()} / Shopify, Inc. Hydrogen is an MIT
|
||||
Licensed Open Source project. This website is carbon neutral.
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// @ts-expect-error @headlessui/react incompatibility with node16 resolution
|
||||
import {Disclosure} from '@headlessui/react';
|
||||
import {Link} from '@shopify/hydrogen';
|
||||
|
||||
import {Heading, IconCaret} from '~/components';
|
||||
import type {EnhancedMenu, EnhancedMenuItem} from '~/lib/utils';
|
||||
|
||||
/**
|
||||
* A server component that specifies the content of the footer on the website
|
||||
*/
|
||||
export function FooterMenu({menu}: {menu?: EnhancedMenu}) {
|
||||
const styles = {
|
||||
section: 'grid gap-4',
|
||||
nav: 'grid gap-2 pb-6',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{(menu?.items || []).map((item: EnhancedMenuItem) => (
|
||||
<section key={item.id} className={styles.section}>
|
||||
<Disclosure>
|
||||
{/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
|
||||
{({open}) => (
|
||||
<>
|
||||
<Disclosure.Button className="text-left md:cursor-default">
|
||||
<Heading className="flex justify-between" size="lead" as="h3">
|
||||
{item.title}
|
||||
{item?.items?.length > 0 && (
|
||||
<span className="md:hidden">
|
||||
<IconCaret direction={open ? 'up' : 'down'} />
|
||||
</span>
|
||||
)}
|
||||
</Heading>
|
||||
</Disclosure.Button>
|
||||
{item?.items?.length > 0 && (
|
||||
<div
|
||||
className={`${
|
||||
open ? `max-h-48 h-fit` : `max-h-0 md:max-h-fit`
|
||||
} overflow-hidden transition-all duration-300`}
|
||||
>
|
||||
<Disclosure.Panel static>
|
||||
<nav className={styles.nav}>
|
||||
{item.items.map((subItem) => (
|
||||
<Link
|
||||
key={subItem.id}
|
||||
to={subItem.to}
|
||||
target={subItem.target}
|
||||
>
|
||||
{subItem.title}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</Disclosure.Panel>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
</section>
|
||||
))}{' '}
|
||||
</>
|
||||
);
|
||||
}
|
||||
230
examples/hydrogen/src/components/global/Header.client.tsx
Normal file
230
examples/hydrogen/src/components/global/Header.client.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import {Link, useUrl, useCart} from '@shopify/hydrogen';
|
||||
import {useWindowScroll} from 'react-use';
|
||||
|
||||
import {
|
||||
Heading,
|
||||
IconAccount,
|
||||
IconBag,
|
||||
IconMenu,
|
||||
IconSearch,
|
||||
Input,
|
||||
} from '~/components';
|
||||
|
||||
import {CartDrawer} from './CartDrawer.client';
|
||||
import {MenuDrawer} from './MenuDrawer.client';
|
||||
import {useDrawer} from './Drawer.client';
|
||||
|
||||
import type {EnhancedMenu} from '~/lib/utils';
|
||||
|
||||
/**
|
||||
* A client component that specifies the content of the header on the website
|
||||
*/
|
||||
export function Header({title, menu}: {title: string; menu?: EnhancedMenu}) {
|
||||
const {pathname} = useUrl();
|
||||
|
||||
const localeMatch = /^\/([a-z]{2})(\/|$)/i.exec(pathname);
|
||||
const countryCode = localeMatch ? localeMatch[1] : undefined;
|
||||
|
||||
const isHome = pathname === `/${countryCode ? countryCode + '/' : ''}`;
|
||||
|
||||
const {
|
||||
isOpen: isCartOpen,
|
||||
openDrawer: openCart,
|
||||
closeDrawer: closeCart,
|
||||
} = useDrawer();
|
||||
|
||||
const {
|
||||
isOpen: isMenuOpen,
|
||||
openDrawer: openMenu,
|
||||
closeDrawer: closeMenu,
|
||||
} = useDrawer();
|
||||
|
||||
return (
|
||||
<>
|
||||
<CartDrawer isOpen={isCartOpen} onClose={closeCart} />
|
||||
<MenuDrawer isOpen={isMenuOpen} onClose={closeMenu} menu={menu!} />
|
||||
<DesktopHeader
|
||||
countryCode={countryCode}
|
||||
isHome={isHome}
|
||||
title={title}
|
||||
menu={menu}
|
||||
openCart={openCart}
|
||||
/>
|
||||
<MobileHeader
|
||||
countryCode={countryCode}
|
||||
isHome={isHome}
|
||||
title={title}
|
||||
openCart={openCart}
|
||||
openMenu={openMenu}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileHeader({
|
||||
countryCode,
|
||||
title,
|
||||
isHome,
|
||||
openCart,
|
||||
openMenu,
|
||||
}: {
|
||||
countryCode?: string | null;
|
||||
title: string;
|
||||
isHome: boolean;
|
||||
openCart: () => void;
|
||||
openMenu: () => void;
|
||||
}) {
|
||||
const {y} = useWindowScroll();
|
||||
|
||||
const styles = {
|
||||
button: 'relative flex items-center justify-center w-8 h-8',
|
||||
container: `${
|
||||
isHome
|
||||
? 'bg-primary/80 dark:bg-contrast/60 text-contrast dark:text-primary shadow-darkHeader'
|
||||
: 'bg-contrast/80 text-primary'
|
||||
} ${
|
||||
y > 50 && !isHome ? 'shadow-lightHeader ' : ''
|
||||
}flex lg:hidden items-center h-nav sticky backdrop-blur-lg z-40 top-0 justify-between w-full leading-none gap-4 px-4 md:px-8`,
|
||||
};
|
||||
|
||||
return (
|
||||
<header role="banner" className={styles.container}>
|
||||
<div className="flex items-center justify-start w-full gap-4">
|
||||
<button onClick={openMenu} className={styles.button}>
|
||||
<IconMenu />
|
||||
</button>
|
||||
<form
|
||||
action={`/${countryCode ? countryCode + '/' : ''}search`}
|
||||
className="items-center gap-2 sm:flex"
|
||||
>
|
||||
<button type="submit" className={styles.button}>
|
||||
<IconSearch />
|
||||
</button>
|
||||
<Input
|
||||
className={
|
||||
isHome
|
||||
? 'focus:border-contrast/20 dark:focus:border-primary/20'
|
||||
: 'focus:border-primary/20'
|
||||
}
|
||||
type="search"
|
||||
variant="minisearch"
|
||||
placeholder="Search"
|
||||
name="q"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
className="flex items-center self-stretch leading-[3rem] md:leading-[4rem] justify-center flex-grow w-full h-full"
|
||||
to="/"
|
||||
>
|
||||
<Heading className="font-bold text-center" as={isHome ? 'h1' : 'h2'}>
|
||||
{title}
|
||||
</Heading>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-end w-full gap-4">
|
||||
<Link to={'/account'} className={styles.button}>
|
||||
<IconAccount />
|
||||
</Link>
|
||||
<button onClick={openCart} className={styles.button}>
|
||||
<IconBag />
|
||||
<CartBadge dark={isHome} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopHeader({
|
||||
countryCode,
|
||||
isHome,
|
||||
menu,
|
||||
openCart,
|
||||
title,
|
||||
}: {
|
||||
countryCode?: string | null;
|
||||
isHome: boolean;
|
||||
openCart: () => void;
|
||||
menu?: EnhancedMenu;
|
||||
title: string;
|
||||
}) {
|
||||
const {y} = useWindowScroll();
|
||||
|
||||
const styles = {
|
||||
button:
|
||||
'relative flex items-center justify-center w-8 h-8 focus:ring-primary/5',
|
||||
container: `${
|
||||
isHome
|
||||
? 'bg-primary/80 dark:bg-contrast/60 text-contrast dark:text-primary shadow-darkHeader'
|
||||
: 'bg-contrast/80 text-primary'
|
||||
} ${
|
||||
y > 50 && !isHome ? 'shadow-lightHeader ' : ''
|
||||
}hidden h-nav lg:flex items-center sticky transition duration-300 backdrop-blur-lg z-40 top-0 justify-between w-full leading-none gap-8 px-12 py-8`,
|
||||
};
|
||||
|
||||
return (
|
||||
<header role="banner" className={styles.container}>
|
||||
<div className="flex gap-12">
|
||||
<Link className={`font-bold`} to="/">
|
||||
{title}
|
||||
</Link>
|
||||
<nav className="flex gap-8">
|
||||
{/* Top level menu items */}
|
||||
{(menu?.items || []).map((item) => (
|
||||
<Link key={item.id} to={item.to} target={item.target}>
|
||||
{item.title}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<form
|
||||
action={`/${countryCode ? countryCode + '/' : ''}search`}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Input
|
||||
className={
|
||||
isHome
|
||||
? 'focus:border-contrast/20 dark:focus:border-primary/20'
|
||||
: 'focus:border-primary/20'
|
||||
}
|
||||
type="search"
|
||||
variant="minisearch"
|
||||
placeholder="Search"
|
||||
name="q"
|
||||
/>
|
||||
<button type="submit" className={styles.button}>
|
||||
<IconSearch />
|
||||
</button>
|
||||
</form>
|
||||
<Link to={'/account'} className={styles.button}>
|
||||
<IconAccount />
|
||||
</Link>
|
||||
<button onClick={openCart} className={styles.button}>
|
||||
<IconBag />
|
||||
<CartBadge dark={isHome} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function CartBadge({dark}: {dark: boolean}) {
|
||||
const {totalQuantity} = useCart();
|
||||
|
||||
if (totalQuantity < 1) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
dark
|
||||
? 'text-primary bg-contrast dark:text-contrast dark:bg-primary'
|
||||
: 'text-contrast bg-primary'
|
||||
} absolute bottom-1 right-1 text-[0.625rem] font-medium subpixel-antialiased h-3 min-w-[0.75rem] flex items-center justify-center leading-none text-center rounded-full w-auto px-[0.125rem] pb-px`}
|
||||
>
|
||||
<span>{totalQuantity}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
examples/hydrogen/src/components/global/Layout.server.tsx
Normal file
129
examples/hydrogen/src/components/global/Layout.server.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import {Suspense} from 'react';
|
||||
import {useLocalization, useShopQuery, CacheLong, gql} from '@shopify/hydrogen';
|
||||
import type {Menu, Shop} from '@shopify/hydrogen/storefront-api-types';
|
||||
|
||||
import {Header} from '~/components';
|
||||
import {Footer} from '~/components/index.server';
|
||||
import {parseMenu} from '~/lib/utils';
|
||||
|
||||
const HEADER_MENU_HANDLE = 'main-menu';
|
||||
const FOOTER_MENU_HANDLE = 'footer';
|
||||
|
||||
const SHOP_NAME_FALLBACK = 'Hydrogen';
|
||||
|
||||
/**
|
||||
* A server component that defines a structure and organization of a page that can be used in different parts of the Hydrogen app
|
||||
*/
|
||||
export function Layout({children}: {children: React.ReactNode}) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<div className="">
|
||||
<a href="#mainContent" className="sr-only">
|
||||
Skip to content
|
||||
</a>
|
||||
</div>
|
||||
<Suspense fallback={<Header title={SHOP_NAME_FALLBACK} />}>
|
||||
<HeaderWithMenu />
|
||||
</Suspense>
|
||||
<main role="main" id="mainContent" className="flex-grow">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<Suspense fallback={<Footer />}>
|
||||
<FooterWithMenu />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderWithMenu() {
|
||||
const {shopName, headerMenu} = useLayoutQuery();
|
||||
return <Header title={shopName} menu={headerMenu} />;
|
||||
}
|
||||
|
||||
function FooterWithMenu() {
|
||||
const {footerMenu} = useLayoutQuery();
|
||||
return <Footer menu={footerMenu} />;
|
||||
}
|
||||
|
||||
function useLayoutQuery() {
|
||||
const {
|
||||
language: {isoCode: languageCode},
|
||||
} = useLocalization();
|
||||
|
||||
const {data} = useShopQuery<{
|
||||
shop: Shop;
|
||||
headerMenu: Menu;
|
||||
footerMenu: Menu;
|
||||
}>({
|
||||
query: SHOP_QUERY,
|
||||
variables: {
|
||||
language: languageCode,
|
||||
headerMenuHandle: HEADER_MENU_HANDLE,
|
||||
footerMenuHandle: FOOTER_MENU_HANDLE,
|
||||
},
|
||||
cache: CacheLong(),
|
||||
preload: '*',
|
||||
});
|
||||
|
||||
const shopName = data ? data.shop.name : SHOP_NAME_FALLBACK;
|
||||
|
||||
/*
|
||||
Modify specific links/routes (optional)
|
||||
@see: https://shopify.dev/api/storefront/unstable/enums/MenuItemType
|
||||
e.g here we map:
|
||||
- /blogs/news -> /news
|
||||
- /blog/news/blog-post -> /news/blog-post
|
||||
- /collections/all -> /products
|
||||
*/
|
||||
const customPrefixes = {BLOG: '', CATALOG: 'products'};
|
||||
|
||||
const headerMenu = data?.headerMenu
|
||||
? parseMenu(data.headerMenu, customPrefixes)
|
||||
: undefined;
|
||||
|
||||
const footerMenu = data?.footerMenu
|
||||
? parseMenu(data.footerMenu, customPrefixes)
|
||||
: undefined;
|
||||
|
||||
return {footerMenu, headerMenu, shopName};
|
||||
}
|
||||
|
||||
const SHOP_QUERY = gql`
|
||||
fragment MenuItem on MenuItem {
|
||||
id
|
||||
resourceId
|
||||
tags
|
||||
title
|
||||
type
|
||||
url
|
||||
}
|
||||
query layoutMenus(
|
||||
$language: LanguageCode
|
||||
$headerMenuHandle: String!
|
||||
$footerMenuHandle: String!
|
||||
) @inContext(language: $language) {
|
||||
shop {
|
||||
name
|
||||
}
|
||||
headerMenu: menu(handle: $headerMenuHandle) {
|
||||
id
|
||||
items {
|
||||
...MenuItem
|
||||
items {
|
||||
...MenuItem
|
||||
}
|
||||
}
|
||||
}
|
||||
footerMenu: menu(handle: $footerMenuHandle) {
|
||||
id
|
||||
items {
|
||||
...MenuItem
|
||||
items {
|
||||
...MenuItem
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,43 @@
|
||||
import {EnhancedMenu} from '~/lib/utils';
|
||||
import {Text} from '~/components';
|
||||
import {Drawer} from './Drawer.client';
|
||||
import {Link} from '@shopify/hydrogen';
|
||||
|
||||
export function MenuDrawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
menu,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
menu: EnhancedMenu;
|
||||
}) {
|
||||
return (
|
||||
<Drawer open={isOpen} onClose={onClose} openFrom="left" heading="Menu">
|
||||
<div className="grid">
|
||||
<MenuMobileNav menu={menu} onClose={onClose} />
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuMobileNav({
|
||||
menu,
|
||||
onClose,
|
||||
}: {
|
||||
menu: EnhancedMenu;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<nav className="grid gap-4 p-6 sm:gap-6 sm:px-12 sm:py-8">
|
||||
{/* Top level menu items */}
|
||||
{(menu?.items || []).map((item) => (
|
||||
<Link key={item.id} to={item.to} target={item.target} onClick={onClose}>
|
||||
<Text as="span" size="copy">
|
||||
{item.title}
|
||||
</Text>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
43
examples/hydrogen/src/components/global/Modal.client.tsx
Normal file
43
examples/hydrogen/src/components/global/Modal.client.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import {IconClose} from '~/components';
|
||||
|
||||
export function Modal({
|
||||
children,
|
||||
close,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
close: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="relative z-50"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
id="modal-bg"
|
||||
>
|
||||
<div className="fixed inset-0 transition-opacity bg-opacity-75 bg-primary/40"></div>
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-full p-4 text-center sm:p-0">
|
||||
<div
|
||||
className="relative flex-1 px-4 pt-5 pb-4 overflow-hidden text-left transition-all transform rounded shadow-xl bg-contrast sm:my-12 sm:flex-none sm:w-full sm:max-w-sm sm:p-6"
|
||||
role="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyPress={(e) => e.stopPropagation()}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="absolute top-0 right-0 hidden pt-4 pr-4 sm:block">
|
||||
<button
|
||||
type="button"
|
||||
className="p-4 -m-4 transition text-primary hover:text-primary/50"
|
||||
onClick={close}
|
||||
>
|
||||
<IconClose aria-label="Close panel" />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
examples/hydrogen/src/components/global/NotFound.server.tsx
Normal file
105
examples/hydrogen/src/components/global/NotFound.server.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
gql,
|
||||
HydrogenResponse,
|
||||
useLocalization,
|
||||
useShopQuery,
|
||||
} from '@shopify/hydrogen';
|
||||
|
||||
import {Suspense} from 'react';
|
||||
import {PRODUCT_CARD_FRAGMENT} from '~/lib/fragments';
|
||||
import {Button, FeaturedCollections, PageHeader, Text} from '~/components';
|
||||
import {ProductSwimlane, Layout} from '~/components/index.server';
|
||||
import type {
|
||||
CollectionConnection,
|
||||
ProductConnection,
|
||||
} from '@shopify/hydrogen/storefront-api-types';
|
||||
|
||||
export function NotFound({
|
||||
response,
|
||||
type = 'page',
|
||||
}: {
|
||||
response?: HydrogenResponse;
|
||||
type?: string;
|
||||
}) {
|
||||
if (response) {
|
||||
response.status = 404;
|
||||
response.statusText = 'Not found';
|
||||
}
|
||||
|
||||
const heading = `We’ve lost this ${type}`;
|
||||
const description = `We couldn’t find the ${type} you’re looking for. Try checking the URL or heading back to the home page.`;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<PageHeader heading={heading}>
|
||||
<Text width="narrow" as="p">
|
||||
{description}
|
||||
</Text>
|
||||
<Button width="auto" variant="secondary" to={'/'}>
|
||||
Take me to the home page
|
||||
</Button>
|
||||
</PageHeader>
|
||||
<Suspense>
|
||||
<FeaturedSection />
|
||||
</Suspense>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturedSection() {
|
||||
const {
|
||||
language: {isoCode: languageCode},
|
||||
country: {isoCode: countryCode},
|
||||
} = useLocalization();
|
||||
|
||||
const {data} = useShopQuery<{
|
||||
featuredCollections: CollectionConnection;
|
||||
featuredProducts: ProductConnection;
|
||||
}>({
|
||||
query: NOT_FOUND_QUERY,
|
||||
variables: {
|
||||
language: languageCode,
|
||||
country: countryCode,
|
||||
},
|
||||
preload: true,
|
||||
});
|
||||
|
||||
const {featuredCollections, featuredProducts} = data;
|
||||
|
||||
return (
|
||||
<>
|
||||
{featuredCollections.nodes.length < 2 && (
|
||||
<FeaturedCollections
|
||||
title="Popular Collections"
|
||||
data={featuredCollections.nodes}
|
||||
/>
|
||||
)}
|
||||
<ProductSwimlane data={featuredProducts.nodes} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const NOT_FOUND_QUERY = gql`
|
||||
${PRODUCT_CARD_FRAGMENT}
|
||||
query homepage($country: CountryCode, $language: LanguageCode)
|
||||
@inContext(country: $country, language: $language) {
|
||||
featuredCollections: collections(first: 3, sortKey: UPDATED_AT) {
|
||||
nodes {
|
||||
id
|
||||
title
|
||||
handle
|
||||
image {
|
||||
altText
|
||||
width
|
||||
height
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
featuredProducts: products(first: 12) {
|
||||
nodes {
|
||||
...ProductCard
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
38
examples/hydrogen/src/components/global/PageHeader.tsx
Normal file
38
examples/hydrogen/src/components/global/PageHeader.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
import {Heading} from '~/components';
|
||||
|
||||
export function PageHeader({
|
||||
children,
|
||||
className,
|
||||
heading,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
heading?: string;
|
||||
variant?: 'default' | 'blogPost' | 'allCollections';
|
||||
[key: string]: any;
|
||||
}) {
|
||||
const variants: Record<string, string> = {
|
||||
default: 'grid w-full gap-8 p-6 py-8 md:p-8 lg:p-12 justify-items-start',
|
||||
blogPost:
|
||||
'grid md:text-center w-full gap-4 p-6 py-8 md:p-8 lg:p-12 md:justify-items-center',
|
||||
allCollections:
|
||||
'flex justify-between items-baseline gap-8 p-6 md:p-8 lg:p-12',
|
||||
};
|
||||
|
||||
const styles = clsx(variants[variant], className);
|
||||
|
||||
return (
|
||||
<header {...props} className={styles}>
|
||||
{heading && (
|
||||
<Heading as="h1" width="narrow" size="heading" className="inline-block">
|
||||
{heading}
|
||||
</Heading>
|
||||
)}
|
||||
{children}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
3
examples/hydrogen/src/components/global/index.server.ts
Normal file
3
examples/hydrogen/src/components/global/index.server.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export {Footer} from './Footer.server';
|
||||
export {Layout} from './Layout.server';
|
||||
export {NotFound} from './NotFound.server';
|
||||
5
examples/hydrogen/src/components/global/index.ts
Normal file
5
examples/hydrogen/src/components/global/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {Drawer, useDrawer} from './Drawer.client';
|
||||
export {FooterMenu} from './FooterMenu.client';
|
||||
export {Header} from './Header.client';
|
||||
export {Modal} from './Modal.client';
|
||||
export {PageHeader} from './PageHeader';
|
||||
5
examples/hydrogen/src/components/index.server.ts
Normal file
5
examples/hydrogen/src/components/index.server.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './cards/index.server';
|
||||
export * from './global/index.server';
|
||||
export * from './sections/index.server';
|
||||
export * from './search/index.server';
|
||||
export {DefaultSeo} from './DefaultSeo.server';
|
||||
10
examples/hydrogen/src/components/index.ts
Normal file
10
examples/hydrogen/src/components/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export * from './account/index';
|
||||
export * from './cards/index';
|
||||
export * from './cart/index';
|
||||
export * from './elements/index';
|
||||
export * from './global/index';
|
||||
export * from './product/index';
|
||||
export * from './sections/index';
|
||||
export {CountrySelector} from './CountrySelector.client';
|
||||
export {CustomFont} from './CustomFont.client';
|
||||
export {HeaderFallback} from './HeaderFallback';
|
||||
@@ -0,0 +1,54 @@
|
||||
// @ts-expect-error @headlessui/react incompatibility with node16 resolution
|
||||
import {Disclosure} from '@headlessui/react';
|
||||
import {Link} from '@shopify/hydrogen';
|
||||
|
||||
import {Text, IconClose} from '~/components';
|
||||
|
||||
export function ProductDetail({
|
||||
title,
|
||||
content,
|
||||
learnMore,
|
||||
}: {
|
||||
title: string;
|
||||
content: string;
|
||||
learnMore?: string;
|
||||
}) {
|
||||
return (
|
||||
<Disclosure key={title} as="div" className="grid w-full gap-2">
|
||||
{/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
|
||||
{({open}) => (
|
||||
<>
|
||||
<Disclosure.Button className="text-left">
|
||||
<div className="flex justify-between">
|
||||
<Text size="lead" as="h4">
|
||||
{title}
|
||||
</Text>
|
||||
<IconClose
|
||||
className={`${
|
||||
open ? '' : 'rotate-[45deg]'
|
||||
} transition-transform transform-gpu duration-200`}
|
||||
/>
|
||||
</div>
|
||||
</Disclosure.Button>
|
||||
|
||||
<Disclosure.Panel className={'pb-4 pt-2 grid gap-2'}>
|
||||
<div
|
||||
className="prose dark:prose-invert"
|
||||
dangerouslySetInnerHTML={{__html: content}}
|
||||
/>
|
||||
{learnMore && (
|
||||
<div className="">
|
||||
<Link
|
||||
className="pb-px border-b border-primary/30 text-primary/50"
|
||||
to={learnMore}
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Disclosure.Panel>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
144
examples/hydrogen/src/components/product/ProductForm.client.tsx
Normal file
144
examples/hydrogen/src/components/product/ProductForm.client.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import {useEffect, useCallback, useState} from 'react';
|
||||
|
||||
import {
|
||||
useProductOptions,
|
||||
isBrowser,
|
||||
useUrl,
|
||||
AddToCartButton,
|
||||
Money,
|
||||
OptionWithValues,
|
||||
ShopPayButton,
|
||||
} from '@shopify/hydrogen';
|
||||
|
||||
import {Heading, Text, Button, ProductOptions} from '~/components';
|
||||
|
||||
export function ProductForm() {
|
||||
const {pathname, search} = useUrl();
|
||||
const [params, setParams] = useState(new URLSearchParams(search));
|
||||
|
||||
const {options, setSelectedOption, selectedOptions, selectedVariant} =
|
||||
useProductOptions();
|
||||
|
||||
const isOutOfStock = !selectedVariant?.availableForSale || false;
|
||||
const isOnSale =
|
||||
selectedVariant?.priceV2?.amount <
|
||||
selectedVariant?.compareAtPriceV2?.amount || false;
|
||||
|
||||
useEffect(() => {
|
||||
if (params || !search) return;
|
||||
setParams(new URLSearchParams(search));
|
||||
}, [params, search]);
|
||||
|
||||
useEffect(() => {
|
||||
(options as OptionWithValues[]).map(({name, values}) => {
|
||||
if (!params) return;
|
||||
const currentValue = params.get(name.toLowerCase()) || null;
|
||||
if (currentValue) {
|
||||
const matchedValue = values.filter(
|
||||
(value) => encodeURIComponent(value.toLowerCase()) === currentValue,
|
||||
);
|
||||
setSelectedOption(name, matchedValue[0]);
|
||||
} else {
|
||||
params.set(
|
||||
encodeURIComponent(name.toLowerCase()),
|
||||
encodeURIComponent(selectedOptions![name]!.toLowerCase()),
|
||||
),
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`${pathname}?${params.toString()}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(name: string, value: string) => {
|
||||
setSelectedOption(name, value);
|
||||
if (!params) return;
|
||||
params.set(
|
||||
encodeURIComponent(name.toLowerCase()),
|
||||
encodeURIComponent(value.toLowerCase()),
|
||||
);
|
||||
if (isBrowser()) {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`${pathname}?${params.toString()}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
[setSelectedOption, params, pathname],
|
||||
);
|
||||
|
||||
return (
|
||||
<form className="grid gap-10">
|
||||
{
|
||||
<div className="grid gap-4">
|
||||
{(options as OptionWithValues[]).map(({name, values}) => {
|
||||
if (values.length === 1) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className="flex flex-col flex-wrap mb-4 gap-y-2 last:mb-0"
|
||||
>
|
||||
<Heading as="legend" size="lead" className="min-w-[4rem]">
|
||||
{name}
|
||||
</Heading>
|
||||
<div className="flex flex-wrap items-baseline gap-4">
|
||||
<ProductOptions
|
||||
name={name}
|
||||
handleChange={handleChange}
|
||||
values={values}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
<div className="grid items-stretch gap-4">
|
||||
<AddToCartButton
|
||||
variantId={selectedVariant?.id}
|
||||
quantity={1}
|
||||
accessibleAddingToCartLabel="Adding item to your cart"
|
||||
disabled={isOutOfStock}
|
||||
>
|
||||
<Button
|
||||
width="full"
|
||||
variant={isOutOfStock ? 'secondary' : 'primary'}
|
||||
as="span"
|
||||
>
|
||||
{isOutOfStock ? (
|
||||
<Text>Sold out</Text>
|
||||
) : (
|
||||
<Text
|
||||
as="span"
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
<span>Add to bag</span> <span>·</span>{' '}
|
||||
<Money
|
||||
withoutTrailingZeros
|
||||
data={selectedVariant.priceV2!}
|
||||
as="span"
|
||||
/>
|
||||
{isOnSale && (
|
||||
<Money
|
||||
withoutTrailingZeros
|
||||
data={selectedVariant.compareAtPriceV2!}
|
||||
as="span"
|
||||
className="opacity-50 strike"
|
||||
/>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Button>
|
||||
</AddToCartButton>
|
||||
{!isOutOfStock && <ShopPayButton variantIds={[selectedVariant.id!]} />}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import {MediaFile} from '@shopify/hydrogen/client';
|
||||
import type {MediaEdge} from '@shopify/hydrogen/storefront-api-types';
|
||||
import {ATTR_LOADING_EAGER} from '~/lib/const';
|
||||
|
||||
/**
|
||||
* A client component that defines a media gallery for hosting images, 3D models, and videos of products
|
||||
*/
|
||||
export function ProductGallery({
|
||||
media,
|
||||
className,
|
||||
}: {
|
||||
media: MediaEdge['node'][];
|
||||
className?: string;
|
||||
}) {
|
||||
if (!media.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`swimlane md:grid-flow-row hiddenScroll md:p-0 md:overflow-x-auto md:grid-cols-2 ${className}`}
|
||||
>
|
||||
{media.map((med, i) => {
|
||||
let mediaProps: Record<string, any> = {};
|
||||
const isFirst = i === 0;
|
||||
const isFourth = i === 3;
|
||||
const isFullWidth = i % 3 === 0;
|
||||
|
||||
const data = {
|
||||
...med,
|
||||
image: {
|
||||
// @ts-ignore
|
||||
...med.image,
|
||||
altText: med.alt || 'Product image',
|
||||
},
|
||||
};
|
||||
|
||||
switch (med.mediaContentType) {
|
||||
case 'IMAGE':
|
||||
mediaProps = {
|
||||
width: 800,
|
||||
widths: [400, 800, 1200, 1600, 2000, 2400],
|
||||
};
|
||||
break;
|
||||
case 'VIDEO':
|
||||
mediaProps = {
|
||||
width: '100%',
|
||||
autoPlay: true,
|
||||
controls: false,
|
||||
muted: true,
|
||||
loop: true,
|
||||
preload: 'auto',
|
||||
};
|
||||
break;
|
||||
case 'EXTERNAL_VIDEO':
|
||||
mediaProps = {width: '100%'};
|
||||
break;
|
||||
case 'MODEL_3D':
|
||||
mediaProps = {
|
||||
width: '100%',
|
||||
interactionPromptThreshold: '0',
|
||||
ar: true,
|
||||
loading: ATTR_LOADING_EAGER,
|
||||
disableZoom: true,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
if (i === 0 && med.mediaContentType === 'IMAGE') {
|
||||
mediaProps.loading = ATTR_LOADING_EAGER;
|
||||
}
|
||||
|
||||
const style = [
|
||||
isFullWidth ? 'md:col-span-2' : 'md:col-span-1',
|
||||
isFirst || isFourth ? '' : 'md:aspect-[4/5]',
|
||||
'aspect-square snap-center card-image bg-white dark:bg-contrast/10 w-mobileGallery md:w-full',
|
||||
].join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={style}
|
||||
// @ts-ignore
|
||||
key={med.id || med.image.id}
|
||||
>
|
||||
<MediaFile
|
||||
tabIndex="0"
|
||||
className={`w-full h-full aspect-square fadeIn object-cover`}
|
||||
data={data}
|
||||
sizes={
|
||||
isFullWidth
|
||||
? '(min-width: 64em) 60vw, (min-width: 48em) 50vw, 90vw'
|
||||
: '(min-width: 64em) 30vw, (min-width: 48em) 25vw, 90vw'
|
||||
}
|
||||
// @ts-ignore
|
||||
options={{
|
||||
crop: 'center',
|
||||
scale: 2,
|
||||
}}
|
||||
{...mediaProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
examples/hydrogen/src/components/product/ProductGrid.client.tsx
Normal file
113
examples/hydrogen/src/components/product/ProductGrid.client.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import {useState, useRef, useEffect, useCallback} from 'react';
|
||||
import {Link, flattenConnection} from '@shopify/hydrogen';
|
||||
|
||||
import {Button, Grid, ProductCard} from '~/components';
|
||||
import {getImageLoadingPriority} from '~/lib/const';
|
||||
import type {Collection, Product} from '@shopify/hydrogen/storefront-api-types';
|
||||
|
||||
export function ProductGrid({
|
||||
url,
|
||||
collection,
|
||||
}: {
|
||||
url: string;
|
||||
collection: Collection;
|
||||
}) {
|
||||
const nextButtonRef = useRef(null);
|
||||
const initialProducts = collection?.products?.nodes || [];
|
||||
const {hasNextPage, endCursor} = collection?.products?.pageInfo ?? {};
|
||||
const [products, setProducts] = useState<Product[]>(initialProducts);
|
||||
const [cursor, setCursor] = useState(endCursor ?? '');
|
||||
const [nextPage, setNextPage] = useState(hasNextPage);
|
||||
const [pending, setPending] = useState(false);
|
||||
const haveProducts = initialProducts.length > 0;
|
||||
|
||||
const fetchProducts = useCallback(async () => {
|
||||
setPending(true);
|
||||
const postUrl = new URL(window.location.origin + url);
|
||||
postUrl.searchParams.set('cursor', cursor);
|
||||
|
||||
const response = await fetch(postUrl, {
|
||||
method: 'POST',
|
||||
});
|
||||
const {data} = await response.json();
|
||||
|
||||
// ProductGrid can paginate collection, products and search routes
|
||||
// @ts-ignore TODO: Fix types
|
||||
const newProducts: Product[] = flattenConnection<Product>(
|
||||
data?.collection?.products || data?.products || [],
|
||||
);
|
||||
const {endCursor, hasNextPage} = data?.collection?.products?.pageInfo ||
|
||||
data?.products?.pageInfo || {endCursor: '', hasNextPage: false};
|
||||
|
||||
setProducts([...products, ...newProducts]);
|
||||
setCursor(endCursor);
|
||||
setNextPage(hasNextPage);
|
||||
setPending(false);
|
||||
}, [cursor, url, products]);
|
||||
|
||||
const handleIntersect = useCallback(
|
||||
(entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
fetchProducts();
|
||||
}
|
||||
});
|
||||
},
|
||||
[fetchProducts],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(handleIntersect, {
|
||||
rootMargin: '100%',
|
||||
});
|
||||
|
||||
const nextButton = nextButtonRef.current;
|
||||
|
||||
if (nextButton) observer.observe(nextButton);
|
||||
|
||||
return () => {
|
||||
if (nextButton) observer.unobserve(nextButton);
|
||||
};
|
||||
}, [nextButtonRef, cursor, handleIntersect]);
|
||||
|
||||
if (!haveProducts) {
|
||||
return (
|
||||
<>
|
||||
<p>No products found on this collection</p>
|
||||
<Link to="/products">
|
||||
<p className="underline">Browse catalog</p>
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid layout="products">
|
||||
{products.map((product, i) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
loading={getImageLoadingPriority(i)}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{nextPage && (
|
||||
<div
|
||||
className="flex items-center justify-center mt-6"
|
||||
ref={nextButtonRef}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={pending}
|
||||
onClick={fetchProducts}
|
||||
width="full"
|
||||
>
|
||||
{pending ? 'Loading...' : 'Load more products'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import {useCallback, useState} from 'react';
|
||||
// @ts-expect-error @headlessui/react incompatibility with node16 resolution
|
||||
import {Listbox} from '@headlessui/react';
|
||||
import {useProductOptions} from '@shopify/hydrogen';
|
||||
|
||||
import {Text, IconCheck, IconCaret} from '~/components';
|
||||
|
||||
export function ProductOptions({
|
||||
values,
|
||||
...props
|
||||
}: {
|
||||
values: any[];
|
||||
[key: string]: any;
|
||||
} & React.ComponentProps<typeof OptionsGrid>) {
|
||||
const asDropdown = values.length > 7;
|
||||
|
||||
return asDropdown ? (
|
||||
<OptionsDropdown values={values} {...props} />
|
||||
) : (
|
||||
<OptionsGrid values={values} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function OptionsGrid({
|
||||
values,
|
||||
name,
|
||||
handleChange,
|
||||
}: {
|
||||
values: string[];
|
||||
name: string;
|
||||
handleChange: (name: string, value: string) => void;
|
||||
}) {
|
||||
const {selectedOptions} = useProductOptions();
|
||||
|
||||
return (
|
||||
<>
|
||||
{values.map((value) => {
|
||||
const checked = selectedOptions![name] === value;
|
||||
const id = `option-${name}-${value}`;
|
||||
|
||||
return (
|
||||
<Text as="label" key={id} htmlFor={id}>
|
||||
<input
|
||||
className="sr-only"
|
||||
type="radio"
|
||||
id={id}
|
||||
name={`option[${name}]`}
|
||||
value={value}
|
||||
checked={checked}
|
||||
onChange={() => handleChange(name, value)}
|
||||
/>
|
||||
<div
|
||||
className={`leading-none py-1 border-b-[1.5px] cursor-pointer transition-all duration-200 ${
|
||||
checked ? 'border-primary/50' : 'border-primary/0'
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: De-dupe UI with CountrySelector
|
||||
function OptionsDropdown({
|
||||
values,
|
||||
name,
|
||||
handleChange,
|
||||
}: {
|
||||
values: string[];
|
||||
name: string;
|
||||
handleChange: (name: string, value: string) => void;
|
||||
}) {
|
||||
const [listboxOpen, setListboxOpen] = useState(false);
|
||||
const {selectedOptions} = useProductOptions();
|
||||
|
||||
const updateSelectedOption = useCallback(
|
||||
(value: string) => {
|
||||
handleChange(name, value);
|
||||
},
|
||||
[name, handleChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Listbox onChange={updateSelectedOption} value="">
|
||||
{/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
|
||||
{({open}) => {
|
||||
setTimeout(() => setListboxOpen(open));
|
||||
return (
|
||||
<>
|
||||
<Listbox.Button
|
||||
className={`flex items-center justify-between w-full py-3 px-4 border border-primary ${
|
||||
open ? 'rounded-b md:rounded-t md:rounded-b-none' : 'rounded'
|
||||
}`}
|
||||
>
|
||||
<span>{selectedOptions![name]}</span>
|
||||
<IconCaret direction={open ? 'up' : 'down'} />
|
||||
</Listbox.Button>
|
||||
|
||||
<Listbox.Options
|
||||
className={`border-primary bg-contrast absolute bottom-12 z-30 grid
|
||||
h-48 w-full overflow-y-scroll rounded-t border px-2 py-2 transition-[max-height]
|
||||
duration-150 sm:bottom-auto md:rounded-b md:rounded-t-none md:border-t-0 md:border-b ${
|
||||
listboxOpen ? 'max-h-48' : 'max-h-0'
|
||||
}`}
|
||||
>
|
||||
{values.map((value) => {
|
||||
const isSelected = selectedOptions![name] === value;
|
||||
const id = `option-${name}-${value}`;
|
||||
|
||||
return (
|
||||
<Listbox.Option key={id} value={value}>
|
||||
{/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
|
||||
{({active}) => (
|
||||
<div
|
||||
className={`text-primary w-full p-2 transition rounded flex justify-start items-center text-left cursor-pointer ${
|
||||
active ? 'bg-primary/10' : null
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
{isSelected ? (
|
||||
<span className="ml-2">
|
||||
<IconCheck />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
);
|
||||
})}
|
||||
</Listbox.Options>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Listbox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
examples/hydrogen/src/components/product/index.ts
Normal file
5
examples/hydrogen/src/components/product/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {ProductForm} from './ProductForm.client';
|
||||
export {ProductGallery} from './ProductGallery.client';
|
||||
export {ProductGrid} from './ProductGrid.client';
|
||||
export {ProductDetail} from './ProductDetail.client';
|
||||
export {ProductOptions} from './ProductOptions.client';
|
||||
@@ -0,0 +1,65 @@
|
||||
import {gql, useShopQuery} from '@shopify/hydrogen';
|
||||
|
||||
import {PRODUCT_CARD_FRAGMENT} from '~/lib/fragments';
|
||||
import {FeaturedCollections} from '~/components';
|
||||
import {ProductSwimlane} from '~/components/index.server';
|
||||
import {PAGINATION_SIZE} from '~/lib/const';
|
||||
|
||||
export function NoResultRecommendations({
|
||||
country,
|
||||
language,
|
||||
}: {
|
||||
country: string;
|
||||
language: string;
|
||||
}) {
|
||||
const {data} = useShopQuery<any>({
|
||||
query: SEARCH_NO_RESULTS_QUERY,
|
||||
variables: {
|
||||
country,
|
||||
language,
|
||||
pageBy: PAGINATION_SIZE,
|
||||
},
|
||||
preload: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<FeaturedCollections
|
||||
title="Trending Collections"
|
||||
data={data.featuredCollections.nodes}
|
||||
/>
|
||||
<ProductSwimlane
|
||||
title="Trending Products"
|
||||
data={data.featuredProducts.nodes}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const SEARCH_NO_RESULTS_QUERY = gql`
|
||||
${PRODUCT_CARD_FRAGMENT}
|
||||
query searchNoResult(
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
$pageBy: Int!
|
||||
) @inContext(country: $country, language: $language) {
|
||||
featuredCollections: collections(first: 3, sortKey: UPDATED_AT) {
|
||||
nodes {
|
||||
id
|
||||
title
|
||||
handle
|
||||
image {
|
||||
altText
|
||||
width
|
||||
height
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
featuredProducts: products(first: $pageBy) {
|
||||
nodes {
|
||||
...ProductCard
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,33 @@
|
||||
import {Heading, Input, PageHeader} from '~/components';
|
||||
import {Layout} from '~/components/index.server';
|
||||
|
||||
export function SearchPage({
|
||||
searchTerm,
|
||||
children,
|
||||
}: {
|
||||
searchTerm?: string | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Layout>
|
||||
<PageHeader>
|
||||
<Heading as="h1" size="copy">
|
||||
Search
|
||||
</Heading>
|
||||
<form className="relative flex w-full text-heading">
|
||||
<Input
|
||||
defaultValue={searchTerm}
|
||||
placeholder="Search…"
|
||||
type="search"
|
||||
variant="search"
|
||||
name="q"
|
||||
/>
|
||||
<button className="absolute right-0 py-2" type="submit">
|
||||
Go
|
||||
</button>
|
||||
</form>
|
||||
</PageHeader>
|
||||
{children}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
2
examples/hydrogen/src/components/search/index.server.ts
Normal file
2
examples/hydrogen/src/components/search/index.server.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export {NoResultRecommendations} from './NoResultRecommendations.server';
|
||||
export {SearchPage} from './SearchPage.server';
|
||||
@@ -0,0 +1,55 @@
|
||||
import {Link, Image} from '@shopify/hydrogen';
|
||||
import type {Collection} from '@shopify/hydrogen/storefront-api-types';
|
||||
|
||||
import {Heading, Section, Grid} from '~/components';
|
||||
|
||||
export function FeaturedCollections({
|
||||
data,
|
||||
title = 'Collections',
|
||||
...props
|
||||
}: {
|
||||
data: Collection[];
|
||||
title?: string;
|
||||
[key: string]: any;
|
||||
}) {
|
||||
const items = data.filter((item) => item.image).length;
|
||||
const haveCollections = data.length > 0;
|
||||
|
||||
if (!haveCollections) return null;
|
||||
|
||||
return (
|
||||
<Section {...props} heading={title}>
|
||||
<Grid items={items}>
|
||||
{data.map((collection) => {
|
||||
if (!collection?.image) {
|
||||
return null;
|
||||
}
|
||||
// TODO: Refactor to use CollectionCard
|
||||
return (
|
||||
<Link key={collection.id} to={`/collections/${collection.handle}`}>
|
||||
<div className="grid gap-4">
|
||||
<div className="card-image bg-primary/5 aspect-[3/2]">
|
||||
{collection?.image && (
|
||||
<Image
|
||||
alt={`Image of ${collection.title}`}
|
||||
data={collection.image}
|
||||
height={400}
|
||||
sizes="(max-width: 32em) 100vw, 33vw"
|
||||
width={600}
|
||||
widths={[400, 500, 600, 700, 800, 900]}
|
||||
loaderOptions={{
|
||||
scale: 2,
|
||||
crop: 'center',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Heading size="copy">{collection.title}</Heading>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
143
examples/hydrogen/src/components/sections/Hero.tsx
Normal file
143
examples/hydrogen/src/components/sections/Hero.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import {Image, Link, Video} from '@shopify/hydrogen';
|
||||
import type {Media} from '@shopify/hydrogen/storefront-api-types';
|
||||
|
||||
import {Heading, Text} from '~/components';
|
||||
|
||||
interface Metafield {
|
||||
value: string;
|
||||
reference?: object;
|
||||
}
|
||||
|
||||
export function Hero({
|
||||
byline,
|
||||
cta,
|
||||
handle,
|
||||
heading,
|
||||
height,
|
||||
loading,
|
||||
spread,
|
||||
spreadSecondary,
|
||||
top,
|
||||
}: {
|
||||
byline: Metafield;
|
||||
cta: Metafield;
|
||||
handle: string;
|
||||
heading: Metafield;
|
||||
height?: 'full';
|
||||
loading?: 'eager' | 'lazy';
|
||||
spread: Metafield;
|
||||
spreadSecondary: Metafield;
|
||||
top?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link to={`/collections/${handle}`}>
|
||||
<section
|
||||
className={`relative justify-end flex flex-col w-full ${
|
||||
top && '-mt-nav'
|
||||
} ${
|
||||
height === 'full'
|
||||
? 'h-screen'
|
||||
: 'aspect-[4/5] sm:aspect-square md:aspect-[5/4] lg:aspect-[3/2] xl:aspect-[2/1]'
|
||||
}`}
|
||||
>
|
||||
<div className="absolute inset-0 grid flex-grow grid-flow-col pointer-events-none auto-cols-fr -z-10 content-stretch overflow-clip">
|
||||
{spread?.reference && (
|
||||
<div className="">
|
||||
<SpreadMedia
|
||||
scale={2}
|
||||
sizes={
|
||||
spreadSecondary?.reference
|
||||
? '(min-width: 80em) 700px, (min-width: 48em) 450px, 500px'
|
||||
: '(min-width: 80em) 1400px, (min-width: 48em) 900px, 500px'
|
||||
}
|
||||
widths={
|
||||
spreadSecondary?.reference
|
||||
? [500, 450, 700]
|
||||
: [500, 900, 1400]
|
||||
}
|
||||
width={spreadSecondary?.reference ? 375 : 750}
|
||||
data={spread.reference as Media}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{spreadSecondary?.reference && (
|
||||
<div className="hidden md:block">
|
||||
<SpreadMedia
|
||||
sizes="(min-width: 80em) 700, (min-width: 48em) 450, 500"
|
||||
widths={[450, 700]}
|
||||
width={375}
|
||||
data={spreadSecondary.reference}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-baseline justify-between gap-4 px-6 py-8 sm:px-8 md:px-12 bg-gradient-to-t dark:from-contrast/60 dark:text-primary from-primary/60 text-contrast">
|
||||
{heading?.value && (
|
||||
<Heading format as="h2" size="display" className="max-w-md">
|
||||
{heading.value}
|
||||
</Heading>
|
||||
)}
|
||||
{byline?.value && (
|
||||
<Text format width="narrow" as="p" size="lead">
|
||||
{byline.value}
|
||||
</Text>
|
||||
)}
|
||||
{cta?.value && <Text size="lead">{cta.value}</Text>}
|
||||
</div>
|
||||
</section>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
interface SpreadMediaProps {
|
||||
data: Media;
|
||||
loading?: HTMLImageElement['loading'];
|
||||
scale?: 2 | 3;
|
||||
sizes: string;
|
||||
width: number;
|
||||
widths: number[];
|
||||
}
|
||||
|
||||
function SpreadMedia({
|
||||
data,
|
||||
loading,
|
||||
scale,
|
||||
sizes,
|
||||
width,
|
||||
widths,
|
||||
}: SpreadMediaProps) {
|
||||
if (data.mediaContentType === 'VIDEO') {
|
||||
return (
|
||||
<Video
|
||||
previewImageOptions={{scale, src: data.previewImage!.url}}
|
||||
width={scale! * width}
|
||||
className="block object-cover w-full h-full"
|
||||
data={data}
|
||||
controls={false}
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
autoPlay
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.mediaContentType === 'IMAGE') {
|
||||
return (
|
||||
<Image
|
||||
widths={widths}
|
||||
sizes={sizes}
|
||||
alt={data.alt || 'Marketing Banner Image'}
|
||||
className="block object-cover w-full h-full"
|
||||
// @ts-ignore
|
||||
data={data.image}
|
||||
loading={loading}
|
||||
width={width}
|
||||
loaderOptions={{scale, crop: 'center'}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
16
examples/hydrogen/src/components/sections/ProductCards.tsx
Normal file
16
examples/hydrogen/src/components/sections/ProductCards.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import {Product} from '@shopify/hydrogen/storefront-api-types';
|
||||
import {ProductCard} from '../cards/ProductCard.client';
|
||||
|
||||
export function ProductCards({products}: {products: Product[]}) {
|
||||
return (
|
||||
<>
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
product={product}
|
||||
key={product.id}
|
||||
className={'snap-start w-80'}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import {Suspense, useMemo} from 'react';
|
||||
import {gql, useShopQuery, useLocalization} from '@shopify/hydrogen';
|
||||
import {PRODUCT_CARD_FRAGMENT} from '~/lib/fragments';
|
||||
import {ProductCard, Section} from '~/components';
|
||||
import type {
|
||||
Product,
|
||||
ProductConnection,
|
||||
} from '@shopify/hydrogen/storefront-api-types';
|
||||
|
||||
const mockProducts = new Array(12).fill('');
|
||||
|
||||
export function ProductSwimlane({
|
||||
title = 'Featured Products',
|
||||
data = mockProducts,
|
||||
count = 12,
|
||||
...props
|
||||
}) {
|
||||
const productCardsMarkup = useMemo(() => {
|
||||
// If the data is already provided, there's no need to query it, so we'll just return the data
|
||||
if (typeof data === 'object') {
|
||||
return <ProductCards products={data} />;
|
||||
}
|
||||
|
||||
// If the data provided is a productId, we will query the productRecommendations API.
|
||||
// To make sure we have enough products for the swimlane, we'll combine the results with our top selling products.
|
||||
if (typeof data === 'string') {
|
||||
return (
|
||||
<Suspense>
|
||||
<RecommendedProducts productId={data} count={count} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// If no data is provided, we'll go and query the top products
|
||||
return <TopProducts count={count} />;
|
||||
}, [count, data]);
|
||||
|
||||
return (
|
||||
<Section heading={title} padding="y" {...props}>
|
||||
<div className="swimlane hiddenScroll md:pb-8 md:scroll-px-8 lg:scroll-px-12 md:px-8 lg:px-12">
|
||||
{productCardsMarkup}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductCards({products}: {products: Product[]}) {
|
||||
return (
|
||||
<>
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
product={product}
|
||||
key={product.id}
|
||||
className={'snap-start w-80'}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendedProducts({
|
||||
productId,
|
||||
count,
|
||||
}: {
|
||||
productId: string;
|
||||
count: number;
|
||||
}) {
|
||||
const {
|
||||
language: {isoCode: languageCode},
|
||||
country: {isoCode: countryCode},
|
||||
} = useLocalization();
|
||||
|
||||
const {data: products} = useShopQuery<{
|
||||
recommended: Product[];
|
||||
additional: ProductConnection;
|
||||
}>({
|
||||
query: RECOMMENDED_PRODUCTS_QUERY,
|
||||
variables: {
|
||||
count,
|
||||
productId,
|
||||
languageCode,
|
||||
countryCode,
|
||||
},
|
||||
});
|
||||
|
||||
const mergedProducts = products.recommended
|
||||
.concat(products.additional.nodes)
|
||||
.filter(
|
||||
(value, index, array) =>
|
||||
array.findIndex((value2) => value2.id === value.id) === index,
|
||||
);
|
||||
|
||||
const originalProduct = mergedProducts
|
||||
.map((item) => item.id)
|
||||
.indexOf(productId);
|
||||
|
||||
mergedProducts.splice(originalProduct, 1);
|
||||
|
||||
return <ProductCards products={mergedProducts} />;
|
||||
}
|
||||
|
||||
function TopProducts({count}: {count: number}) {
|
||||
const {
|
||||
data: {products},
|
||||
} = useShopQuery({
|
||||
query: TOP_PRODUCTS_QUERY,
|
||||
variables: {
|
||||
count,
|
||||
},
|
||||
});
|
||||
|
||||
return <ProductCards products={products.nodes} />;
|
||||
}
|
||||
|
||||
const RECOMMENDED_PRODUCTS_QUERY = gql`
|
||||
${PRODUCT_CARD_FRAGMENT}
|
||||
query productRecommendations(
|
||||
$productId: ID!
|
||||
$count: Int
|
||||
$countryCode: CountryCode
|
||||
$languageCode: LanguageCode
|
||||
) @inContext(country: $countryCode, language: $languageCode) {
|
||||
recommended: productRecommendations(productId: $productId) {
|
||||
...ProductCard
|
||||
}
|
||||
additional: products(first: $count, sortKey: BEST_SELLING) {
|
||||
nodes {
|
||||
...ProductCard
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const TOP_PRODUCTS_QUERY = gql`
|
||||
${PRODUCT_CARD_FRAGMENT}
|
||||
query topProducts(
|
||||
$count: Int
|
||||
$countryCode: CountryCode
|
||||
$languageCode: LanguageCode
|
||||
) @inContext(country: $countryCode, language: $languageCode) {
|
||||
products(first: $count, sortKey: BEST_SELLING) {
|
||||
nodes {
|
||||
...ProductCard
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1 @@
|
||||
export {ProductSwimlane} from './ProductSwimlane.server';
|
||||
2
examples/hydrogen/src/components/sections/index.ts
Normal file
2
examples/hydrogen/src/components/sections/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export {FeaturedCollections} from './FeaturedCollections';
|
||||
export {Hero} from './Hero';
|
||||
10
examples/hydrogen/src/lib/const.ts
Normal file
10
examples/hydrogen/src/lib/const.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const PAGINATION_SIZE = 8;
|
||||
export const DEFAULT_GRID_IMG_LOAD_EAGER_COUNT = 4;
|
||||
export const ATTR_LOADING_EAGER = 'eager';
|
||||
|
||||
export function getImageLoadingPriority(
|
||||
index: number,
|
||||
maxEagerLoadCount = DEFAULT_GRID_IMG_LOAD_EAGER_COUNT,
|
||||
) {
|
||||
return index < maxEagerLoadCount ? ATTR_LOADING_EAGER : undefined;
|
||||
}
|
||||
66
examples/hydrogen/src/lib/fragments.ts
Normal file
66
examples/hydrogen/src/lib/fragments.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {gql} from '@shopify/hydrogen';
|
||||
|
||||
export const MEDIA_FRAGMENT = gql`
|
||||
fragment Media on Media {
|
||||
mediaContentType
|
||||
alt
|
||||
previewImage {
|
||||
url
|
||||
}
|
||||
... on MediaImage {
|
||||
id
|
||||
image {
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
}
|
||||
... on Video {
|
||||
id
|
||||
sources {
|
||||
mimeType
|
||||
url
|
||||
}
|
||||
}
|
||||
... on Model3d {
|
||||
id
|
||||
sources {
|
||||
mimeType
|
||||
url
|
||||
}
|
||||
}
|
||||
... on ExternalVideo {
|
||||
id
|
||||
embedUrl
|
||||
host
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const PRODUCT_CARD_FRAGMENT = gql`
|
||||
fragment ProductCard on Product {
|
||||
id
|
||||
title
|
||||
publishedAt
|
||||
handle
|
||||
variants(first: 1) {
|
||||
nodes {
|
||||
id
|
||||
image {
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
priceV2 {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
compareAtPriceV2 {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
3
examples/hydrogen/src/lib/index.ts
Normal file
3
examples/hydrogen/src/lib/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './fragments';
|
||||
export * from './placeholders';
|
||||
export * from './utils';
|
||||
226
examples/hydrogen/src/lib/placeholders.ts
Normal file
226
examples/hydrogen/src/lib/placeholders.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
// Demo store placeholders
|
||||
const PLACEHOLDERS = {
|
||||
HEROS: [
|
||||
// primaryHero
|
||||
{
|
||||
heading: {value: 'All Mountain All Season'},
|
||||
byline: {
|
||||
value: 'The All New Hydrogen Snowboard Exclusively From Shopify',
|
||||
},
|
||||
cta: {value: 'Shop Now →'},
|
||||
handle: 'freestyle',
|
||||
spread: {
|
||||
reference: {
|
||||
mediaContentType: 'IMAGE',
|
||||
alt: 'Tracks in the snow leading to a person on a mountain top with a red jacket contrasting to an epic blue horizon with a mountain range in the distance.',
|
||||
previewImage: {
|
||||
url: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/files/Hydrogen_Hero_Feature_1.jpg?v=1654902468',
|
||||
},
|
||||
id: 'gid://shopify/MediaImage/29259478466616',
|
||||
image: {
|
||||
url: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/files/Hydrogen_Hero_Feature_1.jpg?v=1654902468',
|
||||
width: 2500,
|
||||
height: 3155,
|
||||
},
|
||||
},
|
||||
},
|
||||
spreadSecondary: {
|
||||
reference: {
|
||||
mediaContentType: 'IMAGE',
|
||||
alt: 'A snowboarder standing on a mountain top in choppy snow, shows off the back of his snowboard which reads Hydrogen in a cursive script.',
|
||||
previewImage: {
|
||||
url: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/files/Hydrogen_Hero_Feature_2.jpg?v=1654902468',
|
||||
},
|
||||
id: 'gid://shopify/MediaImage/29259478499384',
|
||||
image: {
|
||||
url: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/files/Hydrogen_Hero_Feature_2.jpg?v=1654902468',
|
||||
width: 2500,
|
||||
height: 3155,
|
||||
},
|
||||
},
|
||||
},
|
||||
height: 'full',
|
||||
top: true,
|
||||
loading: 'eager',
|
||||
},
|
||||
// secondaryHero
|
||||
{
|
||||
heading: {value: 'The Winter 2022 Collection'},
|
||||
byline: {value: 'Just Dropped'},
|
||||
cta: {value: 'Shop Now →'},
|
||||
handle: 'winter-2022',
|
||||
spread: {
|
||||
reference: {
|
||||
mediaContentType: 'IMAGE',
|
||||
alt: 'Three young women in snowboarding attire embracing and laughing while snow falls around them',
|
||||
previewImage: {
|
||||
url: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/files/Collection_Feature_Wide.jpg?v=1654902160',
|
||||
},
|
||||
id: 'gid://shopify/MediaImage/29259478302776',
|
||||
image: {
|
||||
url: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/files/Collection_Feature_Wide.jpg?v=1654902160',
|
||||
width: 5000,
|
||||
height: 2500,
|
||||
},
|
||||
},
|
||||
},
|
||||
spreadSecondary: null,
|
||||
},
|
||||
// tertiaryHero
|
||||
{
|
||||
heading: {value: 'From the Slopes to the Chalet'},
|
||||
byline: null,
|
||||
cta: {value: 'Shop Now →'},
|
||||
handle: 'backcountry',
|
||||
spread: {
|
||||
reference: {
|
||||
mediaContentType: 'IMAGE',
|
||||
alt: 'A skier hikes up a mountain through the snow with skis over their shoulder.',
|
||||
previewImage: {
|
||||
url: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/files/Chalet_Collection_Feature_1.jpg?v=1654902306',
|
||||
},
|
||||
id: 'gid://shopify/MediaImage/29259478368312',
|
||||
image: {
|
||||
url: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/files/Chalet_Collection_Feature_1.jpg?v=1654902306',
|
||||
width: 2500,
|
||||
height: 2500,
|
||||
},
|
||||
},
|
||||
},
|
||||
spreadSecondary: {
|
||||
reference: {
|
||||
mediaContentType: 'IMAGE',
|
||||
alt: 'A snow covered lodge is illuminated by lights at night with a dark starry sky and mountain backdrop.',
|
||||
previewImage: {
|
||||
url: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/files/Chalet_Collection_Feature_2.jpg?v=1654902306',
|
||||
},
|
||||
id: 'gid://shopify/MediaImage/29259478401080',
|
||||
image: {
|
||||
url: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/files/Chalet_Collection_Feature_2.jpg?v=1654902306',
|
||||
width: 2500,
|
||||
height: 2500,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
PRODUCT_INFO: [
|
||||
{
|
||||
title: 'Description',
|
||||
content:
|
||||
'We threw snow tires on our core classics... Good for all year round! Named after my favorite football match of the year. Just like any of our joints, dress them up or down...',
|
||||
},
|
||||
{
|
||||
title: 'Size and Fit',
|
||||
content:
|
||||
'We threw snow tires on our core classics... Good for all year round! Named after my favorite football match of the year. Just like any of our joints, dress them up or down...',
|
||||
},
|
||||
{
|
||||
title: 'Delivery and Returns',
|
||||
content: `The towels had been hanging from the rod for years. They were stained and worn, and quite frankly, just plain ugly. Debra didn't want to touch them but she really didn't have a choice. It was important for her to see what was living within them. Patrick didn't want to go. The fact that she was insisting they must go made him want to go even less. He had no desire to make small talk with strangers he would never again see just to be polite. But she insisted that Patrick go, and she would soon find out that this would be the biggest mistake she could make in their relationship.`,
|
||||
},
|
||||
],
|
||||
PRODUCT: {
|
||||
label: 'Limited Edition',
|
||||
id: 'gid://shopify/Product/6730850828344',
|
||||
title: 'The Hydrogen',
|
||||
publishedAt: '2021-06-17T18:33:17Z',
|
||||
handle: 'snowboard',
|
||||
description:
|
||||
"Description Our flagship board, ideal for technical terrain and those who dare to go where the chairlift can't take you. The Hydrogen excels in the backcountry making riding out of bounds as easy as resort groomers. New for 2021, the Hydrogen Snowboard has Oxygen Pack inserts giving you more float on the deepest days. Care Guide Clean well after use Wax regularly Specs Weight: 5 lb Length: 4 ft Width: 1 ft Manufactured on: 8/2/2021, 3:30:00 PM Manufactured by: Shopify",
|
||||
priceRange: {
|
||||
minVariantPrice: {
|
||||
amount: '775.0',
|
||||
currencyCode: 'CAD',
|
||||
},
|
||||
maxVariantPrice: {
|
||||
amount: '775.0',
|
||||
currencyCode: 'CAD',
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Color',
|
||||
values: ['Morning', 'Evening', 'Night'],
|
||||
},
|
||||
{
|
||||
name: 'Size',
|
||||
values: ['154', '158', '160'],
|
||||
},
|
||||
],
|
||||
variants: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'gid://shopify/ProductVariant/41007289630776',
|
||||
image: {
|
||||
url: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/products/hydrogen-morning.jpg?v=1636146509',
|
||||
altText: 'The Hydrogen snowboard, color Morning',
|
||||
width: 1200,
|
||||
height: 1504,
|
||||
},
|
||||
priceV2: {
|
||||
amount: '775.0',
|
||||
currencyCode: 'CAD',
|
||||
},
|
||||
compareAtPriceV2: {
|
||||
amount: '840.0',
|
||||
currencyCode: 'CAD',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Return placeholders for collection Heros when metafields are not set
|
||||
export function getHeroPlaceholder(heros: any[]) {
|
||||
if (!heros?.length) return [];
|
||||
|
||||
// when we pass a collection without metafields,
|
||||
// we merge it with placeholder data
|
||||
return heros.map((hero, index) => {
|
||||
// assume passed hero has metafields data already
|
||||
if (hero?.heading?.value) {
|
||||
return hero;
|
||||
}
|
||||
|
||||
// hero placeholder
|
||||
const placeholder = PLACEHOLDERS.HEROS[index];
|
||||
|
||||
// prioritize metafield data if available, else the hero hero values
|
||||
// otherwise the placeholder values
|
||||
const byLine =
|
||||
hero?.byLine || hero?.descriptionHtml
|
||||
? {value: hero.descriptionHtml}
|
||||
: placeholder.byline;
|
||||
|
||||
const heading =
|
||||
hero?.heading || hero?.title ? {value: hero.title} : placeholder.heading;
|
||||
|
||||
// merge hero placeholder with hero data
|
||||
return {
|
||||
heading,
|
||||
byLine,
|
||||
cta: hero?.cta || placeholder.cta,
|
||||
handle: hero?.handle || placeholder.handle,
|
||||
id: hero?.id || index,
|
||||
spread: hero?.spread || placeholder.spread,
|
||||
spreadSecondary: hero?.spreadSecondary || placeholder.spreadSecondary,
|
||||
height: placeholder?.height || undefined,
|
||||
top: placeholder?.top || undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// get product info placeholder data
|
||||
export function getProductInfoPlaceholder() {
|
||||
function getMultipleRandom(arr: any[], infos: number) {
|
||||
const shuffled = [...arr].sort(() => 0.5 - Math.random());
|
||||
return shuffled.slice(0, infos);
|
||||
}
|
||||
return getMultipleRandom(PLACEHOLDERS.PRODUCT_INFO, 3);
|
||||
}
|
||||
|
||||
export function getProductPlaceholder() {
|
||||
return PLACEHOLDERS.PRODUCT;
|
||||
}
|
||||
274
examples/hydrogen/src/lib/utils.ts
Normal file
274
examples/hydrogen/src/lib/utils.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import React, {useCallback} from 'react';
|
||||
import {useServerProps} from '@shopify/hydrogen';
|
||||
import {
|
||||
Menu,
|
||||
MenuItem,
|
||||
MoneyV2,
|
||||
UserError,
|
||||
} from '@shopify/hydrogen/storefront-api-types';
|
||||
|
||||
// @ts-expect-error types not available
|
||||
import typographicBase from 'typographic-base';
|
||||
|
||||
/**
|
||||
* This is a hack until we have better built-in primitives for
|
||||
* causing server components to re-render.
|
||||
*
|
||||
* @returns function when called will cause the current page to re-render on the server
|
||||
*/
|
||||
export function useRenderServerComponents() {
|
||||
const {serverProps, setServerProps} = useServerProps();
|
||||
|
||||
return useCallback(() => {
|
||||
setServerProps('renderRsc', !serverProps.renderRsc);
|
||||
}, [serverProps, setServerProps]);
|
||||
}
|
||||
|
||||
export function missingClass(string?: string, prefix?: string) {
|
||||
if (!string) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const regex = new RegExp(` ?${prefix}`, 'g');
|
||||
return string.match(regex) === null;
|
||||
}
|
||||
|
||||
export function formatText(input?: string | React.ReactNode) {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof input !== 'string') {
|
||||
return input;
|
||||
}
|
||||
|
||||
return typographicBase(input, {locale: 'en-us'}).replace(
|
||||
/\s([^\s<]+)\s*$/g,
|
||||
'\u00A0$1',
|
||||
);
|
||||
}
|
||||
|
||||
export function isNewArrival(date: string, daysOld = 30) {
|
||||
return (
|
||||
new Date(date).valueOf() >
|
||||
new Date().setDate(new Date().getDate() - daysOld).valueOf()
|
||||
);
|
||||
}
|
||||
|
||||
export function isDiscounted(price: MoneyV2, compareAtPrice: MoneyV2) {
|
||||
if (compareAtPrice?.amount > price?.amount) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getExcerpt(text: string) {
|
||||
const regex = /<p.*>(.*?)<\/p>/;
|
||||
const match = regex.exec(text);
|
||||
return match?.length ? match[0] : text;
|
||||
}
|
||||
|
||||
function resolveToFromType(
|
||||
{
|
||||
customPrefixes,
|
||||
pathname,
|
||||
type,
|
||||
}: {
|
||||
customPrefixes: Record<string, string>;
|
||||
pathname?: string;
|
||||
type?: string;
|
||||
} = {
|
||||
customPrefixes: {},
|
||||
},
|
||||
) {
|
||||
if (!pathname || !type) return '';
|
||||
|
||||
/*
|
||||
MenuItemType enum
|
||||
@see: https://shopify.dev/api/storefront/unstable/enums/MenuItemType
|
||||
*/
|
||||
const defaultPrefixes = {
|
||||
BLOG: 'blogs',
|
||||
COLLECTION: 'collections',
|
||||
COLLECTIONS: 'collections', // Collections All (not documented)
|
||||
FRONTPAGE: 'frontpage',
|
||||
HTTP: '',
|
||||
PAGE: 'pages',
|
||||
CATALOG: 'collections/all', // Products All
|
||||
PRODUCT: 'products',
|
||||
SEARCH: 'search',
|
||||
SHOP_POLICY: 'policies',
|
||||
};
|
||||
|
||||
const pathParts = pathname.split('/');
|
||||
const handle = pathParts.pop() || '';
|
||||
const routePrefix: Record<string, string> = {
|
||||
...defaultPrefixes,
|
||||
...customPrefixes,
|
||||
};
|
||||
|
||||
switch (true) {
|
||||
// special cases
|
||||
case type === 'FRONTPAGE':
|
||||
return '/';
|
||||
|
||||
case type === 'ARTICLE': {
|
||||
const blogHandle = pathParts.pop();
|
||||
return routePrefix.BLOG
|
||||
? `/${routePrefix.BLOG}/${blogHandle}/${handle}/`
|
||||
: `/${blogHandle}/${handle}/`;
|
||||
}
|
||||
|
||||
case type === 'COLLECTIONS':
|
||||
return `/${routePrefix.COLLECTIONS}`;
|
||||
|
||||
case type === 'SEARCH':
|
||||
return `/${routePrefix.SEARCH}`;
|
||||
|
||||
case type === 'CATALOG':
|
||||
return `/${routePrefix.CATALOG}`;
|
||||
|
||||
// common cases: BLOG, PAGE, COLLECTION, PRODUCT, SHOP_POLICY, HTTP
|
||||
default:
|
||||
return routePrefix[type]
|
||||
? `/${routePrefix[type]}/${handle}`
|
||||
: `/${handle}`;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Parse each menu link and adding, isExternal, to and target
|
||||
*/
|
||||
function parseItem(customPrefixes = {}) {
|
||||
return function (item: MenuItem): EnhancedMenuItem {
|
||||
if (!item?.url || !item?.type) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Invalid menu item. Must include a url and type.');
|
||||
// @ts-ignore
|
||||
return;
|
||||
}
|
||||
|
||||
// extract path from url because we don't need the origin on internal to attributes
|
||||
const {pathname} = new URL(item.url);
|
||||
|
||||
/*
|
||||
Currently the MenuAPI only returns online store urls e.g — xyz.myshopify.com/..
|
||||
Note: update logic when API is updated to include the active qualified domain
|
||||
*/
|
||||
const isInternalLink = /\.myshopify\.com/g.test(item.url);
|
||||
|
||||
const parsedItem = isInternalLink
|
||||
? // internal links
|
||||
{
|
||||
...item,
|
||||
isExternal: false,
|
||||
target: '_self',
|
||||
to: resolveToFromType({type: item.type, customPrefixes, pathname}),
|
||||
}
|
||||
: // external links
|
||||
{
|
||||
...item,
|
||||
isExternal: true,
|
||||
target: '_blank',
|
||||
to: item.url,
|
||||
};
|
||||
|
||||
return {
|
||||
...parsedItem,
|
||||
items: item.items?.map(parseItem(customPrefixes)),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface EnhancedMenuItem extends MenuItem {
|
||||
to: string;
|
||||
target: string;
|
||||
isExternal?: boolean;
|
||||
items: EnhancedMenuItem[];
|
||||
}
|
||||
|
||||
export interface EnhancedMenu extends Menu {
|
||||
items: EnhancedMenuItem[];
|
||||
}
|
||||
|
||||
/*
|
||||
Recursively adds `to` and `target` attributes to links based on their url
|
||||
and resource type.
|
||||
It optionally overwrites url paths based on item.type
|
||||
*/
|
||||
export function parseMenu(menu: Menu, customPrefixes = {}): EnhancedMenu {
|
||||
if (!menu?.items) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Invalid menu passed to parseMenu');
|
||||
// @ts-ignore
|
||||
return menu;
|
||||
}
|
||||
|
||||
return {
|
||||
...menu,
|
||||
items: menu.items.map(parseItem(customPrefixes)),
|
||||
};
|
||||
}
|
||||
|
||||
export function getApiErrorMessage(
|
||||
field: string,
|
||||
data: Record<string, any>,
|
||||
errors: UserError[],
|
||||
) {
|
||||
if (errors?.length) return errors[0].message ?? errors[0];
|
||||
if (data?.[field]?.customerUserErrors?.length)
|
||||
return data[field].customerUserErrors[0].message;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function statusMessage(status: string) {
|
||||
const translations: Record<string, string> = {
|
||||
ATTEMPTED_DELIVERY: 'Attempted delivery',
|
||||
CANCELED: 'Canceled',
|
||||
CONFIRMED: 'Confirmed',
|
||||
DELIVERED: 'Delivered',
|
||||
FAILURE: 'Failure',
|
||||
FULFILLED: 'Fulfilled',
|
||||
IN_PROGRESS: 'In Progress',
|
||||
IN_TRANSIT: 'In transit',
|
||||
LABEL_PRINTED: 'Label printed',
|
||||
LABEL_PURCHASED: 'Label purchased',
|
||||
LABEL_VOIDED: 'Label voided',
|
||||
MARKED_AS_FULFILLED: 'Marked as fulfilled',
|
||||
NOT_DELIVERED: 'Not delivered',
|
||||
ON_HOLD: 'On Hold',
|
||||
OPEN: 'Open',
|
||||
OUT_FOR_DELIVERY: 'Out for delivery',
|
||||
PARTIALLY_FULFILLED: 'Partially Fulfilled',
|
||||
PENDING_FULFILLMENT: 'Pending',
|
||||
PICKED_UP: 'Displayed as Picked up',
|
||||
READY_FOR_PICKUP: 'Ready for pickup',
|
||||
RESTOCKED: 'Restocked',
|
||||
SCHEDULED: 'Scheduled',
|
||||
SUBMITTED: 'Submitted',
|
||||
UNFULFILLED: 'Unfulfilled',
|
||||
};
|
||||
try {
|
||||
return translations?.[status];
|
||||
} catch (error) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
export function emailValidation(email: HTMLInputElement) {
|
||||
if (email.validity.valid) return null;
|
||||
|
||||
return email.validity.valueMissing
|
||||
? 'Please enter an email'
|
||||
: 'Please enter a valid email';
|
||||
}
|
||||
|
||||
export function passwordValidation(password: HTMLInputElement) {
|
||||
if (password.validity.valid) return null;
|
||||
|
||||
if (password.validity.valueMissing) {
|
||||
return 'Please enter a password';
|
||||
}
|
||||
|
||||
return 'Password must be at least 6 characters';
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {Suspense} from 'react';
|
||||
import {useRouteParams, Seo} from '@shopify/hydrogen';
|
||||
|
||||
import {AccountActivateForm} from '~/components';
|
||||
import {Layout} from '~/components/index.server';
|
||||
|
||||
/**
|
||||
* This page shows a form for the user to activate an account.
|
||||
* It should only be accessed by a link emailed to the user.
|
||||
*/
|
||||
export default function ActivateAccount() {
|
||||
const {id, activationToken} = useRouteParams();
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Suspense>
|
||||
<Seo type="noindex" data={{title: 'Activate account'}} />
|
||||
</Suspense>
|
||||
<AccountActivateForm id={id} activationToken={activationToken} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
CacheNone,
|
||||
gql,
|
||||
type HydrogenApiRouteOptions,
|
||||
type HydrogenRequest,
|
||||
} from '@shopify/hydrogen';
|
||||
|
||||
import {getApiErrorMessage} from '~/lib/utils';
|
||||
|
||||
/**
|
||||
* This API route is used by the form on `/account/activate/[id]/[activationToken]`
|
||||
* complete the reset of the user's password.
|
||||
*/
|
||||
export async function api(
|
||||
request: HydrogenRequest,
|
||||
{session, queryShop}: HydrogenApiRouteOptions,
|
||||
) {
|
||||
if (!session) {
|
||||
return new Response('Session storage not available.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const jsonBody = await request.json();
|
||||
|
||||
if (!jsonBody?.id || !jsonBody?.password || !jsonBody?.activationToken) {
|
||||
return new Response(
|
||||
JSON.stringify({error: 'Incorrect password or activation token.'}),
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const {data, errors} = await queryShop<{
|
||||
customerActivate: any;
|
||||
}>({
|
||||
query: CUSTOMER_ACTIVATE_MUTATION,
|
||||
variables: {
|
||||
id: `gid://shopify/Customer/${jsonBody.id}`,
|
||||
input: {
|
||||
password: jsonBody.password,
|
||||
activationToken: jsonBody.activationToken,
|
||||
},
|
||||
},
|
||||
// @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
|
||||
cache: CacheNone(),
|
||||
});
|
||||
|
||||
if (data?.customerActivate?.customerAccessToken?.accessToken) {
|
||||
await session.set(
|
||||
'customerAccessToken',
|
||||
data.customerActivate.customerAccessToken.accessToken,
|
||||
);
|
||||
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
});
|
||||
} else {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: getApiErrorMessage('customerActivate', data, errors),
|
||||
}),
|
||||
{status: 401},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const CUSTOMER_ACTIVATE_MUTATION = gql`
|
||||
mutation customerActivate($id: ID!, $input: CustomerActivateInput!) {
|
||||
customerActivate(id: $id, input: $input) {
|
||||
customerAccessToken {
|
||||
accessToken
|
||||
expiresAt
|
||||
}
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
CacheNone,
|
||||
gql,
|
||||
type HydrogenApiRouteOptions,
|
||||
type HydrogenRequest,
|
||||
} from '@shopify/hydrogen';
|
||||
|
||||
import {getApiErrorMessage} from '~/lib/utils';
|
||||
import type {Address} from './index.server';
|
||||
|
||||
export async function api(
|
||||
request: HydrogenRequest,
|
||||
{params, session, queryShop}: HydrogenApiRouteOptions,
|
||||
) {
|
||||
if (!session) {
|
||||
return new Response('Session storage not available.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const {customerAccessToken} = await session.get();
|
||||
|
||||
if (!customerAccessToken) return new Response(null, {status: 401});
|
||||
|
||||
if (request.method === 'PATCH')
|
||||
return updateAddress(customerAccessToken, request, params, queryShop);
|
||||
if (request.method === 'DELETE')
|
||||
return deleteAddress(customerAccessToken, params, queryShop);
|
||||
|
||||
return new Response(null, {
|
||||
status: 405,
|
||||
headers: {
|
||||
Allow: 'PATCH,DELETE',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteAddress(
|
||||
customerAccessToken: string,
|
||||
params: HydrogenApiRouteOptions['params'],
|
||||
queryShop: HydrogenApiRouteOptions['queryShop'],
|
||||
) {
|
||||
const {data, errors} = await queryShop<{
|
||||
customerAddressDelete: any;
|
||||
}>({
|
||||
query: DELETE_ADDRESS_MUTATION,
|
||||
variables: {
|
||||
customerAccessToken,
|
||||
id: decodeURIComponent(params.addressId),
|
||||
},
|
||||
// @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
|
||||
cache: CacheNone(),
|
||||
});
|
||||
|
||||
const error = getApiErrorMessage('customerAddressDelete', data, errors);
|
||||
|
||||
if (error) return new Response(JSON.stringify({error}), {status: 400});
|
||||
|
||||
return new Response(null);
|
||||
}
|
||||
|
||||
async function updateAddress(
|
||||
customerAccessToken: string,
|
||||
request: HydrogenRequest,
|
||||
params: HydrogenApiRouteOptions['params'],
|
||||
queryShop: HydrogenApiRouteOptions['queryShop'],
|
||||
) {
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
company,
|
||||
address1,
|
||||
address2,
|
||||
country,
|
||||
province,
|
||||
city,
|
||||
zip,
|
||||
phone,
|
||||
isDefaultAddress,
|
||||
} = await request.json();
|
||||
|
||||
const address: Address = {};
|
||||
|
||||
if (firstName) address.firstName = firstName;
|
||||
if (lastName) address.lastName = lastName;
|
||||
if (company) address.company = company;
|
||||
if (address1) address.address1 = address1;
|
||||
if (address2) address.address2 = address2;
|
||||
if (country) address.country = country;
|
||||
if (province) address.province = province;
|
||||
if (city) address.city = city;
|
||||
if (zip) address.zip = zip;
|
||||
if (phone) address.phone = phone;
|
||||
|
||||
const {data, errors} = await queryShop<{
|
||||
customerAddressUpdate: any;
|
||||
}>({
|
||||
query: UPDATE_ADDRESS_MUTATION,
|
||||
variables: {
|
||||
address,
|
||||
customerAccessToken,
|
||||
id: decodeURIComponent(params.addressId),
|
||||
},
|
||||
// @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
|
||||
cache: CacheNone(),
|
||||
});
|
||||
|
||||
const error = getApiErrorMessage('customerAddressUpdate', data, errors);
|
||||
|
||||
if (error) return new Response(JSON.stringify({error}), {status: 400});
|
||||
|
||||
if (isDefaultAddress) {
|
||||
const {data, errors} = await setDefaultAddress(
|
||||
queryShop,
|
||||
decodeURIComponent(params.addressId),
|
||||
customerAccessToken,
|
||||
);
|
||||
|
||||
const error = getApiErrorMessage(
|
||||
'customerDefaultAddressUpdate',
|
||||
data,
|
||||
errors,
|
||||
);
|
||||
|
||||
if (error) return new Response(JSON.stringify({error}), {status: 400});
|
||||
}
|
||||
|
||||
return new Response(null);
|
||||
}
|
||||
|
||||
export function setDefaultAddress(
|
||||
queryShop: HydrogenApiRouteOptions['queryShop'],
|
||||
addressId: string,
|
||||
customerAccessToken: string,
|
||||
) {
|
||||
return queryShop<{
|
||||
customerDefaultAddressUpdate: any;
|
||||
}>({
|
||||
query: UPDATE_DEFAULT_ADDRESS_MUTATION,
|
||||
variables: {
|
||||
customerAccessToken,
|
||||
addressId,
|
||||
},
|
||||
// @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
|
||||
cache: CacheNone(),
|
||||
});
|
||||
}
|
||||
|
||||
const UPDATE_ADDRESS_MUTATION = gql`
|
||||
mutation customerAddressUpdate(
|
||||
$address: MailingAddressInput!
|
||||
$customerAccessToken: String!
|
||||
$id: ID!
|
||||
) {
|
||||
customerAddressUpdate(
|
||||
address: $address
|
||||
customerAccessToken: $customerAccessToken
|
||||
id: $id
|
||||
) {
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const UPDATE_DEFAULT_ADDRESS_MUTATION = gql`
|
||||
mutation customerDefaultAddressUpdate(
|
||||
$addressId: ID!
|
||||
$customerAccessToken: String!
|
||||
) {
|
||||
customerDefaultAddressUpdate(
|
||||
addressId: $addressId
|
||||
customerAccessToken: $customerAccessToken
|
||||
) {
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const DELETE_ADDRESS_MUTATION = gql`
|
||||
mutation customerAddressDelete($customerAccessToken: String!, $id: ID!) {
|
||||
customerAddressDelete(customerAccessToken: $customerAccessToken, id: $id) {
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
deletedCustomerAddressId
|
||||
}
|
||||
}
|
||||
`;
|
||||
128
examples/hydrogen/src/routes/account/address/index.server.ts
Normal file
128
examples/hydrogen/src/routes/account/address/index.server.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import {setDefaultAddress} from './[addressId].server';
|
||||
import {
|
||||
CacheNone,
|
||||
gql,
|
||||
type HydrogenApiRouteOptions,
|
||||
type HydrogenRequest,
|
||||
} from '@shopify/hydrogen';
|
||||
|
||||
import {getApiErrorMessage} from '~/lib/utils';
|
||||
|
||||
export interface Address {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
company?: string;
|
||||
address1?: string;
|
||||
address2?: string;
|
||||
country?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
zip?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export async function api(
|
||||
request: HydrogenRequest,
|
||||
{session, queryShop}: HydrogenApiRouteOptions,
|
||||
) {
|
||||
if (request.method !== 'POST') {
|
||||
return new Response(null, {
|
||||
status: 405,
|
||||
headers: {
|
||||
Allow: 'POST',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return new Response('Session storage not available.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const {customerAccessToken} = await session.get();
|
||||
|
||||
if (!customerAccessToken) return new Response(null, {status: 401});
|
||||
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
company,
|
||||
address1,
|
||||
address2,
|
||||
country,
|
||||
province,
|
||||
city,
|
||||
zip,
|
||||
phone,
|
||||
isDefaultAddress,
|
||||
} = await request.json();
|
||||
|
||||
const address: Address = {};
|
||||
|
||||
if (firstName) address.firstName = firstName;
|
||||
if (lastName) address.lastName = lastName;
|
||||
if (company) address.company = company;
|
||||
if (address1) address.address1 = address1;
|
||||
if (address2) address.address2 = address2;
|
||||
if (country) address.country = country;
|
||||
if (province) address.province = province;
|
||||
if (city) address.city = city;
|
||||
if (zip) address.zip = zip;
|
||||
if (phone) address.phone = phone;
|
||||
|
||||
const {data, errors} = await queryShop<{
|
||||
customerAddressCreate: any;
|
||||
}>({
|
||||
query: CREATE_ADDRESS_MUTATION,
|
||||
variables: {
|
||||
address,
|
||||
customerAccessToken,
|
||||
},
|
||||
// @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
|
||||
cache: CacheNone(),
|
||||
});
|
||||
|
||||
const error = getApiErrorMessage('customerAddressCreate', data, errors);
|
||||
|
||||
if (error) return new Response(JSON.stringify({error}), {status: 400});
|
||||
|
||||
if (isDefaultAddress) {
|
||||
const {data: defaultDataResponse, errors} = await setDefaultAddress(
|
||||
queryShop,
|
||||
data.customerAddressCreate.customerAddress.id,
|
||||
customerAccessToken,
|
||||
);
|
||||
|
||||
const error = getApiErrorMessage(
|
||||
'customerDefaultAddressUpdate',
|
||||
defaultDataResponse,
|
||||
errors,
|
||||
);
|
||||
|
||||
if (error) return new Response(JSON.stringify({error}), {status: 400});
|
||||
}
|
||||
|
||||
return new Response(null);
|
||||
}
|
||||
|
||||
const CREATE_ADDRESS_MUTATION = gql`
|
||||
mutation customerAddressCreate(
|
||||
$address: MailingAddressInput!
|
||||
$customerAccessToken: String!
|
||||
) {
|
||||
customerAddressCreate(
|
||||
address: $address
|
||||
customerAccessToken: $customerAccessToken
|
||||
) {
|
||||
customerAddress {
|
||||
id
|
||||
}
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
308
examples/hydrogen/src/routes/account/index.server.tsx
Normal file
308
examples/hydrogen/src/routes/account/index.server.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import {Suspense} from 'react';
|
||||
import {
|
||||
CacheNone,
|
||||
flattenConnection,
|
||||
gql,
|
||||
Seo,
|
||||
useSession,
|
||||
useLocalization,
|
||||
useShopQuery,
|
||||
type HydrogenRouteProps,
|
||||
type HydrogenRequest,
|
||||
type HydrogenApiRouteOptions,
|
||||
} from '@shopify/hydrogen';
|
||||
|
||||
import {PRODUCT_CARD_FRAGMENT} from '~/lib/fragments';
|
||||
import {getApiErrorMessage} from '~/lib/utils';
|
||||
import {
|
||||
AccountAddressBook,
|
||||
AccountDetails,
|
||||
AccountOrderHistory,
|
||||
FeaturedCollections,
|
||||
LogoutButton,
|
||||
PageHeader,
|
||||
} from '~/components';
|
||||
import {Layout, ProductSwimlane} from '~/components/index.server';
|
||||
import type {
|
||||
Collection,
|
||||
CollectionConnection,
|
||||
Customer,
|
||||
MailingAddress,
|
||||
Order,
|
||||
Product,
|
||||
ProductConnection,
|
||||
} from '@shopify/hydrogen/storefront-api-types';
|
||||
|
||||
export default function Account({response}: HydrogenRouteProps) {
|
||||
response.cache(CacheNone());
|
||||
|
||||
const {
|
||||
language: {isoCode: languageCode},
|
||||
country: {isoCode: countryCode},
|
||||
} = useLocalization();
|
||||
const {customerAccessToken} = useSession();
|
||||
|
||||
if (!customerAccessToken) return response.redirect('/account/login');
|
||||
|
||||
const {data} = useShopQuery<{
|
||||
customer: Customer;
|
||||
featuredCollections: CollectionConnection;
|
||||
featuredProducts: ProductConnection;
|
||||
}>({
|
||||
query: CUSTOMER_QUERY,
|
||||
variables: {
|
||||
customerAccessToken,
|
||||
language: languageCode,
|
||||
country: countryCode,
|
||||
},
|
||||
cache: CacheNone(),
|
||||
});
|
||||
|
||||
const {customer, featuredCollections, featuredProducts} = data;
|
||||
|
||||
if (!customer) return response.redirect('/account/login');
|
||||
|
||||
const addresses = flattenConnection<MailingAddress>(customer.addresses).map(
|
||||
(address) => ({
|
||||
...address,
|
||||
id: address.id!.substring(0, address.id!.lastIndexOf('?')),
|
||||
originalId: address.id,
|
||||
}),
|
||||
);
|
||||
|
||||
const defaultAddress = customer?.defaultAddress?.id?.substring(
|
||||
0,
|
||||
customer.defaultAddress.id.lastIndexOf('?'),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthenticatedAccount
|
||||
customer={customer}
|
||||
addresses={addresses}
|
||||
defaultAddress={defaultAddress}
|
||||
featuredCollections={
|
||||
flattenConnection<Collection>(featuredCollections) as Collection[]
|
||||
}
|
||||
featuredProducts={
|
||||
flattenConnection<Product>(featuredProducts) as Product[]
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthenticatedAccount({
|
||||
customer,
|
||||
addresses,
|
||||
defaultAddress,
|
||||
featuredCollections,
|
||||
featuredProducts,
|
||||
}: {
|
||||
customer: Customer;
|
||||
addresses: any[];
|
||||
defaultAddress?: string;
|
||||
featuredCollections: Collection[];
|
||||
featuredProducts: Product[];
|
||||
}) {
|
||||
const orders = flattenConnection(customer?.orders) || [];
|
||||
|
||||
const heading = customer
|
||||
? customer.firstName
|
||||
? `Welcome, ${customer.firstName}.`
|
||||
: `Welcome to your account.`
|
||||
: 'Account Details';
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Suspense>
|
||||
<Seo type="noindex" data={{title: 'Account details'}} />
|
||||
</Suspense>
|
||||
<PageHeader heading={heading}>
|
||||
<LogoutButton>Sign out</LogoutButton>
|
||||
</PageHeader>
|
||||
{orders && <AccountOrderHistory orders={orders as Order[]} />}
|
||||
<AccountDetails
|
||||
firstName={customer.firstName as string}
|
||||
lastName={customer.lastName as string}
|
||||
phone={customer.phone as string}
|
||||
email={customer.email as string}
|
||||
/>
|
||||
<AccountAddressBook
|
||||
defaultAddress={defaultAddress}
|
||||
addresses={addresses}
|
||||
/>
|
||||
{!orders && (
|
||||
<>
|
||||
<FeaturedCollections
|
||||
title="Popular Collections"
|
||||
data={featuredCollections}
|
||||
/>
|
||||
<ProductSwimlane data={featuredProducts} />
|
||||
</>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export async function api(
|
||||
request: HydrogenRequest,
|
||||
{session, queryShop}: HydrogenApiRouteOptions,
|
||||
) {
|
||||
if (request.method !== 'PATCH' && request.method !== 'DELETE') {
|
||||
return new Response(null, {
|
||||
status: 405,
|
||||
headers: {
|
||||
Allow: 'PATCH,DELETE',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return new Response('Session storage not available.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const {customerAccessToken} = await session.get();
|
||||
|
||||
if (!customerAccessToken) return new Response(null, {status: 401});
|
||||
|
||||
const {email, phone, firstName, lastName, newPassword} = await request.json();
|
||||
|
||||
interface Customer {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
const customer: Customer = {};
|
||||
|
||||
if (email) customer.email = email;
|
||||
if (phone) customer.phone = phone;
|
||||
if (firstName) customer.firstName = firstName;
|
||||
if (lastName) customer.lastName = lastName;
|
||||
if (newPassword) customer.password = newPassword;
|
||||
|
||||
const {data, errors} = await queryShop<{customerUpdate: any}>({
|
||||
query: CUSTOMER_UPDATE_MUTATION,
|
||||
variables: {
|
||||
customer,
|
||||
customerAccessToken,
|
||||
},
|
||||
// @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
|
||||
cache: CacheNone(),
|
||||
});
|
||||
|
||||
const error = getApiErrorMessage('customerUpdate', data, errors);
|
||||
|
||||
if (error) return new Response(JSON.stringify({error}), {status: 400});
|
||||
|
||||
return new Response(null);
|
||||
}
|
||||
|
||||
const CUSTOMER_QUERY = gql`
|
||||
${PRODUCT_CARD_FRAGMENT}
|
||||
query CustomerDetails(
|
||||
$customerAccessToken: String!
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customer(customerAccessToken: $customerAccessToken) {
|
||||
firstName
|
||||
lastName
|
||||
phone
|
||||
email
|
||||
defaultAddress {
|
||||
id
|
||||
formatted
|
||||
}
|
||||
addresses(first: 6) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
formatted
|
||||
firstName
|
||||
lastName
|
||||
company
|
||||
address1
|
||||
address2
|
||||
country
|
||||
province
|
||||
city
|
||||
zip
|
||||
phone
|
||||
}
|
||||
}
|
||||
}
|
||||
orders(first: 250, sortKey: PROCESSED_AT, reverse: true) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
orderNumber
|
||||
processedAt
|
||||
financialStatus
|
||||
fulfillmentStatus
|
||||
currentTotalPrice {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
lineItems(first: 2) {
|
||||
edges {
|
||||
node {
|
||||
variant {
|
||||
image {
|
||||
url
|
||||
altText
|
||||
height
|
||||
width
|
||||
}
|
||||
}
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
featuredProducts: products(first: 12) {
|
||||
nodes {
|
||||
...ProductCard
|
||||
}
|
||||
}
|
||||
featuredCollections: collections(first: 3, sortKey: UPDATED_AT) {
|
||||
nodes {
|
||||
id
|
||||
title
|
||||
handle
|
||||
image {
|
||||
altText
|
||||
width
|
||||
height
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const CUSTOMER_UPDATE_MUTATION = gql`
|
||||
mutation customerUpdate(
|
||||
$customer: CustomerUpdateInput!
|
||||
$customerAccessToken: String!
|
||||
) {
|
||||
customerUpdate(
|
||||
customer: $customer
|
||||
customerAccessToken: $customerAccessToken
|
||||
) {
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
114
examples/hydrogen/src/routes/account/login.server.tsx
Normal file
114
examples/hydrogen/src/routes/account/login.server.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import {Suspense} from 'react';
|
||||
import {
|
||||
useShopQuery,
|
||||
CacheLong,
|
||||
CacheNone,
|
||||
Seo,
|
||||
gql,
|
||||
type HydrogenRouteProps,
|
||||
HydrogenRequest,
|
||||
HydrogenApiRouteOptions,
|
||||
} from '@shopify/hydrogen';
|
||||
|
||||
import {AccountLoginForm} from '~/components';
|
||||
import {Layout} from '~/components/index.server';
|
||||
|
||||
export default function Login({response}: HydrogenRouteProps) {
|
||||
response.cache(CacheNone());
|
||||
|
||||
const {
|
||||
data: {
|
||||
shop: {name},
|
||||
},
|
||||
} = useShopQuery({
|
||||
query: SHOP_QUERY,
|
||||
cache: CacheLong(),
|
||||
preload: '*',
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Suspense>
|
||||
<Seo type="noindex" data={{title: 'Login'}} />
|
||||
</Suspense>
|
||||
<AccountLoginForm shopName={name} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const SHOP_QUERY = gql`
|
||||
query shopInfo {
|
||||
shop {
|
||||
name
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export async function api(
|
||||
request: HydrogenRequest,
|
||||
{session, queryShop}: HydrogenApiRouteOptions,
|
||||
) {
|
||||
if (!session) {
|
||||
return new Response('Session storage not available.', {status: 400});
|
||||
}
|
||||
|
||||
const jsonBody = await request.json();
|
||||
|
||||
if (
|
||||
!jsonBody.email ||
|
||||
jsonBody.email === '' ||
|
||||
!jsonBody.password ||
|
||||
jsonBody.password === ''
|
||||
) {
|
||||
return new Response(
|
||||
JSON.stringify({error: 'Incorrect email or password.'}),
|
||||
{status: 400},
|
||||
);
|
||||
}
|
||||
|
||||
const {data, errors} = await queryShop<{customerAccessTokenCreate: any}>({
|
||||
query: LOGIN_MUTATION,
|
||||
variables: {
|
||||
input: {
|
||||
email: jsonBody.email,
|
||||
password: jsonBody.password,
|
||||
},
|
||||
},
|
||||
// @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
|
||||
cache: CacheNone(),
|
||||
});
|
||||
|
||||
if (data?.customerAccessTokenCreate?.customerAccessToken?.accessToken) {
|
||||
await session.set(
|
||||
'customerAccessToken',
|
||||
data.customerAccessTokenCreate.customerAccessToken.accessToken,
|
||||
);
|
||||
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
});
|
||||
} else {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: data?.customerAccessTokenCreate?.customerUserErrors ?? errors,
|
||||
}),
|
||||
{status: 401},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const LOGIN_MUTATION = gql`
|
||||
mutation customerAccessTokenCreate($input: CustomerAccessTokenCreateInput!) {
|
||||
customerAccessTokenCreate(input: $input) {
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
customerAccessToken {
|
||||
accessToken
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
25
examples/hydrogen/src/routes/account/logout.server.ts
Normal file
25
examples/hydrogen/src/routes/account/logout.server.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type {HydrogenApiRouteOptions, HydrogenRequest} from '@shopify/hydrogen';
|
||||
|
||||
export async function api(
|
||||
request: HydrogenRequest,
|
||||
{session}: HydrogenApiRouteOptions,
|
||||
) {
|
||||
if (request.method !== 'POST') {
|
||||
return new Response('Post required to logout', {
|
||||
status: 405,
|
||||
headers: {
|
||||
Allow: 'POST',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return new Response('Session storage not available.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await session.set('customerAccessToken', '');
|
||||
|
||||
return new Response();
|
||||
}
|
||||
442
examples/hydrogen/src/routes/account/orders/[id].server.tsx
Normal file
442
examples/hydrogen/src/routes/account/orders/[id].server.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
import {
|
||||
CacheNone,
|
||||
flattenConnection,
|
||||
gql,
|
||||
type HydrogenRouteProps,
|
||||
Image,
|
||||
Link,
|
||||
Money,
|
||||
Seo,
|
||||
useRouteParams,
|
||||
useSession,
|
||||
useLocalization,
|
||||
useShopQuery,
|
||||
} from '@shopify/hydrogen';
|
||||
import type {
|
||||
Customer,
|
||||
DiscountApplication,
|
||||
DiscountApplicationConnection,
|
||||
Order,
|
||||
OrderLineItem,
|
||||
} from '@shopify/hydrogen/storefront-api-types';
|
||||
import {Suspense} from 'react';
|
||||
|
||||
import {Text, PageHeader, Heading} from '~/components';
|
||||
import {Layout} from '~/components/index.server';
|
||||
import {statusMessage} from '~/lib/utils';
|
||||
|
||||
export default function OrderDetails({response}: HydrogenRouteProps) {
|
||||
const {id} = useRouteParams();
|
||||
|
||||
response.cache(CacheNone());
|
||||
|
||||
const {
|
||||
language: {isoCode: languageCode},
|
||||
country: {isoCode: countryCode},
|
||||
} = useLocalization();
|
||||
const {customerAccessToken} = useSession();
|
||||
|
||||
if (!customerAccessToken) return response.redirect('/account/login');
|
||||
if (!id) return response.redirect('/account/');
|
||||
|
||||
const {data} = useShopQuery<{
|
||||
customer?: Customer;
|
||||
}>({
|
||||
query: ORDER_QUERY,
|
||||
variables: {
|
||||
customerAccessToken,
|
||||
orderId: `id:${id}`,
|
||||
language: languageCode,
|
||||
country: countryCode,
|
||||
},
|
||||
cache: CacheNone(),
|
||||
});
|
||||
|
||||
const [order] = flattenConnection<Order>(data?.customer?.orders ?? {}) || [
|
||||
null,
|
||||
];
|
||||
|
||||
if (!order) return null;
|
||||
|
||||
const lineItems = flattenConnection<OrderLineItem>(order.lineItems!);
|
||||
const discountApplications = flattenConnection<DiscountApplication>(
|
||||
order.discountApplications as DiscountApplicationConnection,
|
||||
);
|
||||
|
||||
const firstDiscount = discountApplications[0]?.value;
|
||||
const discountValue =
|
||||
firstDiscount?.__typename === 'MoneyV2' && firstDiscount;
|
||||
const discountPercentage =
|
||||
firstDiscount?.__typename === 'PricingPercentageValue' &&
|
||||
firstDiscount?.percentage;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Suspense>
|
||||
<Seo type="noindex" data={{title: `Order ${order.name}`}} />
|
||||
</Suspense>
|
||||
<PageHeader heading={`Order detail`}>
|
||||
<Link to="/account">
|
||||
<Text color="subtle">Return to Account Overview</Text>
|
||||
</Link>
|
||||
</PageHeader>
|
||||
<div className="w-full p-6 sm:grid-cols-1 md:p-8 lg:p-12 lg:py-6">
|
||||
<div>
|
||||
<Text as="h3" size="lead">
|
||||
Order No. {order.name}
|
||||
</Text>
|
||||
<Text className="mt-2" as="p">
|
||||
Placed on {new Date(order.processedAt!).toDateString()}
|
||||
</Text>
|
||||
<div className="grid items-start gap-12 sm:grid-cols-1 md:grid-cols-4 md:gap-16 sm:divide-y sm:divide-gray-200">
|
||||
<table className="min-w-full my-8 divide-y divide-gray-300 md:col-span-3">
|
||||
<thead>
|
||||
<tr className="align-baseline ">
|
||||
<th
|
||||
scope="col"
|
||||
className="pb-4 pl-0 pr-3 font-semibold text-left"
|
||||
>
|
||||
Product
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="hidden px-4 pb-4 font-semibold text-right sm:table-cell md:table-cell"
|
||||
>
|
||||
Price
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="hidden px-4 pb-4 font-semibold text-right sm:table-cell md:table-cell"
|
||||
>
|
||||
Quantity
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-4 pb-4 font-semibold text-right"
|
||||
>
|
||||
Total
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{lineItems.map((lineItem) => (
|
||||
<tr key={lineItem.variant!.id}>
|
||||
<td className="w-full py-4 pl-0 pr-3 align-top sm:align-middle max-w-0 sm:w-auto sm:max-w-none">
|
||||
<div className="flex gap-6">
|
||||
<Link
|
||||
to={`/products/${lineItem.variant!.product!.handle}`}
|
||||
>
|
||||
{lineItem?.variant?.image && (
|
||||
<div className="w-24 card-image aspect-square">
|
||||
<Image
|
||||
src={lineItem.variant.image.src!}
|
||||
width={lineItem.variant.image.width!}
|
||||
height={lineItem.variant.image.height!}
|
||||
alt={lineItem.variant.image.altText!}
|
||||
loaderOptions={{
|
||||
scale: 2,
|
||||
crop: 'center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<div className="flex-col justify-center hidden lg:flex">
|
||||
<Text as="p">{lineItem.title}</Text>
|
||||
<Text size="fine" className="mt-1" as="p">
|
||||
{lineItem.variant!.title}
|
||||
</Text>
|
||||
</div>
|
||||
<dl className="grid">
|
||||
<dt className="sr-only">Product</dt>
|
||||
<dd className="truncate lg:hidden">
|
||||
<Heading size="copy" format as="h3">
|
||||
{lineItem.title}
|
||||
</Heading>
|
||||
<Text size="fine" className="mt-1">
|
||||
{lineItem.variant!.title}
|
||||
</Text>
|
||||
</dd>
|
||||
<dt className="sr-only">Price</dt>
|
||||
<dd className="truncate sm:hidden">
|
||||
<Text size="fine" className="mt-4">
|
||||
<Money data={lineItem.variant!.priceV2!} />
|
||||
</Text>
|
||||
</dd>
|
||||
<dt className="sr-only">Quantity</dt>
|
||||
<dd className="truncate sm:hidden">
|
||||
<Text className="mt-1" size="fine">
|
||||
Qty: {lineItem.quantity}
|
||||
</Text>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden px-3 py-4 text-right align-top sm:align-middle sm:table-cell">
|
||||
<Money data={lineItem.variant!.priceV2!} />
|
||||
</td>
|
||||
<td className="hidden px-3 py-4 text-right align-top sm:align-middle sm:table-cell">
|
||||
{lineItem.quantity}
|
||||
</td>
|
||||
<td className="px-3 py-4 text-right align-top sm:align-middle sm:table-cell">
|
||||
<Text>
|
||||
<Money data={lineItem.discountedTotalPrice!} />
|
||||
</Text>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
{((discountValue && discountValue.amount) ||
|
||||
discountPercentage) && (
|
||||
<tr>
|
||||
<th
|
||||
scope="row"
|
||||
colSpan={3}
|
||||
className="hidden pt-6 pl-6 pr-3 font-normal text-right sm:table-cell md:pl-0"
|
||||
>
|
||||
<Text>Discounts</Text>
|
||||
</th>
|
||||
<th
|
||||
scope="row"
|
||||
className="pt-6 pr-3 font-normal text-left sm:hidden"
|
||||
>
|
||||
<Text>Discounts</Text>
|
||||
</th>
|
||||
<td className="pt-6 pl-3 pr-4 font-medium text-right text-green-700 md:pr-3">
|
||||
{discountPercentage ? (
|
||||
<span className="text-sm">
|
||||
-{discountPercentage}% OFF
|
||||
</span>
|
||||
) : (
|
||||
discountValue && <Money data={discountValue!} />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<th
|
||||
scope="row"
|
||||
colSpan={3}
|
||||
className="hidden pt-6 pl-6 pr-3 font-normal text-right sm:table-cell md:pl-0"
|
||||
>
|
||||
<Text>Subtotal</Text>
|
||||
</th>
|
||||
<th
|
||||
scope="row"
|
||||
className="pt-6 pr-3 font-normal text-left sm:hidden"
|
||||
>
|
||||
<Text>Subtotal</Text>
|
||||
</th>
|
||||
<td className="pt-6 pl-3 pr-4 text-right md:pr-3">
|
||||
<Money data={order.subtotalPriceV2!} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th
|
||||
scope="row"
|
||||
colSpan={3}
|
||||
className="hidden pt-4 pl-6 pr-3 font-normal text-right sm:table-cell md:pl-0"
|
||||
>
|
||||
Tax
|
||||
</th>
|
||||
<th
|
||||
scope="row"
|
||||
className="pt-4 pr-3 font-normal text-left sm:hidden"
|
||||
>
|
||||
<Text>Tax</Text>
|
||||
</th>
|
||||
<td className="pt-4 pl-3 pr-4 text-right md:pr-3">
|
||||
<Money data={order.totalTaxV2!} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th
|
||||
scope="row"
|
||||
colSpan={3}
|
||||
className="hidden pt-4 pl-6 pr-3 font-semibold text-right sm:table-cell md:pl-0"
|
||||
>
|
||||
Total
|
||||
</th>
|
||||
<th
|
||||
scope="row"
|
||||
className="pt-4 pr-3 font-semibold text-left sm:hidden"
|
||||
>
|
||||
<Text>Total</Text>
|
||||
</th>
|
||||
<td className="pt-4 pl-3 pr-4 font-semibold text-right md:pr-3">
|
||||
<Money data={order.totalPriceV2!} />
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<div className="sticky border-none top-nav md:my-8">
|
||||
<Heading size="copy" className="font-semibold" as="h3">
|
||||
Shipping Address
|
||||
</Heading>
|
||||
{order?.shippingAddress ? (
|
||||
<ul className="mt-6">
|
||||
<li>
|
||||
<Text>
|
||||
{order.shippingAddress.firstName &&
|
||||
order.shippingAddress.firstName + ' '}
|
||||
{order.shippingAddress.lastName}
|
||||
</Text>
|
||||
</li>
|
||||
{order?.shippingAddress?.formatted ? (
|
||||
order.shippingAddress.formatted.map((line) => (
|
||||
<li key={line}>
|
||||
<Text>{line}</Text>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-3">No shipping address defined</p>
|
||||
)}
|
||||
<Heading size="copy" className="mt-8 font-semibold" as="h3">
|
||||
Status
|
||||
</Heading>
|
||||
<div
|
||||
className={`mt-3 px-3 py-1 text-xs font-medium rounded-full inline-block w-auto ${
|
||||
order.fulfillmentStatus === 'FULFILLED'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-primary/20 text-primary/50'
|
||||
}`}
|
||||
>
|
||||
<Text size="fine">
|
||||
{statusMessage(order.fulfillmentStatus!)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// @see: https://shopify.dev/api/storefront/2022-07/objects/Order#fields
|
||||
const ORDER_QUERY = gql`
|
||||
fragment Money 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 {
|
||||
... on MoneyV2 {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
... on PricingPercentageValue {
|
||||
percentage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment Image on Image {
|
||||
altText
|
||||
height
|
||||
src: url(transform: {crop: CENTER, maxHeight: 96, maxWidth: 96, scale: 2})
|
||||
id
|
||||
width
|
||||
}
|
||||
|
||||
fragment ProductVariant on ProductVariant {
|
||||
image {
|
||||
...Image
|
||||
}
|
||||
priceV2 {
|
||||
...Money
|
||||
}
|
||||
product {
|
||||
handle
|
||||
}
|
||||
sku
|
||||
title
|
||||
}
|
||||
|
||||
fragment LineItemFull on OrderLineItem {
|
||||
title
|
||||
quantity
|
||||
discountAllocations {
|
||||
allocatedAmount {
|
||||
...Money
|
||||
}
|
||||
discountApplication {
|
||||
...DiscountApplication
|
||||
}
|
||||
}
|
||||
originalTotalPrice {
|
||||
...Money
|
||||
}
|
||||
discountedTotalPrice {
|
||||
...Money
|
||||
}
|
||||
variant {
|
||||
...ProductVariant
|
||||
}
|
||||
}
|
||||
|
||||
query orderById(
|
||||
$customerAccessToken: String!
|
||||
$orderId: String!
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customer(customerAccessToken: $customerAccessToken) {
|
||||
orders(first: 1, query: $orderId) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
orderNumber
|
||||
processedAt
|
||||
fulfillmentStatus
|
||||
totalTaxV2 {
|
||||
...Money
|
||||
}
|
||||
totalPriceV2 {
|
||||
...Money
|
||||
}
|
||||
subtotalPriceV2 {
|
||||
...Money
|
||||
}
|
||||
shippingAddress {
|
||||
...AddressFull
|
||||
}
|
||||
discountApplications(first: 100) {
|
||||
nodes {
|
||||
...DiscountApplication
|
||||
}
|
||||
}
|
||||
lineItems(first: 100) {
|
||||
nodes {
|
||||
...LineItemFull
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
71
examples/hydrogen/src/routes/account/recover.server.tsx
Normal file
71
examples/hydrogen/src/routes/account/recover.server.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import {Suspense} from 'react';
|
||||
import {
|
||||
CacheNone,
|
||||
Seo,
|
||||
gql,
|
||||
type HydrogenRequest,
|
||||
type HydrogenApiRouteOptions,
|
||||
type HydrogenRouteProps,
|
||||
} from '@shopify/hydrogen';
|
||||
|
||||
import {AccountRecoverForm} from '~/components';
|
||||
import {Layout} from '~/components/index.server';
|
||||
|
||||
/**
|
||||
* A form for the user to fill out to _initiate_ a password reset.
|
||||
* If the form succeeds, an email will be sent to the user with a link
|
||||
* to reset their password. Clicking the link leads the user to the
|
||||
* page `/account/reset/[resetToken]`.
|
||||
*/
|
||||
export default function AccountRecover({response}: HydrogenRouteProps) {
|
||||
response.cache(CacheNone());
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Suspense>
|
||||
<Seo type="noindex" data={{title: 'Recover password'}} />
|
||||
</Suspense>
|
||||
<AccountRecoverForm />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export async function api(
|
||||
request: HydrogenRequest,
|
||||
{queryShop}: HydrogenApiRouteOptions,
|
||||
) {
|
||||
const jsonBody = await request.json();
|
||||
|
||||
if (!jsonBody.email || jsonBody.email === '') {
|
||||
return new Response(JSON.stringify({error: 'Email required'}), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await queryShop({
|
||||
query: CUSTOMER_RECOVER_MUTATION,
|
||||
variables: {
|
||||
email: jsonBody.email,
|
||||
},
|
||||
// @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
|
||||
cache: CacheNone(),
|
||||
});
|
||||
|
||||
// Ignore errors, we don't want to tell the user if the email was
|
||||
// valid or not, thereby allowing them to determine who uses the site
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
|
||||
const CUSTOMER_RECOVER_MUTATION = gql`
|
||||
mutation customerRecover($email: String!) {
|
||||
customerRecover(email: $email) {
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
95
examples/hydrogen/src/routes/account/register.server.tsx
Normal file
95
examples/hydrogen/src/routes/account/register.server.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import {Suspense} from 'react';
|
||||
import {
|
||||
CacheNone,
|
||||
Seo,
|
||||
gql,
|
||||
type HydrogenRequest,
|
||||
type HydrogenApiRouteOptions,
|
||||
type HydrogenRouteProps,
|
||||
} from '@shopify/hydrogen';
|
||||
|
||||
import {AccountCreateForm} from '~/components';
|
||||
import {Layout} from '~/components/index.server';
|
||||
import {getApiErrorMessage} from '~/lib/utils';
|
||||
|
||||
export default function Register({response}: HydrogenRouteProps) {
|
||||
response.cache(CacheNone());
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Suspense>
|
||||
<Seo type="noindex" data={{title: 'Register'}} />
|
||||
</Suspense>
|
||||
<AccountCreateForm />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export async function api(
|
||||
request: HydrogenRequest,
|
||||
{queryShop}: HydrogenApiRouteOptions,
|
||||
) {
|
||||
const jsonBody = await request.json();
|
||||
|
||||
if (
|
||||
!jsonBody.email ||
|
||||
jsonBody.email === '' ||
|
||||
!jsonBody.password ||
|
||||
jsonBody.password === ''
|
||||
) {
|
||||
return new Response(
|
||||
JSON.stringify({error: 'Email and password are required'}),
|
||||
{status: 400},
|
||||
);
|
||||
}
|
||||
|
||||
const {data, errors} = await queryShop<{customerCreate: any}>({
|
||||
query: CUSTOMER_CREATE_MUTATION,
|
||||
variables: {
|
||||
input: {
|
||||
email: jsonBody.email,
|
||||
password: jsonBody.password,
|
||||
firstName: jsonBody.firstName,
|
||||
lastName: jsonBody.lastName,
|
||||
},
|
||||
},
|
||||
// @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
|
||||
cache: CacheNone(),
|
||||
});
|
||||
|
||||
const errorMessage = getApiErrorMessage('customerCreate', data, errors);
|
||||
|
||||
if (
|
||||
!errorMessage &&
|
||||
data &&
|
||||
data.customerCreate &&
|
||||
data.customerCreate.customer &&
|
||||
data.customerCreate.customer.id
|
||||
) {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
});
|
||||
} else {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: errorMessage ?? 'Unknown error',
|
||||
}),
|
||||
{status: 401},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const CUSTOMER_CREATE_MUTATION = gql`
|
||||
mutation customerCreate($input: CustomerCreateInput!) {
|
||||
customerCreate(input: $input) {
|
||||
customer {
|
||||
id
|
||||
}
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,23 @@
|
||||
import {Suspense} from 'react';
|
||||
import {useRouteParams, Seo} from '@shopify/hydrogen';
|
||||
|
||||
import {AccountPasswordResetForm} from '~/components';
|
||||
import {Layout} from '~/components/index.server';
|
||||
|
||||
/**
|
||||
* This page shows a form for the user to enter a new password.
|
||||
* It should only be accessed by a link emailed to the user after
|
||||
* they initiate a password reset from `/account/recover`.
|
||||
*/
|
||||
export default function ResetPassword() {
|
||||
const {id, resetToken} = useRouteParams();
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Suspense>
|
||||
<Seo type="noindex" data={{title: 'Reset password'}} />
|
||||
</Suspense>
|
||||
<AccountPasswordResetForm id={id} resetToken={resetToken} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
87
examples/hydrogen/src/routes/account/reset/index.server.ts
Normal file
87
examples/hydrogen/src/routes/account/reset/index.server.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
CacheNone,
|
||||
gql,
|
||||
type HydrogenApiRouteOptions,
|
||||
type HydrogenRequest,
|
||||
} from '@shopify/hydrogen';
|
||||
import {getApiErrorMessage} from '~/lib/utils';
|
||||
|
||||
/**
|
||||
* This API route is used by the form on `/account/reset/[id]/[resetToken]`
|
||||
* complete the reset of the user's password.
|
||||
*/
|
||||
export async function api(
|
||||
request: HydrogenRequest,
|
||||
{session, queryShop}: HydrogenApiRouteOptions,
|
||||
) {
|
||||
if (!session) {
|
||||
return new Response('Session storage not available.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const jsonBody = await request.json();
|
||||
|
||||
if (
|
||||
!jsonBody.id ||
|
||||
jsonBody.id === '' ||
|
||||
!jsonBody.password ||
|
||||
jsonBody.password === '' ||
|
||||
!jsonBody.resetToken ||
|
||||
jsonBody.resetToken === ''
|
||||
) {
|
||||
return new Response(
|
||||
JSON.stringify({error: 'Incorrect password or reset token.'}),
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const {data, errors} = await queryShop<{customerReset: any}>({
|
||||
query: CUSTOMER_RESET_MUTATION,
|
||||
variables: {
|
||||
id: `gid://shopify/Customer/${jsonBody.id}`,
|
||||
input: {
|
||||
password: jsonBody.password,
|
||||
resetToken: jsonBody.resetToken,
|
||||
},
|
||||
},
|
||||
// @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
|
||||
cache: CacheNone(),
|
||||
});
|
||||
|
||||
if (data?.customerReset?.customerAccessToken?.accessToken) {
|
||||
await session.set(
|
||||
'customerAccessToken',
|
||||
data.customerReset.customerAccessToken.accessToken,
|
||||
);
|
||||
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
});
|
||||
} else {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: getApiErrorMessage('customerReset', data, errors),
|
||||
}),
|
||||
{status: 401},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const CUSTOMER_RESET_MUTATION = gql`
|
||||
mutation customerReset($id: ID!, $input: CustomerResetInput!) {
|
||||
customerReset(id: $id, input: $input) {
|
||||
customerAccessToken {
|
||||
accessToken
|
||||
expiresAt
|
||||
}
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
36
examples/hydrogen/src/routes/admin.server.tsx
Normal file
36
examples/hydrogen/src/routes/admin.server.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
useShopQuery,
|
||||
gql,
|
||||
CacheLong,
|
||||
type HydrogenRouteProps,
|
||||
} from '@shopify/hydrogen';
|
||||
import type {Shop} from '@shopify/hydrogen/storefront-api-types';
|
||||
|
||||
/*
|
||||
This route redirects you to your Shopify Admin
|
||||
by querying for your myshopify.com domain.
|
||||
Learn more about the redirect method here:
|
||||
https://developer.mozilla.org/en-US/docs/Web/API/Response/redirect
|
||||
*/
|
||||
|
||||
export default function AdminRedirect({response}: HydrogenRouteProps) {
|
||||
const {data} = useShopQuery<{
|
||||
shop: Shop;
|
||||
}>({
|
||||
query: SHOP_QUERY,
|
||||
cache: CacheLong(),
|
||||
});
|
||||
|
||||
const {url} = data.shop.primaryDomain;
|
||||
return response.redirect(`${url}/admin`);
|
||||
}
|
||||
|
||||
const SHOP_QUERY = gql`
|
||||
query {
|
||||
shop {
|
||||
primaryDomain {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
37
examples/hydrogen/src/routes/api/bestSellers.server.ts
Normal file
37
examples/hydrogen/src/routes/api/bestSellers.server.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {gql} from '@shopify/hydrogen';
|
||||
import type {HydrogenApiRouteOptions, HydrogenRequest} from '@shopify/hydrogen';
|
||||
import {ProductConnection} from '@shopify/hydrogen/storefront-api-types';
|
||||
import {PRODUCT_CARD_FRAGMENT} from '~/lib/fragments';
|
||||
|
||||
export async function api(
|
||||
_request: HydrogenRequest,
|
||||
{queryShop}: HydrogenApiRouteOptions,
|
||||
) {
|
||||
const {
|
||||
data: {products},
|
||||
} = await queryShop<{
|
||||
products: ProductConnection;
|
||||
}>({
|
||||
query: TOP_PRODUCTS_QUERY,
|
||||
variables: {
|
||||
count: 4,
|
||||
},
|
||||
});
|
||||
|
||||
return products.nodes;
|
||||
}
|
||||
|
||||
const TOP_PRODUCTS_QUERY = gql`
|
||||
${PRODUCT_CARD_FRAGMENT}
|
||||
query topProducts(
|
||||
$count: Int
|
||||
$countryCode: CountryCode
|
||||
$languageCode: LanguageCode
|
||||
) @inContext(country: $countryCode, language: $languageCode) {
|
||||
products(first: $count, sortKey: BEST_SELLING) {
|
||||
nodes {
|
||||
...ProductCard
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user