mirror of
https://github.com/LukeHagar/vercel.git
synced 2025-12-31 11:49:10 +00:00
Compare commits
302 Commits
@vercel/ru
...
add/hydrog
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
955ce93c28 | ||
|
|
e258caf345 | ||
|
|
2661f56347 | ||
|
|
b1c14cde03 | ||
|
|
8dd6d021df | ||
|
|
88ec6e69d6 | ||
|
|
a6f2e7b136 | ||
|
|
e906365909 | ||
|
|
7b01a07394 | ||
|
|
ce4633fe4d | ||
|
|
fdf86fda03 | ||
|
|
56178e6a46 | ||
|
|
5439d7c0c9 | ||
|
|
c670e51712 | ||
|
|
b56639b624 | ||
|
|
12bbae098c | ||
|
|
9969f0ba18 | ||
|
|
24e1e3c3be | ||
|
|
b61674cb2d | ||
|
|
cae60155f3 | ||
|
|
a91bde5287 | ||
|
|
0750517af9 | ||
|
|
70f6782954 | ||
|
|
5f1e37ee16 | ||
|
|
680d666fdc | ||
|
|
06a5dccfed | ||
|
|
f85df894c0 | ||
|
|
e9ec779f1c | ||
|
|
4333d1e6b2 | ||
|
|
493a31091d | ||
|
|
8d7206f5b6 | ||
|
|
4bf2ca55ff | ||
|
|
5e5332fbc9 | ||
|
|
281ec776a5 | ||
|
|
ee8f9292b4 | ||
|
|
027bce00b3 | ||
|
|
ca1f41200a | ||
|
|
834b3e652b | ||
|
|
5c6941d18c | ||
|
|
761ede2482 | ||
|
|
1d01703dc3 | ||
|
|
7021279b28 | ||
|
|
28acf50bdf | ||
|
|
718bbd365a | ||
|
|
7e791ee080 | ||
|
|
13769106cb | ||
|
|
aa734efc6c | ||
|
|
acc10e47c7 | ||
|
|
6a0e1e0b66 | ||
|
|
3468922108 | ||
|
|
e4895d979b | ||
|
|
733ff5ed85 | ||
|
|
0867f11a6a | ||
|
|
a6de052ed2 | ||
|
|
f5d48ec3bc | ||
|
|
6ec1180798 | ||
|
|
36db62a491 | ||
|
|
f9266e069f | ||
|
|
8163a153df | ||
|
|
c5e6bd1a7a | ||
|
|
c2f1bebd1f | ||
|
|
3138415533 | ||
|
|
8f6813bb63 | ||
|
|
734499fc03 | ||
|
|
f06988d914 | ||
|
|
71ac16220b | ||
|
|
8b3a4146af | ||
|
|
cfea31e6cf | ||
|
|
7090fec110 | ||
|
|
bc7c80fb9b | ||
|
|
91406abdb0 | ||
|
|
a5af8381ce | ||
|
|
2230ea6cc1 | ||
|
|
f057f0421b | ||
|
|
42c0b32a8d | ||
|
|
d61a1a7988 | ||
|
|
c438bbb362 | ||
|
|
cb5eef0eb5 | ||
|
|
79dee367cf | ||
|
|
dea58dea7e | ||
|
|
fecebfa7fa | ||
|
|
94d5612dce | ||
|
|
3eaf58bb74 | ||
|
|
e63cf40153 | ||
|
|
709c9509f4 | ||
|
|
6107c1ed22 | ||
|
|
7a0f377afe | ||
|
|
a04bf557fc | ||
|
|
b6736e82cf | ||
|
|
7923056bc0 | ||
|
|
71ff193ea3 | ||
|
|
c21d93de44 | ||
|
|
0039c8b5ce | ||
|
|
49c7178567 | ||
|
|
b038b29614 | ||
|
|
7a249a2284 | ||
|
|
a5e32ec31d | ||
|
|
bc5afe24c4 | ||
|
|
5070e3bbbd | ||
|
|
4ad1cbbd7d | ||
|
|
4f4e09477d | ||
|
|
cd35071f60 | ||
|
|
f373c94508 | ||
|
|
553c001eb0 | ||
|
|
f6c3a95783 | ||
|
|
c0bcef0ca4 | ||
|
|
4cd77608e8 | ||
|
|
e6b2980eba | ||
|
|
67e20a6ede | ||
|
|
c63679ea0a | ||
|
|
4280166df4 | ||
|
|
18ae78137c | ||
|
|
ebe4058073 | ||
|
|
942e76840e | ||
|
|
57515d2d07 | ||
|
|
ef30a46c03 | ||
|
|
113b8ac87b | ||
|
|
b56ac2717d | ||
|
|
aa8957ab10 | ||
|
|
c6c19354e8 | ||
|
|
96b2502133 | ||
|
|
2df0262675 | ||
|
|
1e47bbf32f | ||
|
|
00813a3945 | ||
|
|
a73ec6343f | ||
|
|
4bd70d4b6e | ||
|
|
c7bcea4081 | ||
|
|
aab95532d6 | ||
|
|
1b18c853c2 | ||
|
|
1663db7ca3 | ||
|
|
e80247fb99 | ||
|
|
a19edc985b | ||
|
|
4fd593ac09 | ||
|
|
1b0d72aba5 | ||
|
|
c52a59809e | ||
|
|
cdf55b3b1a | ||
|
|
8de42e0a70 | ||
|
|
7ff321310f | ||
|
|
2da72bc5e4 | ||
|
|
46950633f4 | ||
|
|
44b1dfe7c5 | ||
|
|
0278c7b7b9 | ||
|
|
761db597be | ||
|
|
671e63e7b8 | ||
|
|
f7bdc6cc26 | ||
|
|
e94a153b2f | ||
|
|
74e639a772 | ||
|
|
f00b08a820 | ||
|
|
6cdd38d130 | ||
|
|
2c950d47ae | ||
|
|
71b9f3a94b | ||
|
|
91b7f6dcd9 | ||
|
|
ba10fb4dd4 | ||
|
|
18c1c45ce3 | ||
|
|
67e556bc80 | ||
|
|
7235000181 | ||
|
|
5124d431ea | ||
|
|
c879401bbc | ||
|
|
6c6f3ce9d2 | ||
|
|
aa83680832 | ||
|
|
06113d3e39 | ||
|
|
5150f21404 | ||
|
|
eb6bb98406 | ||
|
|
d8e3b6e738 | ||
|
|
25da051d62 | ||
|
|
f57af66dc2 | ||
|
|
b2f71d5352 | ||
|
|
6115f0d74a | ||
|
|
14c877e468 | ||
|
|
d80732d74f | ||
|
|
b739c1845c | ||
|
|
e9f0fcf397 | ||
|
|
924a20a0fc | ||
|
|
fc3b74d06f | ||
|
|
380ed38c71 | ||
|
|
e228cdd373 | ||
|
|
9bd92535d6 | ||
|
|
5825e30700 | ||
|
|
6d5983eaae | ||
|
|
2fd59a3b5a | ||
|
|
d1d3e9384d | ||
|
|
8428632eb1 | ||
|
|
fa443035f6 | ||
|
|
de2c7e1633 | ||
|
|
75d2435138 | ||
|
|
5328bb69e2 | ||
|
|
e3fe368baa | ||
|
|
99832587c5 | ||
|
|
47d0d4f84a | ||
|
|
aba54ee6cf | ||
|
|
cd7d3ef1c5 | ||
|
|
c4915507aa | ||
|
|
6c02ae9e36 | ||
|
|
672c1681cc | ||
|
|
7a0dacf8bb | ||
|
|
8088b07abf | ||
|
|
5ee4acb23b | ||
|
|
ff0978593d | ||
|
|
678e13af53 | ||
|
|
ac2457494a | ||
|
|
8b9a8c0986 | ||
|
|
603a7d8787 | ||
|
|
9240cbc022 | ||
|
|
da046d85de | ||
|
|
f8749e1ff1 | ||
|
|
ec7b1cf80d | ||
|
|
85947b658e | ||
|
|
7ab86a94fb | ||
|
|
b5db13c970 | ||
|
|
473f62d141 | ||
|
|
cc140d6e28 | ||
|
|
09ac96ae21 | ||
|
|
ebd1bf41b6 | ||
|
|
e1a16b0333 | ||
|
|
ed56822675 | ||
|
|
ce597ce0ec | ||
|
|
2de365f9cf | ||
|
|
fb6fe46158 | ||
|
|
bfebfc28e1 | ||
|
|
9e183b2e8d | ||
|
|
0490a7733b | ||
|
|
65a6e713c8 | ||
|
|
fda76691d1 | ||
|
|
007e48c1cd | ||
|
|
954536e681 | ||
|
|
c52220ab72 | ||
|
|
545f82603f | ||
|
|
032df14c79 | ||
|
|
604f3b8d83 | ||
|
|
c0380cac5a | ||
|
|
7082da8451 | ||
|
|
20a7b2f2d4 | ||
|
|
a5c3cbcd45 | ||
|
|
6dded87426 | ||
|
|
a63b9d960b | ||
|
|
3cefbb19fe | ||
|
|
f43894ef4e | ||
|
|
2081f10b22 | ||
|
|
b52df7a533 | ||
|
|
ec89436417 | ||
|
|
993a404311 | ||
|
|
3e7bcb2073 | ||
|
|
ed119d6a33 | ||
|
|
2aeddde7d5 | ||
|
|
a8eab6beb7 | ||
|
|
031c182896 | ||
|
|
38eb0bca04 | ||
|
|
732ac2072c | ||
|
|
64d9cef963 | ||
|
|
6b9e274bc7 | ||
|
|
a2da071755 | ||
|
|
14ece4111a | ||
|
|
e302631ded | ||
|
|
43a57a3a60 | ||
|
|
136077ab6f | ||
|
|
8634f9cd7e | ||
|
|
dc8523998c | ||
|
|
925c8ba18c | ||
|
|
19804b78a9 | ||
|
|
f774d5f7a2 | ||
|
|
9bf8a68685 | ||
|
|
294935a9c5 | ||
|
|
778c287964 | ||
|
|
7a9a519517 | ||
|
|
52b2892b80 | ||
|
|
ee4ba6ccbe | ||
|
|
07a09b7880 | ||
|
|
b62c40586a | ||
|
|
d07eb34e38 | ||
|
|
46d0503361 | ||
|
|
349df907a8 | ||
|
|
e5f5ba0ae8 | ||
|
|
b2ccbf4881 | ||
|
|
434c794713 | ||
|
|
d8f5052d9a | ||
|
|
10d14488db | ||
|
|
c29f2b2fbd | ||
|
|
46348201b4 | ||
|
|
509926545e | ||
|
|
70c8b32cf0 | ||
|
|
54514a44af | ||
|
|
0db8fadf74 | ||
|
|
6f01e5ab75 | ||
|
|
78d45f9e7e | ||
|
|
22e1a6a9ce | ||
|
|
8391734b5e | ||
|
|
6a7fa1526c | ||
|
|
64b15d2409 | ||
|
|
40f73e7978 | ||
|
|
4c9ca27195 | ||
|
|
a847ef43fd | ||
|
|
3b466232a9 | ||
|
|
efdeea9db2 | ||
|
|
ab9915af32 | ||
|
|
d6dc27638c | ||
|
|
0fb0601d19 | ||
|
|
ce25dec97d | ||
|
|
20bd71ce70 | ||
|
|
4c77dab5cb | ||
|
|
151b0dfb63 | ||
|
|
42e9bbea5b | ||
|
|
c0471302e9 |
8
.changeset/README.md
Normal file
8
.changeset/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Changesets
|
||||
|
||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
|
||||
|
||||
We have a quick list of common questions to get you started engaging with this project in
|
||||
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
|
||||
14
.changeset/config.json
Normal file
14
.changeset/config.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
|
||||
"changelog": [
|
||||
"@svitejs/changesets-changelog-github-compact",
|
||||
{ "repo": "vercel/vercel" }
|
||||
],
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": []
|
||||
}
|
||||
@@ -26,12 +26,6 @@ packages/hydrogen/edge-entry.js
|
||||
packages/next/test/integration/middleware
|
||||
packages/next/test/integration/middleware-eval
|
||||
|
||||
# node-bridge
|
||||
packages/node-bridge/bridge.js
|
||||
packages/node-bridge/launcher.js
|
||||
packages/node-bridge/helpers.js
|
||||
packages/node-bridge/source-map-support.js
|
||||
|
||||
# middleware
|
||||
packages/middleware/src/entries.js
|
||||
|
||||
|
||||
8
.github/CODEOWNERS
vendored
8
.github/CODEOWNERS
vendored
@@ -1,16 +1,20 @@
|
||||
# Documentation
|
||||
# https://help.github.com/en/articles/about-code-owners
|
||||
|
||||
# Restricted Paths
|
||||
* @TooTallNate @EndangeredMassa @styfle @cb1kenobi @Ethan-Arrowood
|
||||
/.github/workflows @TooTallNate @EndangeredMassa @styfle @cb1kenobi @Ethan-Arrowood @ijjk
|
||||
/packages/fs-detectors @TooTallNate @EndangeredMassa @styfle @cb1kenobi @Ethan-Arrowood @agadzik @chloetedder
|
||||
/packages/node-bridge @TooTallNate @EndangeredMassa @styfle @cb1kenobi @Ethan-Arrowood @ijjk
|
||||
/packages/next @TooTallNate @EndangeredMassa @styfle @cb1kenobi @Ethan-Arrowood @ijjk
|
||||
/packages/routing-utils @TooTallNate @EndangeredMassa @styfle @cb1kenobi @Ethan-Arrowood @ijjk
|
||||
/packages/edge @vercel/edge-compute
|
||||
/packages/edge @vercel/compute
|
||||
/examples @leerob
|
||||
/examples/create-react-app @Timer
|
||||
/examples/nextjs @timneutkens @ijjk @styfle
|
||||
/examples/hugo @styfle
|
||||
/examples/jekyll @styfle
|
||||
/examples/zola @styfle
|
||||
/packages/node @TooTallNate @EndangeredMassa @styfle @cb1kenobi @Ethan-Arrowood @Kikobeats
|
||||
|
||||
# Unrestricted Paths
|
||||
.changeset/
|
||||
|
||||
1
.github/CONTRIBUTING.md
vendored
1
.github/CONTRIBUTING.md
vendored
@@ -15,7 +15,6 @@ git clone https://github.com/vercel/vercel
|
||||
cd vercel
|
||||
corepack enable
|
||||
pnpm install
|
||||
pnpm bootstrap
|
||||
pnpm build
|
||||
pnpm lint
|
||||
pnpm test-unit
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
- cron: '0 0 * * 0'
|
||||
|
||||
jobs:
|
||||
create-pull-request:
|
||||
update-gatsby-fixtures:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -16,9 +16,12 @@ jobs:
|
||||
# 0 means fetch all commits so we can commit and push in the script below
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 14
|
||||
- name: Enable corepack
|
||||
run: corepack enable pnpm
|
||||
- name: Create Pull Request
|
||||
- name: Update Gatsby Fixtures
|
||||
uses: actions/github-script@v6
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
66
.github/workflows/publish.yml
vendored
66
.github/workflows/publish.yml
vendored
@@ -1,66 +0,0 @@
|
||||
name: Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- '!*'
|
||||
|
||||
env:
|
||||
TURBO_REMOTE_ONLY: 'true'
|
||||
TURBO_TEAM: 'vercel'
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check Release
|
||||
id: check-release
|
||||
run: |
|
||||
tag="$(git describe --tags --exact-match 2> /dev/null || :)"
|
||||
if [[ -z "$tag" ]];
|
||||
then
|
||||
echo "IS_RELEASE=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "IS_RELEASE=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- name: Setup Go
|
||||
if: ${{ steps.check-release.outputs.IS_RELEASE == 'true' }}
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: '1.13.15'
|
||||
- name: Setup Node
|
||||
if: ${{ steps.check-release.outputs.IS_RELEASE == 'true' }}
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
- name: install npm@9
|
||||
run: npm i -g npm@9
|
||||
- name: install pnpm@7.24.2
|
||||
run: npm i -g pnpm@7.24.2
|
||||
- name: Install
|
||||
if: ${{ steps.check-release.outputs.IS_RELEASE == 'true' }}
|
||||
run: pnpm install
|
||||
- name: Build
|
||||
if: ${{ steps.check-release.outputs.IS_RELEASE == 'true' }}
|
||||
run: pnpm build
|
||||
env:
|
||||
GA_TRACKING_ID: ${{ secrets.GA_TRACKING_ID }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
- name: Publish
|
||||
if: ${{ steps.check-release.outputs.IS_RELEASE == 'true' }}
|
||||
run: pnpm publish-from-github
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN_ELEVATED }}
|
||||
GA_TRACKING_ID: ${{ secrets.GA_TRACKING_ID }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
78
.github/workflows/release.yml
vendored
Normal file
78
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
TURBO_REMOTE_ONLY: 'true'
|
||||
TURBO_TEAM: 'vercel'
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Fetch git tags
|
||||
run: git fetch origin 'refs/tags/*:refs/tags/*'
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- name: install npm@9
|
||||
run: npm i -g npm@9
|
||||
|
||||
- name: install pnpm@8.3.1
|
||||
run: npm i -g pnpm@8.3.1
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Packages
|
||||
run: pnpm build
|
||||
env:
|
||||
GA_TRACKING_ID: ${{ secrets.GA_TRACKING_ID }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
|
||||
- name: Create Release Pull Request or Publish to npm
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
version: pnpm ci:version
|
||||
publish: pnpm ci:publish
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN_PULL_REQUESTS }}
|
||||
NPM_CONFIG_PROVENANCE: 'true'
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN_ELEVATED }}
|
||||
GA_TRACKING_ID: ${{ secrets.GA_TRACKING_ID }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
|
||||
- name: Trigger Update (if a Publish Happened)
|
||||
if: steps.changesets.outputs.published == 'true'
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN_PULL_REQUESTS }}
|
||||
script: |
|
||||
const script = require('./utils/trigger-update-workflow.js')
|
||||
await script({ github, context })
|
||||
|
||||
- name: Set latest Release to `vercel` (if a Publish Happened)
|
||||
if: steps.changesets.outputs.published == 'true'
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN_PULL_REQUESTS }}
|
||||
script: |
|
||||
const script = require('./utils/update-latest-release.js')
|
||||
await script({ github, context })
|
||||
26
.github/workflows/required-pr-label.yml
vendored
26
.github/workflows/required-pr-label.yml
vendored
@@ -1,26 +0,0 @@
|
||||
name: Required PR Label
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, labeled, unlabeled, synchronize]
|
||||
jobs:
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check PR Labels
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
let missing = false;
|
||||
const labels = context.payload.pull_request.labels.map(l => l.name);
|
||||
if (labels.filter(l => l.startsWith('area:')).length === 0) {
|
||||
console.error('::error::Missing label: Please add at least one "area" label.');
|
||||
missing = true;
|
||||
}
|
||||
if (labels.filter(l => l.startsWith('semver:')).length !== 1) {
|
||||
console.error('::error::Missing label: Please add exactly one "semver" label.');
|
||||
missing = true;
|
||||
}
|
||||
if (missing) {
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('::notice::Success: This pull request has correct labels, thanks!');
|
||||
44
.github/workflows/test-integration-cli.yml
vendored
44
.github/workflows/test-integration-cli.yml
vendored
@@ -1,44 +0,0 @@
|
||||
name: CLI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- '!*'
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
TURBO_REMOTE_ONLY: 'true'
|
||||
TURBO_TEAM: 'vercel'
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: CLI
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
node: [16]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
- name: install pnpm@7.24.2
|
||||
run: npm i -g pnpm@7.24.2
|
||||
- run: pnpm install
|
||||
- run: pnpm run build
|
||||
- run: pnpm test-cli
|
||||
env:
|
||||
VERCEL_TEST_TOKEN: ${{ secrets.VERCEL_TEST_TOKEN }}
|
||||
VERCEL_TEST_REGISTRATION_URL: ${{ secrets.VERCEL_TEST_REGISTRATION_URL }}
|
||||
20
.github/workflows/test-lint.yml
vendored
20
.github/workflows/test-lint.yml
vendored
@@ -19,6 +19,22 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
enforce-changeset:
|
||||
name: Enforce Changeset
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.title != 'Version Packages'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
- run: git checkout ${{ github.event.pull_request.head.ref }}
|
||||
- name: install pnpm@8.3.1
|
||||
run: npm i -g pnpm@8.3.1
|
||||
- run: pnpm install
|
||||
# Enforce a changeset file to be present
|
||||
- run: pnpm exec changeset status --since=main
|
||||
|
||||
test:
|
||||
name: Lint
|
||||
timeout-minutes: 10
|
||||
@@ -28,8 +44,8 @@ jobs:
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- name: install pnpm@7.24.2
|
||||
run: npm i -g pnpm@7.24.2
|
||||
- name: install pnpm@8.3.1
|
||||
run: npm i -g pnpm@8.3.1
|
||||
- run: pnpm install
|
||||
- run: pnpm run lint
|
||||
- run: pnpm run prettier-check
|
||||
|
||||
38
.github/workflows/test.yml
vendored
38
.github/workflows/test.yml
vendored
@@ -32,8 +32,8 @@ jobs:
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- name: install pnpm@7.24.2
|
||||
run: npm i -g pnpm@7.24.2
|
||||
- name: install pnpm@8.3.1
|
||||
run: npm i -g pnpm@8.3.1
|
||||
- run: pnpm install
|
||||
- id: set-tests
|
||||
run: |
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
echo "Files to test:"
|
||||
echo "$TESTS_ARRAY"
|
||||
echo "tests=$TESTS_ARRAY" >> $GITHUB_OUTPUT
|
||||
- uses: patrickedqvist/wait-for-vercel-preview@ae34b392ef30297f2b672f9afb3c329bde9bd487
|
||||
- uses: patrickedqvist/wait-for-vercel-preview@bfdff514ff78a669f2536e9f4dd4ef5813a704a2
|
||||
id: waitForTarball
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -71,11 +71,15 @@ jobs:
|
||||
if: matrix.runner == 'macos-latest'
|
||||
run: curl -L -O https://github.com/gohugoio/hugo/releases/download/v0.56.0/hugo_0.56.0_macOS-64bit.tar.gz && tar -xzf hugo_0.56.0_macOS-64bit.tar.gz && mv ./hugo packages/cli/test/dev/fixtures/08-hugo/
|
||||
|
||||
- name: install pnpm@7.24.2
|
||||
run: npm i -g pnpm@7.24.2
|
||||
- name: install pnpm@8.3.1
|
||||
run: npm i -g pnpm@8.3.1
|
||||
|
||||
- run: pnpm install
|
||||
|
||||
- name: fetch ssl certificate before build (linux, os x)
|
||||
if: matrix.runner != 'windows-latest'
|
||||
run: echo | openssl s_client -showcerts -servername 'api.vercel.com' -connect 76.76.21.21:443
|
||||
|
||||
- name: Build ${{matrix.packageName}} and all its dependencies
|
||||
run: node utils/gen.js && node_modules/.bin/turbo run build --cache-dir=".turbo" --scope=${{matrix.packageName}} --include-dependencies --no-deps
|
||||
env:
|
||||
@@ -89,11 +93,25 @@ jobs:
|
||||
VERCEL_TEST_REGISTRATION_URL: ${{ secrets.VERCEL_TEST_REGISTRATION_URL }}
|
||||
FORCE_COLOR: '1'
|
||||
|
||||
conclusion:
|
||||
- name: fetch ssl certificate after tests (linux, os x)
|
||||
if: matrix.runner != 'windows-latest'
|
||||
run: echo | openssl s_client -showcerts -servername 'api.vercel.com' -connect 76.76.21.21:443
|
||||
|
||||
summary:
|
||||
name: Summary
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
if: always()
|
||||
needs:
|
||||
- test
|
||||
runs-on: ubuntu-latest
|
||||
name: E2E
|
||||
steps:
|
||||
- name: Done
|
||||
run: echo "Done."
|
||||
- name: Check All
|
||||
run: |-
|
||||
for status in ${{ join(needs.*.result, ' ') }}
|
||||
do
|
||||
if [ "$status" != "success" ] && [ "$status" != "skipped" ]
|
||||
then
|
||||
echo "Some checks failed"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -12,6 +12,7 @@ packages/gatsby-plugin-vercel-analytics
|
||||
node_modules
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
.changeset
|
||||
.vscode
|
||||
.DS_Store
|
||||
.next
|
||||
@@ -31,4 +32,4 @@ packages/**/test/fixtures
|
||||
packages/**/test/dev/fixtures
|
||||
packages/**/test/build-fixtures
|
||||
packages/**/test/cache-fixtures
|
||||
|
||||
packages/cli/src/util/dev/templates/*.ts
|
||||
|
||||
@@ -19,9 +19,7 @@
|
||||
|
||||
## Vercel
|
||||
|
||||
Vercel is the platform for frontend developers, providing the speed and reliability innovators need to create at the moment of inspiration.
|
||||
|
||||
We enable teams to iterate quickly and develop, preview, and ship delightful user experiences. Vercel has zero-configuration support for 35+ frontend frameworks and integrates with your headless content, commerce, or database of choice.
|
||||
Vercel's frontend cloud gives developers frameworks, workflows, and infrastructure to build a faster, more personalized web.
|
||||
|
||||
## Deploy
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "16.18.11",
|
||||
"@types/node-fetch": "2.5.4",
|
||||
"@vercel/node": "*",
|
||||
"typescript": "4.3.4"
|
||||
"@vercel/node": "*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
You ran `vercel dev` inside a project that contains a `vercel.json` file with `env` or `build.env` properties that use [Vercel Secrets](https://vercel.com/docs/concepts/projects/environment-variables).
|
||||
|
||||
In order to use environment variables in your project locally that have values defined using the Vercel Secrets format (e.g. `@my-secret-value`), you will need to provide the value as an environment variable using a `.env`.
|
||||
In order to use environment variables in your project locally that have values defined using the Vercel Secrets format (e.g. `@my-secret-value`), you will need to provide the value as an environment variable using a `.env.local`.
|
||||
|
||||
We require this to ensure your app works as you intend it to, in the development environment, and to provide you with a way to mirror or separate private environment variables within your applications, for example when connecting to a database.
|
||||
|
||||
@@ -12,11 +12,11 @@ Read below for how to address this error.
|
||||
|
||||
#### Possible Ways to Fix It
|
||||
|
||||
The error message will list environment variables that are required and which file they are required to be included in `.env`.
|
||||
The error message will list environment variables that are required and which file they are required to be included in `.env.local`.
|
||||
|
||||
If the file does not exist yet, please create the file that the error message mentions and insert the missing environment variable into it.
|
||||
|
||||
For example, if the error message shows that the environment variable `TEST` is missing from `.env`, then the `.env` file should look like this:
|
||||
For example, if the error message shows that the environment variable `TEST` is missing from `.env.local`, then the `.env.local` file should look like this:
|
||||
|
||||
```
|
||||
TEST=value
|
||||
|
||||
4
examples/__tests__/integration/storybook.test.ts
Normal file
4
examples/__tests__/integration/storybook.test.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { deployExample } from '../test-utils';
|
||||
it('should deploy', async () => {
|
||||
await deployExample(__filename);
|
||||
});
|
||||
@@ -1,6 +1,13 @@
|
||||
# Astro
|
||||
|
||||
This directory is a brief example of an [Astro](https://astro.build/) site that can be deployed to Vercel with zero configuration.
|
||||
This directory is a brief example of an [Astro](https://astro.build/) site that can be deployed to Vercel with zero configuration. This demo showcases:
|
||||
|
||||
- `/` - A static page (pre-rendered)
|
||||
- `/ssr` - A page that uses server-side rendering (through Vercel Edge Functions)
|
||||
- `/ssr-with-swr-caching` - Similar to the previous page, but also caches the response on the Vercel Edge Network using `cache-control` headers
|
||||
- `/edge.json` - An Astro API Endpoint that returns JSON data using Vercel Edge Functions
|
||||
|
||||
Learn more about [Astro on Vercel](https://vercel.com/docs/frameworks/astro).
|
||||
|
||||
## Deploy Your Own
|
||||
|
||||
@@ -12,21 +19,7 @@ _Live Example: https://astro-template.vercel.app_
|
||||
|
||||
## Project Structure
|
||||
|
||||
Inside of your Astro project, you'll see the following folders and files:
|
||||
|
||||
```
|
||||
/
|
||||
├── public/
|
||||
│ └── favicon.ico
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ └── Layout.astro
|
||||
│ └── pages/
|
||||
│ └── index.astro
|
||||
└── package.json
|
||||
```
|
||||
|
||||
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||
Astro looks for `.astro`, `.md`, or `.js` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||
|
||||
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components or layouts.
|
||||
|
||||
@@ -42,5 +35,6 @@ All commands are run from the root of the project, from a terminal:
|
||||
| `pnpm run dev` | Starts local dev server at `localhost:3000` |
|
||||
| `pnpm run build` | Build your production site to `./dist/` |
|
||||
| `pnpm run preview` | Preview your build locally, before deploying |
|
||||
| `pnpm run start` | Starts a production dev server at `localhost:3000` |
|
||||
| `pnpm run astro ...` | Run CLI commands like `astro add`, `astro preview` |
|
||||
| `pnpm run astro --help` | Get help using the Astro CLI |
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
import vercel from '@astrojs/vercel/edge';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({});
|
||||
export default defineConfig({
|
||||
output: 'server',
|
||||
adapter: vercel(),
|
||||
});
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"devDependencies": {
|
||||
"astro": "^2.0.6",
|
||||
"web-vitals": "^3.1.1"
|
||||
"dependencies": {
|
||||
"@astrojs/vercel": "3.2.2",
|
||||
"astro": "^2.2.1",
|
||||
"react": "18.2.0",
|
||||
"web-vitals": "^3.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
2953
examples/astro/pnpm-lock.yaml
generated
2953
examples/astro/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,7 @@ export function sendToAnalytics(metric, options) {
|
||||
};
|
||||
|
||||
if (options.debug) {
|
||||
console.log("[Analytics]", metric.name, JSON.stringify(body, null, 2));
|
||||
console.log("[Web Vitals]", metric.name, JSON.stringify(body, null, 2));
|
||||
}
|
||||
|
||||
const blob = new Blob([new URLSearchParams(body).toString()], {
|
||||
@@ -61,6 +61,6 @@ export function webVitals(options) {
|
||||
onCLS((metric) => sendToAnalytics(metric, options));
|
||||
onFCP((metric) => sendToAnalytics(metric, options));
|
||||
} catch (err) {
|
||||
console.error("[Analytics]", err);
|
||||
console.error("[Web Vitals]", err);
|
||||
}
|
||||
}
|
||||
|
||||
9
examples/astro/src/pages/edge.json.js
Normal file
9
examples/astro/src/pages/edge.json.js
Normal file
@@ -0,0 +1,9 @@
|
||||
export async function get() {
|
||||
return new Response(JSON.stringify({ time: new Date() }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 's-maxage=10, stale-while-revalidate',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import Card from '../components/Card.astro';
|
||||
|
||||
export const prerender = true;
|
||||
---
|
||||
|
||||
<Layout title="Welcome to Astro.">
|
||||
|
||||
7
examples/astro/src/pages/ssr-with-swr-caching.astro
Normal file
7
examples/astro/src/pages/ssr-with-swr-caching.astro
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
Astro.response.headers.set('Cache-Control', 's-maxage=10, stale-while-revalidate');
|
||||
|
||||
const time = new Date().toLocaleTimeString();
|
||||
---
|
||||
|
||||
<h1>{time}</h1>
|
||||
5
examples/astro/src/pages/ssr.astro
Normal file
5
examples/astro/src/pages/ssr.astro
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
const time = new Date().toLocaleTimeString();
|
||||
---
|
||||
|
||||
<h1>{time}</h1>
|
||||
5
examples/hydrogen-2/.eslintignore
Normal file
5
examples/hydrogen-2/.eslintignore
Normal file
@@ -0,0 +1,5 @@
|
||||
build
|
||||
node_modules
|
||||
bin
|
||||
*.d.ts
|
||||
dist
|
||||
18
examples/hydrogen-2/.eslintrc.js
Normal file
18
examples/hydrogen-2/.eslintrc.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @type {import("@types/eslint").Linter.BaseConfig}
|
||||
*/
|
||||
module.exports = {
|
||||
extends: [
|
||||
'@remix-run/eslint-config',
|
||||
'plugin:hydrogen/recommended',
|
||||
'plugin:hydrogen/typescript',
|
||||
],
|
||||
rules: {
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/naming-convention': 'off',
|
||||
'hydrogen/prefer-image-component': 'off',
|
||||
'no-useless-escape': 'off',
|
||||
'@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
|
||||
'no-case-declarations': 'off',
|
||||
},
|
||||
};
|
||||
9
examples/hydrogen-2/.gitignore
vendored
Normal file
9
examples/hydrogen-2/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
/.cache
|
||||
/build
|
||||
/dist
|
||||
/public/build
|
||||
/.mf
|
||||
.env
|
||||
.shopify
|
||||
.vercel
|
||||
1
examples/hydrogen-2/.graphqlrc.yml
Normal file
1
examples/hydrogen-2/.graphqlrc.yml
Normal file
@@ -0,0 +1 @@
|
||||
schema: node_modules/@shopify/hydrogen-react/storefront.schema.json
|
||||
40
examples/hydrogen-2/README.md
Normal file
40
examples/hydrogen-2/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Hydrogen template: Skeleton
|
||||
|
||||
Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen.
|
||||
|
||||
[Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen)
|
||||
[Get familiar with Remix](https://remix.run/docs/en/v1)
|
||||
|
||||
## What's included
|
||||
|
||||
- Remix
|
||||
- Hydrogen
|
||||
- Oxygen
|
||||
- Shopify CLI
|
||||
- ESLint
|
||||
- Prettier
|
||||
- GraphQL generator
|
||||
- TypeScript and JavaScript flavors
|
||||
- Minimal setup of components and routes
|
||||
|
||||
## Getting started
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- Node.js version 16.14.0 or higher
|
||||
|
||||
```bash
|
||||
npm create @shopify/hydrogen@latest
|
||||
```
|
||||
|
||||
## Building for production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
47
examples/hydrogen-2/app/components/Aside.tsx
Normal file
47
examples/hydrogen-2/app/components/Aside.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* A side bar component with Overlay that works without JavaScript.
|
||||
* @example
|
||||
* ```ts
|
||||
* <Aside id="search-aside" heading="SEARCH">`
|
||||
* <input type="search" />
|
||||
* ...
|
||||
* </Aside>
|
||||
* ```
|
||||
*/
|
||||
export function Aside({
|
||||
children,
|
||||
heading,
|
||||
id = 'aside',
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
heading: React.ReactNode;
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<div aria-modal className="overlay" id={id} role="dialog">
|
||||
<button
|
||||
className="close-outside"
|
||||
onClick={() => {
|
||||
history.go(-1);
|
||||
window.location.hash = '';
|
||||
}}
|
||||
/>
|
||||
<aside>
|
||||
<header>
|
||||
<h3>{heading}</h3>
|
||||
<CloseAside />
|
||||
</header>
|
||||
<main>{children}</main>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CloseAside() {
|
||||
return (
|
||||
/* eslint-disable-next-line jsx-a11y/anchor-is-valid */
|
||||
<a className="close" href="#" onChange={() => history.go(-1)}>
|
||||
×
|
||||
</a>
|
||||
);
|
||||
}
|
||||
340
examples/hydrogen-2/app/components/Cart.tsx
Normal file
340
examples/hydrogen-2/app/components/Cart.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import {CartForm, Image, Money} from '@shopify/hydrogen';
|
||||
import type {CartLineUpdateInput} from '@shopify/hydrogen/storefront-api-types';
|
||||
import {Link} from '@remix-run/react';
|
||||
import type {CartApiQueryFragment} from 'storefrontapi.generated';
|
||||
import {useVariantUrl} from '~/utils';
|
||||
|
||||
type CartLine = CartApiQueryFragment['lines']['nodes'][0];
|
||||
|
||||
type CartMainProps = {
|
||||
cart: CartApiQueryFragment | null;
|
||||
layout: 'page' | 'aside';
|
||||
};
|
||||
|
||||
export function CartMain({layout, cart}: CartMainProps) {
|
||||
const linesCount = Boolean(cart?.lines?.nodes?.length || 0);
|
||||
const withDiscount =
|
||||
cart &&
|
||||
Boolean(cart.discountCodes.filter((code) => code.applicable).length);
|
||||
const className = `cart-main ${withDiscount ? 'with-discount' : ''}`;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<CartEmpty hidden={linesCount} layout={layout} />
|
||||
<CartDetails cart={cart} layout={layout} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CartDetails({layout, cart}: CartMainProps) {
|
||||
const cartHasItems = !!cart && cart.totalQuantity > 0;
|
||||
|
||||
return (
|
||||
<div className="cart-details">
|
||||
<CartLines lines={cart?.lines} layout={layout} />
|
||||
{cartHasItems && (
|
||||
<CartSummary cost={cart.cost} layout={layout}>
|
||||
<CartDiscounts discountCodes={cart.discountCodes} />
|
||||
<CartCheckoutActions checkoutUrl={cart.checkoutUrl} />
|
||||
</CartSummary>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CartLines({
|
||||
lines,
|
||||
layout,
|
||||
}: {
|
||||
layout: CartMainProps['layout'];
|
||||
lines: CartApiQueryFragment['lines'] | undefined;
|
||||
}) {
|
||||
if (!lines) return null;
|
||||
|
||||
return (
|
||||
<div aria-labelledby="cart-lines">
|
||||
<ul>
|
||||
{lines.nodes.map((line) => (
|
||||
<CartLineItem key={line.id} line={line} layout={layout} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CartLineItem({
|
||||
layout,
|
||||
line,
|
||||
}: {
|
||||
layout: CartMainProps['layout'];
|
||||
line: CartLine;
|
||||
}) {
|
||||
const {id, merchandise} = line;
|
||||
const {product, title, image, selectedOptions} = merchandise;
|
||||
const lineItemUrl = useVariantUrl(product.handle, selectedOptions);
|
||||
|
||||
return (
|
||||
<li key={id} className="cart-line">
|
||||
{image && (
|
||||
<Image
|
||||
alt={title}
|
||||
aspectRatio="1/1"
|
||||
data={image}
|
||||
height={100}
|
||||
loading="lazy"
|
||||
width={100}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Link
|
||||
prefetch="intent"
|
||||
to={lineItemUrl}
|
||||
onClick={() => {
|
||||
if (layout === 'aside') {
|
||||
// close the drawer
|
||||
window.location.href = lineItemUrl;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
<strong>{product.title}</strong>
|
||||
</p>
|
||||
</Link>
|
||||
<CartLinePrice line={line} as="span" />
|
||||
<ul>
|
||||
{selectedOptions.map((option) => (
|
||||
<li key={option.name}>
|
||||
<small>
|
||||
{option.name}: {option.value}
|
||||
</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<CartLineQuantity line={line} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function CartCheckoutActions({checkoutUrl}: {checkoutUrl: string}) {
|
||||
if (!checkoutUrl) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<a href={checkoutUrl} target="_self">
|
||||
<p>Continue to Checkout →</p>
|
||||
</a>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CartSummary({
|
||||
cost,
|
||||
layout,
|
||||
children = null,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
cost: CartApiQueryFragment['cost'];
|
||||
layout: CartMainProps['layout'];
|
||||
}) {
|
||||
const className =
|
||||
layout === 'page' ? 'cart-summary-page' : 'cart-summary-aside';
|
||||
|
||||
return (
|
||||
<div aria-labelledby="cart-summary" className={className}>
|
||||
<h4>Totals</h4>
|
||||
<dl className="cart-subtotal">
|
||||
<dt>Subtotal</dt>
|
||||
<dd>
|
||||
{cost?.subtotalAmount?.amount ? (
|
||||
<Money data={cost?.subtotalAmount} />
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CartLineRemoveButton({lineIds}: {lineIds: string[]}) {
|
||||
return (
|
||||
<CartForm
|
||||
route="/cart"
|
||||
action={CartForm.ACTIONS.LinesRemove}
|
||||
inputs={{lineIds}}
|
||||
>
|
||||
<button type="submit">Remove</button>
|
||||
</CartForm>
|
||||
);
|
||||
}
|
||||
|
||||
function CartLineQuantity({line}: {line: CartLine}) {
|
||||
if (!line || typeof line?.quantity === 'undefined') return null;
|
||||
const {id: lineId, quantity} = line;
|
||||
const prevQuantity = Number(Math.max(0, quantity - 1).toFixed(0));
|
||||
const nextQuantity = Number((quantity + 1).toFixed(0));
|
||||
|
||||
return (
|
||||
<div className="cart-line-quantiy">
|
||||
<small>Quantity: {quantity} </small>
|
||||
<CartLineUpdateButton lines={[{id: lineId, quantity: prevQuantity}]}>
|
||||
<button
|
||||
aria-label="Decrease quantity"
|
||||
disabled={quantity <= 1}
|
||||
name="decrease-quantity"
|
||||
value={prevQuantity}
|
||||
>
|
||||
<span>− </span>
|
||||
</button>
|
||||
</CartLineUpdateButton>
|
||||
|
||||
<CartLineUpdateButton lines={[{id: lineId, quantity: nextQuantity}]}>
|
||||
<button
|
||||
aria-label="Increase quantity"
|
||||
name="increase-quantity"
|
||||
value={nextQuantity}
|
||||
>
|
||||
<span>+</span>
|
||||
</button>
|
||||
</CartLineUpdateButton>
|
||||
|
||||
<CartLineRemoveButton lineIds={[lineId]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CartLinePrice({
|
||||
line,
|
||||
priceType = 'regular',
|
||||
...passthroughProps
|
||||
}: {
|
||||
line: CartLine;
|
||||
priceType?: 'regular' | 'compareAt';
|
||||
[key: string]: any;
|
||||
}) {
|
||||
if (!line?.cost?.amountPerQuantity || !line?.cost?.totalAmount) return null;
|
||||
|
||||
const moneyV2 =
|
||||
priceType === 'regular'
|
||||
? line.cost.totalAmount
|
||||
: line.cost.compareAtAmountPerQuantity;
|
||||
|
||||
if (moneyV2 == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Money withoutTrailingZeros {...passthroughProps} data={moneyV2} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CartEmpty({
|
||||
hidden = false,
|
||||
layout = 'aside',
|
||||
}: {
|
||||
hidden: boolean;
|
||||
layout?: CartMainProps['layout'];
|
||||
}) {
|
||||
return (
|
||||
<div hidden={hidden}>
|
||||
<br />
|
||||
<p>
|
||||
Looks like you haven’t added anything yet, let’s get you
|
||||
started!
|
||||
</p>
|
||||
<br />
|
||||
<Link
|
||||
to="/collections"
|
||||
onClick={() => {
|
||||
if (layout === 'aside') {
|
||||
window.location.href = '/collections';
|
||||
}
|
||||
}}
|
||||
>
|
||||
Continue shopping →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CartDiscounts({
|
||||
discountCodes,
|
||||
}: {
|
||||
discountCodes: CartApiQueryFragment['discountCodes'];
|
||||
}) {
|
||||
const codes: string[] =
|
||||
discountCodes
|
||||
?.filter((discount) => discount.applicable)
|
||||
?.map(({code}) => code) || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Have existing discount, display it with a remove option */}
|
||||
<dl hidden={!codes.length}>
|
||||
<div>
|
||||
<dt>Discount(s)</dt>
|
||||
<UpdateDiscountForm>
|
||||
<div className="cart-discount">
|
||||
<code>{codes?.join(', ')}</code>
|
||||
|
||||
<button>Remove</button>
|
||||
</div>
|
||||
</UpdateDiscountForm>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{/* Show an input to apply a discount */}
|
||||
<UpdateDiscountForm discountCodes={codes}>
|
||||
<div>
|
||||
<input type="text" name="discountCode" placeholder="Discount code" />
|
||||
|
||||
<button type="submit">Apply</button>
|
||||
</div>
|
||||
</UpdateDiscountForm>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateDiscountForm({
|
||||
discountCodes,
|
||||
children,
|
||||
}: {
|
||||
discountCodes?: string[];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<CartForm
|
||||
route="/cart"
|
||||
action={CartForm.ACTIONS.DiscountCodesUpdate}
|
||||
inputs={{
|
||||
discountCodes: discountCodes || [],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CartForm>
|
||||
);
|
||||
}
|
||||
|
||||
function CartLineUpdateButton({
|
||||
children,
|
||||
lines,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
lines: CartLineUpdateInput[];
|
||||
}) {
|
||||
return (
|
||||
<CartForm
|
||||
route="/cart"
|
||||
action={CartForm.ACTIONS.LinesUpdate}
|
||||
inputs={{lines}}
|
||||
>
|
||||
{children}
|
||||
</CartForm>
|
||||
);
|
||||
}
|
||||
99
examples/hydrogen-2/app/components/Footer.tsx
Normal file
99
examples/hydrogen-2/app/components/Footer.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import {useMatches, NavLink} from '@remix-run/react';
|
||||
import type {FooterQuery} from 'storefrontapi.generated';
|
||||
|
||||
export function Footer({menu}: FooterQuery) {
|
||||
return (
|
||||
<footer className="footer">
|
||||
<FooterMenu menu={menu} />
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterMenu({menu}: Pick<FooterQuery, 'menu'>) {
|
||||
const [root] = useMatches();
|
||||
const publicStoreDomain = root?.data?.publicStoreDomain;
|
||||
return (
|
||||
<nav className="footer-menu" role="navigation">
|
||||
{(menu || FALLBACK_FOOTER_MENU).items.map((item) => {
|
||||
if (!item.url) return null;
|
||||
// if the url is internal, we strip the domain
|
||||
const url =
|
||||
item.url.includes('myshopify.com') ||
|
||||
item.url.includes(publicStoreDomain)
|
||||
? new URL(item.url).pathname
|
||||
: item.url;
|
||||
const isExternal = !url.startsWith('/');
|
||||
return isExternal ? (
|
||||
<a href={url} key={item.id} rel="noopener noreferrer" target="_blank">
|
||||
{item.title}
|
||||
</a>
|
||||
) : (
|
||||
<NavLink
|
||||
end
|
||||
key={item.id}
|
||||
prefetch="intent"
|
||||
style={activeLinkStyle}
|
||||
to={url}
|
||||
>
|
||||
{item.title}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
const FALLBACK_FOOTER_MENU = {
|
||||
id: 'gid://shopify/Menu/199655620664',
|
||||
items: [
|
||||
{
|
||||
id: 'gid://shopify/MenuItem/461633060920',
|
||||
resourceId: 'gid://shopify/ShopPolicy/23358046264',
|
||||
tags: [],
|
||||
title: 'Privacy Policy',
|
||||
type: 'SHOP_POLICY',
|
||||
url: '/policies/privacy-policy',
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'gid://shopify/MenuItem/461633093688',
|
||||
resourceId: 'gid://shopify/ShopPolicy/23358013496',
|
||||
tags: [],
|
||||
title: 'Refund Policy',
|
||||
type: 'SHOP_POLICY',
|
||||
url: '/policies/refund-policy',
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'gid://shopify/MenuItem/461633126456',
|
||||
resourceId: 'gid://shopify/ShopPolicy/23358111800',
|
||||
tags: [],
|
||||
title: 'Shipping Policy',
|
||||
type: 'SHOP_POLICY',
|
||||
url: '/policies/shipping-policy',
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'gid://shopify/MenuItem/461633159224',
|
||||
resourceId: 'gid://shopify/ShopPolicy/23358079032',
|
||||
tags: [],
|
||||
title: 'Terms of Service',
|
||||
type: 'SHOP_POLICY',
|
||||
url: '/policies/terms-of-service',
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function activeLinkStyle({
|
||||
isActive,
|
||||
isPending,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
return {
|
||||
fontWeight: isActive ? 'bold' : '',
|
||||
color: isPending ? 'grey' : 'white',
|
||||
};
|
||||
}
|
||||
178
examples/hydrogen-2/app/components/Header.tsx
Normal file
178
examples/hydrogen-2/app/components/Header.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import {Await, NavLink, useMatches} from '@remix-run/react';
|
||||
import {Suspense} from 'react';
|
||||
import type {LayoutProps} from './Layout';
|
||||
|
||||
type HeaderProps = Pick<LayoutProps, 'header' | 'cart' | 'isLoggedIn'>;
|
||||
|
||||
type Viewport = 'desktop' | 'mobile';
|
||||
|
||||
export function Header({header, isLoggedIn, cart}: HeaderProps) {
|
||||
const {shop, menu} = header;
|
||||
return (
|
||||
<header className="header">
|
||||
<NavLink prefetch="intent" to="/" style={activeLinkStyle} end>
|
||||
<strong>{shop.name}</strong>
|
||||
</NavLink>
|
||||
<HeaderMenu menu={menu} viewport="desktop" />
|
||||
<HeaderCtas isLoggedIn={isLoggedIn} cart={cart} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeaderMenu({
|
||||
menu,
|
||||
viewport,
|
||||
}: {
|
||||
menu: HeaderProps['header']['menu'];
|
||||
viewport: Viewport;
|
||||
}) {
|
||||
const [root] = useMatches();
|
||||
const publicStoreDomain = root?.data?.publicStoreDomain;
|
||||
const className = `header-menu-${viewport}`;
|
||||
|
||||
function closeAside(event: React.MouseEvent<HTMLAnchorElement>) {
|
||||
if (viewport === 'mobile') {
|
||||
event.preventDefault();
|
||||
window.location.href = event.currentTarget.href;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className={className} role="navigation">
|
||||
{viewport === 'mobile' && (
|
||||
<NavLink
|
||||
end
|
||||
onClick={closeAside}
|
||||
prefetch="intent"
|
||||
style={activeLinkStyle}
|
||||
to="/"
|
||||
>
|
||||
Home
|
||||
</NavLink>
|
||||
)}
|
||||
{(menu || FALLBACK_HEADER_MENU).items.map((item) => {
|
||||
if (!item.url) return null;
|
||||
|
||||
// if the url is internal, we strip the domain
|
||||
const url =
|
||||
item.url.includes('myshopify.com') ||
|
||||
item.url.includes(publicStoreDomain)
|
||||
? new URL(item.url).pathname
|
||||
: item.url;
|
||||
return (
|
||||
<NavLink
|
||||
className="header-menu-item"
|
||||
end
|
||||
key={item.id}
|
||||
onClick={closeAside}
|
||||
prefetch="intent"
|
||||
style={activeLinkStyle}
|
||||
to={url}
|
||||
>
|
||||
{item.title}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderCtas({
|
||||
isLoggedIn,
|
||||
cart,
|
||||
}: Pick<HeaderProps, 'isLoggedIn' | 'cart'>) {
|
||||
return (
|
||||
<nav className="header-ctas" role="navigation">
|
||||
<HeaderMenuMobileToggle />
|
||||
<NavLink prefetch="intent" to="/account" style={activeLinkStyle}>
|
||||
{isLoggedIn ? 'Account' : 'Sign in'}
|
||||
</NavLink>
|
||||
<SearchToggle />
|
||||
<CartToggle cart={cart} />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderMenuMobileToggle() {
|
||||
return (
|
||||
<a className="header-menu-mobile-toggle" href="#mobile-menu-aside">
|
||||
<h3>☰</h3>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchToggle() {
|
||||
return <a href="#search-aside">Search</a>;
|
||||
}
|
||||
|
||||
function CartBadge({count}: {count: number}) {
|
||||
return <a href="#cart-aside">Cart {count}</a>;
|
||||
}
|
||||
|
||||
function CartToggle({cart}: Pick<HeaderProps, 'cart'>) {
|
||||
return (
|
||||
<Suspense fallback={<CartBadge count={0} />}>
|
||||
<Await resolve={cart}>
|
||||
{(cart) => {
|
||||
if (!cart) return <CartBadge count={0} />;
|
||||
return <CartBadge count={cart.totalQuantity || 0} />;
|
||||
}}
|
||||
</Await>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const FALLBACK_HEADER_MENU = {
|
||||
id: 'gid://shopify/Menu/199655587896',
|
||||
items: [
|
||||
{
|
||||
id: 'gid://shopify/MenuItem/461609500728',
|
||||
resourceId: null,
|
||||
tags: [],
|
||||
title: 'Collections',
|
||||
type: 'HTTP',
|
||||
url: '/collections',
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'gid://shopify/MenuItem/461609533496',
|
||||
resourceId: null,
|
||||
tags: [],
|
||||
title: 'Blog',
|
||||
type: 'HTTP',
|
||||
url: '/blogs/journal',
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'gid://shopify/MenuItem/461609566264',
|
||||
resourceId: null,
|
||||
tags: [],
|
||||
title: 'Policies',
|
||||
type: 'HTTP',
|
||||
url: '/policies',
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'gid://shopify/MenuItem/461609599032',
|
||||
resourceId: 'gid://shopify/Page/92591030328',
|
||||
tags: [],
|
||||
title: 'About',
|
||||
type: 'PAGE',
|
||||
url: '/pages/about',
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function activeLinkStyle({
|
||||
isActive,
|
||||
isPending,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
return {
|
||||
fontWeight: isActive ? 'bold' : '',
|
||||
color: isPending ? 'grey' : 'black',
|
||||
};
|
||||
}
|
||||
95
examples/hydrogen-2/app/components/Layout.tsx
Normal file
95
examples/hydrogen-2/app/components/Layout.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import {Await} from '@remix-run/react';
|
||||
import {Suspense} from 'react';
|
||||
import type {
|
||||
CartApiQueryFragment,
|
||||
FooterQuery,
|
||||
HeaderQuery,
|
||||
} from 'storefrontapi.generated';
|
||||
import {Aside} from '~/components/Aside';
|
||||
import {Footer} from '~/components/Footer';
|
||||
import {Header, HeaderMenu} from '~/components/Header';
|
||||
import {CartMain} from '~/components/Cart';
|
||||
import {
|
||||
PredictiveSearchForm,
|
||||
PredictiveSearchResults,
|
||||
} from '~/components/Search';
|
||||
|
||||
export type LayoutProps = {
|
||||
cart: Promise<CartApiQueryFragment | null>;
|
||||
children?: React.ReactNode;
|
||||
footer: Promise<FooterQuery>;
|
||||
header: HeaderQuery;
|
||||
isLoggedIn: boolean;
|
||||
};
|
||||
|
||||
export function Layout({
|
||||
cart,
|
||||
children = null,
|
||||
footer,
|
||||
header,
|
||||
isLoggedIn,
|
||||
}: LayoutProps) {
|
||||
return (
|
||||
<>
|
||||
<CartAside cart={cart} />
|
||||
<SearchAside />
|
||||
<MobileMenuAside menu={header.menu} />
|
||||
<Header header={header} cart={cart} isLoggedIn={isLoggedIn} />
|
||||
<main>{children}</main>
|
||||
<Suspense>
|
||||
<Await resolve={footer}>
|
||||
{(footer) => <Footer menu={footer.menu} />}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CartAside({cart}: {cart: LayoutProps['cart']}) {
|
||||
return (
|
||||
<Aside id="cart-aside" heading="CART">
|
||||
<Suspense fallback={<p>Loading cart ...</p>}>
|
||||
<Await resolve={cart}>
|
||||
{(cart) => {
|
||||
return <CartMain cart={cart} layout="aside" />;
|
||||
}}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</Aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchAside() {
|
||||
return (
|
||||
<Aside id="search-aside" heading="SEARCH">
|
||||
<div className="predictive-search">
|
||||
<br />
|
||||
<PredictiveSearchForm>
|
||||
{({fetchResults, inputRef}) => (
|
||||
<div>
|
||||
<input
|
||||
name="q"
|
||||
onChange={fetchResults}
|
||||
onFocus={fetchResults}
|
||||
placeholder="Search"
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
/>
|
||||
|
||||
<button type="submit">Search</button>
|
||||
</div>
|
||||
)}
|
||||
</PredictiveSearchForm>
|
||||
<PredictiveSearchResults />
|
||||
</div>
|
||||
</Aside>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileMenuAside({menu}: {menu: HeaderQuery['menu']}) {
|
||||
return (
|
||||
<Aside id="mobile-menu-aside" heading="MENU">
|
||||
<HeaderMenu menu={menu} viewport="mobile" />
|
||||
</Aside>
|
||||
);
|
||||
}
|
||||
480
examples/hydrogen-2/app/components/Search.tsx
Normal file
480
examples/hydrogen-2/app/components/Search.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
import {
|
||||
useParams,
|
||||
useFetcher,
|
||||
Link,
|
||||
Form,
|
||||
type FormProps,
|
||||
} from '@remix-run/react';
|
||||
import {Image, Money, Pagination} from '@shopify/hydrogen';
|
||||
import React, {useRef, useEffect} from 'react';
|
||||
import {useFetchers} from '@remix-run/react';
|
||||
|
||||
import type {
|
||||
PredictiveProductFragment,
|
||||
PredictiveCollectionFragment,
|
||||
PredictiveArticleFragment,
|
||||
SearchQuery,
|
||||
} from 'storefrontapi.generated';
|
||||
|
||||
type PredicticeSearchResultItemImage =
|
||||
| PredictiveCollectionFragment['image']
|
||||
| PredictiveArticleFragment['image']
|
||||
| PredictiveProductFragment['variants']['nodes'][0]['image'];
|
||||
|
||||
type PredictiveSearchResultItemPrice =
|
||||
| PredictiveProductFragment['variants']['nodes'][0]['price'];
|
||||
|
||||
export type NormalizedPredictiveSearchResultItem = {
|
||||
__typename: string | undefined;
|
||||
handle: string;
|
||||
id: string;
|
||||
image?: PredicticeSearchResultItemImage;
|
||||
price?: PredictiveSearchResultItemPrice;
|
||||
styledTitle?: string;
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type NormalizedPredictiveSearchResults = Array<
|
||||
| {type: 'queries'; items: Array<NormalizedPredictiveSearchResultItem>}
|
||||
| {type: 'products'; items: Array<NormalizedPredictiveSearchResultItem>}
|
||||
| {type: 'collections'; items: Array<NormalizedPredictiveSearchResultItem>}
|
||||
| {type: 'pages'; items: Array<NormalizedPredictiveSearchResultItem>}
|
||||
| {type: 'articles'; items: Array<NormalizedPredictiveSearchResultItem>}
|
||||
>;
|
||||
|
||||
export type NormalizedPredictiveSearch = {
|
||||
results: NormalizedPredictiveSearchResults;
|
||||
totalResults: number;
|
||||
};
|
||||
|
||||
type FetchSearchResultsReturn = {
|
||||
searchResults: {
|
||||
results: SearchQuery | null;
|
||||
totalResults: number;
|
||||
};
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
export const NO_PREDICTIVE_SEARCH_RESULTS: NormalizedPredictiveSearchResults = [
|
||||
{type: 'queries', items: []},
|
||||
{type: 'products', items: []},
|
||||
{type: 'collections', items: []},
|
||||
{type: 'pages', items: []},
|
||||
{type: 'articles', items: []},
|
||||
];
|
||||
|
||||
export function SearchForm({searchTerm}: {searchTerm: string}) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// focus the input when cmd+k is pressed
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'k' && event.metaKey) {
|
||||
event.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Form method="get">
|
||||
<input
|
||||
defaultValue={searchTerm}
|
||||
name="q"
|
||||
placeholder="Search…"
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
/>
|
||||
|
||||
<button type="submit">Search</button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchResults({
|
||||
results,
|
||||
}: Pick<FetchSearchResultsReturn['searchResults'], 'results'>) {
|
||||
if (!results) {
|
||||
return null;
|
||||
}
|
||||
const keys = Object.keys(results) as Array<keyof typeof results>;
|
||||
return (
|
||||
<div>
|
||||
{results &&
|
||||
keys.map((type) => {
|
||||
const resourceResults = results[type];
|
||||
|
||||
if (resourceResults.nodes[0]?.__typename === 'Page') {
|
||||
const pageResults = resourceResults as SearchQuery['pages'];
|
||||
return resourceResults.nodes.length ? (
|
||||
<SearchResultPageGrid key="pages" pages={pageResults} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
if (resourceResults.nodes[0]?.__typename === 'Product') {
|
||||
const productResults = resourceResults as SearchQuery['products'];
|
||||
return resourceResults.nodes.length ? (
|
||||
<SearchResultsProductsGrid
|
||||
key="products"
|
||||
products={productResults}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
if (resourceResults.nodes[0]?.__typename === 'Article') {
|
||||
const articleResults = resourceResults as SearchQuery['articles'];
|
||||
return resourceResults.nodes.length ? (
|
||||
<SearchResultArticleGrid
|
||||
key="articles"
|
||||
articles={articleResults}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultsProductsGrid({products}: Pick<SearchQuery, 'products'>) {
|
||||
return (
|
||||
<div className="search-result">
|
||||
<h3>Products</h3>
|
||||
<Pagination connection={products}>
|
||||
{({nodes, isLoading, NextLink, PreviousLink}) => {
|
||||
const itemsMarkup = nodes.map((product) => (
|
||||
<div className="search-results-item" key={product.id}>
|
||||
<Link prefetch="intent" to={`/products/${product.handle}`}>
|
||||
<span>{product.title}</span>
|
||||
</Link>
|
||||
</div>
|
||||
));
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<PreviousLink>
|
||||
{isLoading ? 'Loading...' : <span>↑ Load previous</span>}
|
||||
</PreviousLink>
|
||||
</div>
|
||||
<div>
|
||||
{itemsMarkup}
|
||||
<br />
|
||||
</div>
|
||||
<div>
|
||||
<NextLink>
|
||||
{isLoading ? 'Loading...' : <span>Load more ↓</span>}
|
||||
</NextLink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Pagination>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultPageGrid({pages}: Pick<SearchQuery, 'pages'>) {
|
||||
return (
|
||||
<div className="search-result">
|
||||
<h2>Pages</h2>
|
||||
<div>
|
||||
{pages?.nodes?.map((page) => (
|
||||
<div className="search-results-item" key={page.id}>
|
||||
<Link prefetch="intent" to={`/pages/${page.handle}`}>
|
||||
{page.title}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultArticleGrid({articles}: Pick<SearchQuery, 'articles'>) {
|
||||
return (
|
||||
<div className="search-result">
|
||||
<h2>Articles</h2>
|
||||
<div>
|
||||
{articles?.nodes?.map((article) => (
|
||||
<div className="search-results-item" key={article.id}>
|
||||
<Link prefetch="intent" to={`/blog/${article.handle}`}>
|
||||
{article.title}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoSearchResults() {
|
||||
return <p>No results, try a different search.</p>;
|
||||
}
|
||||
|
||||
type ChildrenRenderProps = {
|
||||
fetchResults: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
fetcher: ReturnType<typeof useFetcher<NormalizedPredictiveSearchResults>>;
|
||||
inputRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
};
|
||||
|
||||
type SearchFromProps = {
|
||||
action?: FormProps['action'];
|
||||
method?: FormProps['method'];
|
||||
className?: string;
|
||||
children: (passedProps: ChildrenRenderProps) => React.ReactNode;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Search form component that posts search requests to the `/search` route
|
||||
**/
|
||||
export function PredictiveSearchForm({
|
||||
action,
|
||||
children,
|
||||
className = 'predictive-search-form',
|
||||
method = 'POST',
|
||||
...props
|
||||
}: SearchFromProps) {
|
||||
const params = useParams();
|
||||
const fetcher = useFetcher<NormalizedPredictiveSearchResults>();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
function fetchResults(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const searchAction = action ?? '/api/predictive-search';
|
||||
const localizedAction = params.locale
|
||||
? `/${params.locale}${searchAction}`
|
||||
: searchAction;
|
||||
const newSearchTerm = event.target.value || '';
|
||||
fetcher.submit(
|
||||
{q: newSearchTerm, limit: '6'},
|
||||
{method, action: localizedAction},
|
||||
);
|
||||
}
|
||||
|
||||
// ensure the passed input has a type of search, because SearchResults
|
||||
// will select the element based on the input
|
||||
useEffect(() => {
|
||||
inputRef?.current?.setAttribute('type', 'search');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<fetcher.Form
|
||||
{...props}
|
||||
className={className}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!inputRef?.current || inputRef.current.value === '') {
|
||||
return;
|
||||
}
|
||||
inputRef.current.blur();
|
||||
}}
|
||||
>
|
||||
{children({fetchResults, inputRef, fetcher})}
|
||||
</fetcher.Form>
|
||||
);
|
||||
}
|
||||
|
||||
export function PredictiveSearchResults() {
|
||||
const {results, totalResults, searchInputRef, searchTerm} =
|
||||
usePredictiveSearch();
|
||||
|
||||
function goToSearchResult(event: React.MouseEvent<HTMLAnchorElement>) {
|
||||
if (!searchInputRef.current) return;
|
||||
searchInputRef.current.blur();
|
||||
searchInputRef.current.value = '';
|
||||
// close the aside
|
||||
window.location.href = event.currentTarget.href;
|
||||
}
|
||||
|
||||
if (!totalResults) {
|
||||
return <NoPredictiveSearchResults searchTerm={searchTerm} />;
|
||||
}
|
||||
return (
|
||||
<div className="predictive-search-results">
|
||||
<div>
|
||||
{results.map(({type, items}) => (
|
||||
<PredictiveSearchResult
|
||||
goToSearchResult={goToSearchResult}
|
||||
items={items}
|
||||
key={type}
|
||||
searchTerm={searchTerm}
|
||||
type={type}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* view all results /search?q=term */}
|
||||
{searchTerm.current && (
|
||||
<Link onClick={goToSearchResult} to={`/search?q=${searchTerm.current}`}>
|
||||
<p>
|
||||
View all results for <q>{searchTerm.current}</q>
|
||||
→
|
||||
</p>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoPredictiveSearchResults({
|
||||
searchTerm,
|
||||
}: {
|
||||
searchTerm: React.MutableRefObject<string>;
|
||||
}) {
|
||||
if (!searchTerm.current) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<p>
|
||||
No results found for <q>{searchTerm.current}</q>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
type SearchResultTypeProps = {
|
||||
goToSearchResult: (event: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
items: NormalizedPredictiveSearchResultItem[];
|
||||
searchTerm: UseSearchReturn['searchTerm'];
|
||||
type: NormalizedPredictiveSearchResults[number]['type'];
|
||||
};
|
||||
|
||||
function PredictiveSearchResult({
|
||||
goToSearchResult,
|
||||
items,
|
||||
searchTerm,
|
||||
type,
|
||||
}: SearchResultTypeProps) {
|
||||
const isSuggestions = type === 'queries';
|
||||
const categoryUrl = `/search?q=${
|
||||
searchTerm.current
|
||||
}&type=${pluralToSingularSearchType(type)}`;
|
||||
|
||||
return (
|
||||
<div className="predictive-search-result" key={type}>
|
||||
<Link prefetch="intent" to={categoryUrl} onClick={goToSearchResult}>
|
||||
<h5>{isSuggestions ? 'Suggestions' : type}</h5>
|
||||
</Link>
|
||||
<ul>
|
||||
{items.map((item: NormalizedPredictiveSearchResultItem) => (
|
||||
<SearchResultItem
|
||||
goToSearchResult={goToSearchResult}
|
||||
item={item}
|
||||
key={item.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SearchResultItemProps = Pick<SearchResultTypeProps, 'goToSearchResult'> & {
|
||||
item: NormalizedPredictiveSearchResultItem;
|
||||
};
|
||||
|
||||
function SearchResultItem({goToSearchResult, item}: SearchResultItemProps) {
|
||||
return (
|
||||
<li className="predictive-search-result-item" key={item.id}>
|
||||
<Link onClick={goToSearchResult} to={item.url}>
|
||||
{item.image?.url && (
|
||||
<Image
|
||||
alt={item.image.altText ?? ''}
|
||||
src={item.image.url}
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
{item.styledTitle ? (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: item.styledTitle,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span>{item.title}</span>
|
||||
)}
|
||||
{item?.price && (
|
||||
<small>
|
||||
<Money data={item.price} />
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
type UseSearchReturn = NormalizedPredictiveSearch & {
|
||||
searchInputRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
searchTerm: React.MutableRefObject<string>;
|
||||
};
|
||||
|
||||
function usePredictiveSearch(): UseSearchReturn {
|
||||
const fetchers = useFetchers();
|
||||
const searchTerm = useRef<string>('');
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const searchFetcher = fetchers.find((fetcher) => fetcher.data?.searchResults);
|
||||
|
||||
if (searchFetcher?.state === 'loading') {
|
||||
searchTerm.current = (searchFetcher.formData?.get('q') || '') as string;
|
||||
}
|
||||
|
||||
const search = (searchFetcher?.data?.searchResults || {
|
||||
results: NO_PREDICTIVE_SEARCH_RESULTS,
|
||||
totalResults: 0,
|
||||
}) as NormalizedPredictiveSearch;
|
||||
|
||||
// capture the search input element as a ref
|
||||
useEffect(() => {
|
||||
if (searchInputRef.current) return;
|
||||
searchInputRef.current = document.querySelector('input[type="search"]');
|
||||
}, []);
|
||||
|
||||
return {...search, searchInputRef, searchTerm};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a plural search type to a singular search type
|
||||
* @param type - The plural search type
|
||||
* @returns The singular search type
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* pluralToSingularSearchType('articles') // => 'ARTICLE'
|
||||
* pluralToSingularSearchType(['articles', 'products']) // => 'ARTICLE,PRODUCT'
|
||||
* ```
|
||||
*/
|
||||
function pluralToSingularSearchType(
|
||||
type:
|
||||
| NormalizedPredictiveSearchResults[number]['type']
|
||||
| Array<NormalizedPredictiveSearchResults[number]['type']>,
|
||||
) {
|
||||
const plural = {
|
||||
articles: 'ARTICLE',
|
||||
collections: 'COLLECTION',
|
||||
pages: 'PAGE',
|
||||
products: 'PRODUCT',
|
||||
queries: 'QUERY',
|
||||
};
|
||||
|
||||
if (typeof type === 'string') {
|
||||
return plural[type];
|
||||
}
|
||||
|
||||
return type.map((t) => plural[t]).join(',');
|
||||
}
|
||||
12
examples/hydrogen-2/app/entry.client.tsx
Normal file
12
examples/hydrogen-2/app/entry.client.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import {RemixBrowser} from '@remix-run/react';
|
||||
import {startTransition, StrictMode} from 'react';
|
||||
import {hydrateRoot} from 'react-dom/client';
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<RemixBrowser />
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
33
examples/hydrogen-2/app/entry.server.tsx
Normal file
33
examples/hydrogen-2/app/entry.server.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type {EntryContext} from '@shopify/remix-oxygen';
|
||||
import {RemixServer} from '@remix-run/react';
|
||||
import isbot from 'isbot';
|
||||
import {renderToReadableStream} from 'react-dom/server';
|
||||
|
||||
export default async function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext,
|
||||
) {
|
||||
const body = await renderToReadableStream(
|
||||
<RemixServer context={remixContext} url={request.url} />,
|
||||
{
|
||||
signal: request.signal,
|
||||
onError(error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
responseStatusCode = 500;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (isbot(request.headers.get('user-agent'))) {
|
||||
await body.allReady;
|
||||
}
|
||||
|
||||
responseHeaders.set('Content-Type', 'text/html');
|
||||
return new Response(body, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
});
|
||||
}
|
||||
245
examples/hydrogen-2/app/root.tsx
Normal file
245
examples/hydrogen-2/app/root.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import {
|
||||
isRouteErrorResponse,
|
||||
useMatches,
|
||||
useRouteError,
|
||||
} from '@remix-run/react';
|
||||
import {defer, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
useLoaderData,
|
||||
} from '@remix-run/react';
|
||||
import type {CustomerAccessToken} from '@shopify/hydrogen-react/storefront-api-types';
|
||||
import type {HydrogenSession} from '../server';
|
||||
import favicon from '../public/favicon.svg';
|
||||
import resetStyles from './styles/reset.css';
|
||||
import appStyles from './styles/app.css';
|
||||
import {Layout} from '~/components/Layout';
|
||||
|
||||
export function links() {
|
||||
return [
|
||||
{rel: 'stylesheet', href: resetStyles},
|
||||
{rel: 'stylesheet', href: appStyles},
|
||||
{
|
||||
rel: 'preconnect',
|
||||
href: 'https://cdn.shopify.com',
|
||||
},
|
||||
{
|
||||
rel: 'preconnect',
|
||||
href: 'https://shop.app',
|
||||
},
|
||||
{rel: 'icon', type: 'image/svg+xml', href: favicon},
|
||||
];
|
||||
}
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
const {storefront, session, cart} = context;
|
||||
const customerAccessToken = await session.get('customerAccessToken');
|
||||
const publicStoreDomain = context.env.PUBLIC_STORE_DOMAIN;
|
||||
|
||||
// validate the customer access token is valid
|
||||
const {isLoggedIn, headers} = await validateCustomerAccessToken(
|
||||
customerAccessToken,
|
||||
session,
|
||||
);
|
||||
|
||||
// defer the cart query by not awaiting it
|
||||
const cartPromise = cart.get();
|
||||
|
||||
// defer the footer query (below the fold)
|
||||
const footerPromise = storefront.query(FOOTER_QUERY, {
|
||||
cache: storefront.CacheLong(),
|
||||
variables: {
|
||||
footerMenuHandle: 'footer', // Adjust to your footer menu handle
|
||||
},
|
||||
});
|
||||
|
||||
// await the header query (above the fold)
|
||||
const headerPromise = storefront.query(HEADER_QUERY, {
|
||||
cache: storefront.CacheLong(),
|
||||
variables: {
|
||||
headerMenuHandle: 'main-menu', // Adjust to your header menu handle
|
||||
},
|
||||
});
|
||||
|
||||
return defer(
|
||||
{
|
||||
cart: cartPromise,
|
||||
footer: footerPromise,
|
||||
header: await headerPromise,
|
||||
isLoggedIn,
|
||||
publicStoreDomain,
|
||||
},
|
||||
{headers},
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
<Layout {...data}>
|
||||
<Outlet />
|
||||
</Layout>
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
const [root] = useMatches();
|
||||
let errorMessage = 'Unknown error';
|
||||
let errorStatus = 500;
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
errorMessage = error?.data?.message ?? error.data;
|
||||
errorStatus = error.status;
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
<Layout {...root.data}>
|
||||
<div className="route-error">
|
||||
<h1>Oops</h1>
|
||||
<h2>{errorStatus}</h2>
|
||||
{errorMessage && (
|
||||
<fieldset>
|
||||
<pre>{errorMessage}</pre>
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the customer access token and returns a boolean and headers
|
||||
* @see https://shopify.dev/docs/api/storefront/latest/objects/CustomerAccessToken
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* //
|
||||
* const {isLoggedIn, headers} = await validateCustomerAccessToken(
|
||||
* customerAccessToken,
|
||||
* session,
|
||||
* );
|
||||
* ```
|
||||
* */
|
||||
async function validateCustomerAccessToken(
|
||||
customerAccessToken: CustomerAccessToken,
|
||||
session: HydrogenSession,
|
||||
) {
|
||||
let isLoggedIn = false;
|
||||
const headers = new Headers();
|
||||
if (!customerAccessToken?.accessToken || !customerAccessToken?.expiresAt) {
|
||||
return {isLoggedIn, headers};
|
||||
}
|
||||
const expiresAt = new Date(customerAccessToken.expiresAt);
|
||||
const dateNow = new Date();
|
||||
const customerAccessTokenExpired = expiresAt < dateNow;
|
||||
if (customerAccessTokenExpired) {
|
||||
session.unset('customerAccessToken');
|
||||
headers.append('Set-Cookie', await session.commit());
|
||||
} else {
|
||||
isLoggedIn = true;
|
||||
}
|
||||
|
||||
return {isLoggedIn, headers};
|
||||
}
|
||||
|
||||
const MENU_FRAGMENT = `#graphql
|
||||
fragment MenuItem on MenuItem {
|
||||
id
|
||||
resourceId
|
||||
tags
|
||||
title
|
||||
type
|
||||
url
|
||||
}
|
||||
fragment ChildMenuItem on MenuItem {
|
||||
...MenuItem
|
||||
}
|
||||
fragment ParentMenuItem on MenuItem {
|
||||
...MenuItem
|
||||
items {
|
||||
...ChildMenuItem
|
||||
}
|
||||
}
|
||||
fragment Menu on Menu {
|
||||
id
|
||||
items {
|
||||
...ParentMenuItem
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
const HEADER_QUERY = `#graphql
|
||||
fragment Shop on Shop {
|
||||
id
|
||||
name
|
||||
description
|
||||
primaryDomain {
|
||||
url
|
||||
}
|
||||
brand {
|
||||
logo {
|
||||
image {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
query Header(
|
||||
$country: CountryCode
|
||||
$headerMenuHandle: String!
|
||||
$language: LanguageCode
|
||||
) @inContext(language: $language, country: $country) {
|
||||
shop {
|
||||
...Shop
|
||||
}
|
||||
menu(handle: $headerMenuHandle) {
|
||||
...Menu
|
||||
}
|
||||
}
|
||||
${MENU_FRAGMENT}
|
||||
` as const;
|
||||
|
||||
const FOOTER_QUERY = `#graphql
|
||||
query Footer(
|
||||
$country: CountryCode
|
||||
$footerMenuHandle: String!
|
||||
$language: LanguageCode
|
||||
) @inContext(language: $language, country: $country) {
|
||||
menu(handle: $footerMenuHandle) {
|
||||
...Menu
|
||||
}
|
||||
}
|
||||
${MENU_FRAGMENT}
|
||||
` as const;
|
||||
7
examples/hydrogen-2/app/routes/$.tsx
Normal file
7
examples/hydrogen-2/app/routes/$.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import type {LoaderArgs} from '@shopify/remix-oxygen';
|
||||
|
||||
export async function loader({request}: LoaderArgs) {
|
||||
throw new Response(`${new URL(request.url).pathname} not found`, {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
145
examples/hydrogen-2/app/routes/[robots.txt].tsx
Normal file
145
examples/hydrogen-2/app/routes/[robots.txt].tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import {type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {useRouteError, isRouteErrorResponse} from '@remix-run/react';
|
||||
import {parseGid} from '@shopify/hydrogen';
|
||||
|
||||
export async function loader({request, context}: LoaderArgs) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
const {shop} = await context.storefront.query(ROBOTS_QUERY);
|
||||
|
||||
const shopId = parseGid(shop.id).id;
|
||||
const body = robotsTxtData({url: url.origin, shopId});
|
||||
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
|
||||
'Cache-Control': `max-age=${60 * 60 * 24}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
return (
|
||||
<div>
|
||||
<h1>Oops</h1>
|
||||
<p>Status: {error.status}</p>
|
||||
<p>{error.data.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let errorMessage = 'Unknown error';
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Uh oh ...</h1>
|
||||
<p>Something went wrong.</p>
|
||||
<pre>{errorMessage}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function robotsTxtData({url, shopId}: {shopId?: string; url?: string}) {
|
||||
const sitemapUrl = url ? `${url}/sitemap.xml` : undefined;
|
||||
|
||||
return `
|
||||
User-agent: *
|
||||
${generalDisallowRules({sitemapUrl, shopId})}
|
||||
|
||||
# Google adsbot ignores robots.txt unless specifically named!
|
||||
User-agent: adsbot-google
|
||||
Disallow: /checkouts/
|
||||
Disallow: /checkout
|
||||
Disallow: /carts
|
||||
Disallow: /orders
|
||||
${shopId ? `Disallow: /${shopId}/checkouts` : ''}
|
||||
${shopId ? `Disallow: /${shopId}/orders` : ''}
|
||||
Disallow: /*?*oseid=*
|
||||
Disallow: /*preview_theme_id*
|
||||
Disallow: /*preview_script_id*
|
||||
|
||||
User-agent: Nutch
|
||||
Disallow: /
|
||||
|
||||
User-agent: AhrefsBot
|
||||
Crawl-delay: 10
|
||||
${generalDisallowRules({sitemapUrl, shopId})}
|
||||
|
||||
User-agent: AhrefsSiteAudit
|
||||
Crawl-delay: 10
|
||||
${generalDisallowRules({sitemapUrl, shopId})}
|
||||
|
||||
User-agent: MJ12bot
|
||||
Crawl-Delay: 10
|
||||
|
||||
User-agent: Pinterest
|
||||
Crawl-delay: 1
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* This function generates disallow rules that generally follow what Shopify's
|
||||
* Online Store has as defaults for their robots.txt
|
||||
*/
|
||||
function generalDisallowRules({
|
||||
shopId,
|
||||
sitemapUrl,
|
||||
}: {
|
||||
shopId?: string;
|
||||
sitemapUrl?: string;
|
||||
}) {
|
||||
return `Disallow: /admin
|
||||
Disallow: /cart
|
||||
Disallow: /orders
|
||||
Disallow: /checkouts/
|
||||
Disallow: /checkout
|
||||
${shopId ? `Disallow: /${shopId}/checkouts` : ''}
|
||||
${shopId ? `Disallow: /${shopId}/orders` : ''}
|
||||
Disallow: /carts
|
||||
Disallow: /account
|
||||
Disallow: /collections/*sort_by*
|
||||
Disallow: /*/collections/*sort_by*
|
||||
Disallow: /collections/*+*
|
||||
Disallow: /collections/*%2B*
|
||||
Disallow: /collections/*%2b*
|
||||
Disallow: /*/collections/*+*
|
||||
Disallow: /*/collections/*%2B*
|
||||
Disallow: /*/collections/*%2b*
|
||||
Disallow: */collections/*filter*&*filter*
|
||||
Disallow: /blogs/*+*
|
||||
Disallow: /blogs/*%2B*
|
||||
Disallow: /blogs/*%2b*
|
||||
Disallow: /*/blogs/*+*
|
||||
Disallow: /*/blogs/*%2B*
|
||||
Disallow: /*/blogs/*%2b*
|
||||
Disallow: /*?*oseid=*
|
||||
Disallow: /*preview_theme_id*
|
||||
Disallow: /*preview_script_id*
|
||||
Disallow: /policies/
|
||||
Disallow: /*/*?*ls=*&ls=*
|
||||
Disallow: /*/*?*ls%3D*%3Fls%3D*
|
||||
Disallow: /*/*?*ls%3d*%3fls%3d*
|
||||
Disallow: /search
|
||||
Allow: /search/
|
||||
Disallow: /search/?*
|
||||
Disallow: /apple-app-site-association
|
||||
Disallow: /.well-known/shopify/monorail
|
||||
${sitemapUrl ? `Sitemap: ${sitemapUrl}` : ''}`;
|
||||
}
|
||||
|
||||
const ROBOTS_QUERY = `#graphql
|
||||
query StoreRobots($country: CountryCode, $language: LanguageCode)
|
||||
@inContext(country: $country, language: $language) {
|
||||
shop {
|
||||
id
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
174
examples/hydrogen-2/app/routes/[sitemap.xml].tsx
Normal file
174
examples/hydrogen-2/app/routes/[sitemap.xml].tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import {flattenConnection} from '@shopify/hydrogen';
|
||||
import type {LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import type {SitemapQuery} from 'storefrontapi.generated';
|
||||
|
||||
/**
|
||||
* the google limit is 50K, however, the storefront API
|
||||
* allows querying only 250 resources per pagination page
|
||||
*/
|
||||
const MAX_URLS = 250;
|
||||
|
||||
type Entry = {
|
||||
url: string;
|
||||
lastMod?: string;
|
||||
changeFreq?: string;
|
||||
image?: {
|
||||
url: string;
|
||||
title?: string;
|
||||
caption?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function loader({request, context: {storefront}}: LoaderArgs) {
|
||||
const data = await storefront.query(SITEMAP_QUERY, {
|
||||
variables: {
|
||||
urlLimits: MAX_URLS,
|
||||
language: storefront.i18n.language,
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
throw new Response('No data found', {status: 404});
|
||||
}
|
||||
|
||||
const sitemap = generateSitemap({data, baseUrl: new URL(request.url).origin});
|
||||
|
||||
return new Response(sitemap, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml',
|
||||
|
||||
'Cache-Control': `max-age=${60 * 60 * 24}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function xmlEncode(string: string) {
|
||||
return string.replace(/[&<>'"]/g, (char) => `&#${char.charCodeAt(0)};`);
|
||||
}
|
||||
|
||||
function generateSitemap({
|
||||
data,
|
||||
baseUrl,
|
||||
}: {
|
||||
data: SitemapQuery;
|
||||
baseUrl: string;
|
||||
}) {
|
||||
const products = flattenConnection(data.products)
|
||||
.filter((product) => product.onlineStoreUrl)
|
||||
.map((product) => {
|
||||
const url = `${baseUrl}/products/${xmlEncode(product.handle)}`;
|
||||
|
||||
const productEntry: Entry = {
|
||||
url,
|
||||
lastMod: product.updatedAt,
|
||||
changeFreq: 'daily',
|
||||
};
|
||||
|
||||
if (product.featuredImage?.url) {
|
||||
productEntry.image = {
|
||||
url: xmlEncode(product.featuredImage.url),
|
||||
};
|
||||
|
||||
if (product.title) {
|
||||
productEntry.image.title = xmlEncode(product.title);
|
||||
}
|
||||
|
||||
if (product.featuredImage.altText) {
|
||||
productEntry.image.caption = xmlEncode(product.featuredImage.altText);
|
||||
}
|
||||
}
|
||||
|
||||
return productEntry;
|
||||
});
|
||||
|
||||
const collections = flattenConnection(data.collections)
|
||||
.filter((collection) => collection.onlineStoreUrl)
|
||||
.map((collection) => {
|
||||
const url = `${baseUrl}/collections/${collection.handle}`;
|
||||
|
||||
return {
|
||||
url,
|
||||
lastMod: collection.updatedAt,
|
||||
changeFreq: 'daily',
|
||||
};
|
||||
});
|
||||
|
||||
const pages = flattenConnection(data.pages)
|
||||
.filter((page) => page.onlineStoreUrl)
|
||||
.map((page) => {
|
||||
const url = `${baseUrl}/pages/${page.handle}`;
|
||||
|
||||
return {
|
||||
url,
|
||||
lastMod: page.updatedAt,
|
||||
changeFreq: 'weekly',
|
||||
};
|
||||
});
|
||||
|
||||
const urls = [...products, ...collections, ...pages];
|
||||
|
||||
return `
|
||||
<urlset
|
||||
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"
|
||||
>
|
||||
${urls.map(renderUrlTag).join('')}
|
||||
</urlset>`;
|
||||
}
|
||||
|
||||
function renderUrlTag({url, lastMod, changeFreq, image}: Entry) {
|
||||
const imageTag = image
|
||||
? `<image:image>
|
||||
<image:loc>${image.url}</image:loc>
|
||||
<image:title>${image.title ?? ''}</image:title>
|
||||
<image:caption>${image.caption ?? ''}</image:caption>
|
||||
</image:image>`.trim()
|
||||
: '';
|
||||
|
||||
return `
|
||||
<url>
|
||||
<loc>${url}</loc>
|
||||
<lastmod>${lastMod}</lastmod>
|
||||
<changefreq>${changeFreq}</changefreq>
|
||||
${imageTag}
|
||||
</url>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
const SITEMAP_QUERY = `#graphql
|
||||
query Sitemap($urlLimits: Int, $language: LanguageCode)
|
||||
@inContext(language: $language) {
|
||||
products(
|
||||
first: $urlLimits
|
||||
query: "published_status:'online_store:visible'"
|
||||
) {
|
||||
nodes {
|
||||
updatedAt
|
||||
handle
|
||||
onlineStoreUrl
|
||||
title
|
||||
featuredImage {
|
||||
url
|
||||
altText
|
||||
}
|
||||
}
|
||||
}
|
||||
collections(
|
||||
first: $urlLimits
|
||||
query: "published_status:'online_store:visible'"
|
||||
) {
|
||||
nodes {
|
||||
updatedAt
|
||||
handle
|
||||
onlineStoreUrl
|
||||
}
|
||||
}
|
||||
pages(first: $urlLimits, query: "published_status:'published'") {
|
||||
nodes {
|
||||
updatedAt
|
||||
handle
|
||||
onlineStoreUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
145
examples/hydrogen-2/app/routes/_index.tsx
Normal file
145
examples/hydrogen-2/app/routes/_index.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import type {V2_MetaFunction} from '@shopify/remix-oxygen';
|
||||
import {defer, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {Await, useLoaderData, Link} from '@remix-run/react';
|
||||
import {Suspense} from 'react';
|
||||
import {Image, Money} from '@shopify/hydrogen';
|
||||
import type {
|
||||
FeaturedCollectionFragment,
|
||||
RecommendedProductsQuery,
|
||||
} from 'storefrontapi.generated';
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: 'Hydrogen | Home'}];
|
||||
};
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
const {storefront} = context;
|
||||
const {collections} = await storefront.query(FEATURED_COLLECTION_QUERY);
|
||||
const featuredCollection = collections.nodes[0];
|
||||
const recommendedProducts = storefront.query(RECOMMENDED_PRODUCTS_QUERY);
|
||||
|
||||
return defer({featuredCollection, recommendedProducts});
|
||||
}
|
||||
|
||||
export default function Homepage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
return (
|
||||
<div className="home">
|
||||
<FeaturedCollection collection={data.featuredCollection} />
|
||||
<RecommendedProducts products={data.recommendedProducts} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturedCollection({
|
||||
collection,
|
||||
}: {
|
||||
collection: FeaturedCollectionFragment;
|
||||
}) {
|
||||
const image = collection.image;
|
||||
return (
|
||||
<Link
|
||||
className="featured-collection"
|
||||
to={`/collections/${collection.handle}`}
|
||||
>
|
||||
{image && (
|
||||
<div className="featured-collection-image">
|
||||
<Image data={image} sizes="100vw" />
|
||||
</div>
|
||||
)}
|
||||
<h1>{collection.title}</h1>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendedProducts({
|
||||
products,
|
||||
}: {
|
||||
products: Promise<RecommendedProductsQuery>;
|
||||
}) {
|
||||
return (
|
||||
<div className="recommended-products">
|
||||
<h2>Recommended Products</h2>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<Await resolve={products}>
|
||||
{({products}) => (
|
||||
<div className="recommended-products-grid">
|
||||
{products.nodes.map((product) => (
|
||||
<Link
|
||||
key={product.id}
|
||||
className="recommended-product"
|
||||
to={`/products/${product.handle}`}
|
||||
>
|
||||
<Image
|
||||
data={product.images.nodes[0]}
|
||||
aspectRatio="1/1"
|
||||
sizes="(min-width: 45em) 20vw, 50vw"
|
||||
/>
|
||||
<h4>{product.title}</h4>
|
||||
<small>
|
||||
<Money data={product.priceRange.minVariantPrice} />
|
||||
</small>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Await>
|
||||
</Suspense>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FEATURED_COLLECTION_QUERY = `#graphql
|
||||
fragment FeaturedCollection on Collection {
|
||||
id
|
||||
title
|
||||
image {
|
||||
id
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
handle
|
||||
}
|
||||
query FeaturedCollection($country: CountryCode, $language: LanguageCode)
|
||||
@inContext(country: $country, language: $language) {
|
||||
collections(first: 1, sortKey: UPDATED_AT, reverse: true) {
|
||||
nodes {
|
||||
...FeaturedCollection
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
const RECOMMENDED_PRODUCTS_QUERY = `#graphql
|
||||
fragment RecommendedProduct on Product {
|
||||
id
|
||||
title
|
||||
handle
|
||||
priceRange {
|
||||
minVariantPrice {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
images(first: 1) {
|
||||
nodes {
|
||||
id
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
}
|
||||
}
|
||||
query RecommendedProducts ($country: CountryCode, $language: LanguageCode)
|
||||
@inContext(country: $country, language: $language) {
|
||||
products(first: 4, sortKey: UPDATED_AT, reverse: true) {
|
||||
nodes {
|
||||
...RecommendedProduct
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
9
examples/hydrogen-2/app/routes/account.$.tsx
Normal file
9
examples/hydrogen-2/app/routes/account.$.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import type {LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {redirect} from '@shopify/remix-oxygen';
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
if (await context.session.get('customerAccessToken')) {
|
||||
return redirect('/account');
|
||||
}
|
||||
return redirect('/account/login');
|
||||
}
|
||||
563
examples/hydrogen-2/app/routes/account.addresses.tsx
Normal file
563
examples/hydrogen-2/app/routes/account.addresses.tsx
Normal file
@@ -0,0 +1,563 @@
|
||||
import type {MailingAddressInput} from '@shopify/hydrogen/storefront-api-types';
|
||||
import type {AddressFragment, CustomerFragment} from 'storefrontapi.generated';
|
||||
import {
|
||||
json,
|
||||
redirect,
|
||||
type ActionArgs,
|
||||
type LoaderArgs,
|
||||
type V2_MetaFunction,
|
||||
} from '@shopify/remix-oxygen';
|
||||
import {
|
||||
Form,
|
||||
useActionData,
|
||||
useNavigation,
|
||||
useOutletContext,
|
||||
} from '@remix-run/react';
|
||||
|
||||
export type ActionResponse = {
|
||||
addressId?: string | null;
|
||||
createdAddress?: AddressFragment;
|
||||
defaultAddress?: string | null;
|
||||
deletedAddress?: string | null;
|
||||
error: Record<AddressFragment['id'], string> | null;
|
||||
updatedAddress?: AddressFragment;
|
||||
};
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: 'Addresses'}];
|
||||
};
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
const {session} = context;
|
||||
const customerAccessToken = await session.get('customerAccessToken');
|
||||
if (!customerAccessToken) {
|
||||
return redirect('/account/login');
|
||||
}
|
||||
return json({});
|
||||
}
|
||||
|
||||
export async function action({request, context}: ActionArgs) {
|
||||
const {storefront, session} = context;
|
||||
|
||||
try {
|
||||
const form = await request.formData();
|
||||
|
||||
const addressId = form.has('addressId')
|
||||
? String(form.get('addressId'))
|
||||
: null;
|
||||
if (!addressId) {
|
||||
throw new Error('You must provide an address id.');
|
||||
}
|
||||
|
||||
const customerAccessToken = await session.get('customerAccessToken');
|
||||
if (!customerAccessToken) {
|
||||
return json({error: {[addressId]: 'Unauthorized'}}, {status: 401});
|
||||
}
|
||||
const {accessToken} = customerAccessToken;
|
||||
|
||||
const defaultAddress = form.has('defaultAddress')
|
||||
? String(form.get('defaultAddress')) === 'on'
|
||||
: null;
|
||||
const address: MailingAddressInput = {};
|
||||
const keys: (keyof MailingAddressInput)[] = [
|
||||
'address1',
|
||||
'address2',
|
||||
'city',
|
||||
'company',
|
||||
'country',
|
||||
'firstName',
|
||||
'lastName',
|
||||
'phone',
|
||||
'province',
|
||||
'zip',
|
||||
];
|
||||
|
||||
for (const key of keys) {
|
||||
const value = form.get(key);
|
||||
if (typeof value === 'string') {
|
||||
address[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
switch (request.method) {
|
||||
case 'POST': {
|
||||
// handle new address creation
|
||||
try {
|
||||
const {customerAddressCreate} = await storefront.mutate(
|
||||
CREATE_ADDRESS_MUTATION,
|
||||
{
|
||||
variables: {customerAccessToken: accessToken, address},
|
||||
},
|
||||
);
|
||||
|
||||
if (customerAddressCreate?.customerUserErrors?.length) {
|
||||
const error = customerAddressCreate.customerUserErrors[0];
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
const createdAddress = customerAddressCreate?.customerAddress;
|
||||
if (!createdAddress?.id) {
|
||||
throw new Error(
|
||||
'Expected customer address to be created, but the id is missing',
|
||||
);
|
||||
}
|
||||
|
||||
if (defaultAddress) {
|
||||
const createdAddressId = decodeURIComponent(createdAddress.id);
|
||||
const {customerDefaultAddressUpdate} = await storefront.mutate(
|
||||
UPDATE_DEFAULT_ADDRESS_MUTATION,
|
||||
{
|
||||
variables: {
|
||||
customerAccessToken: accessToken,
|
||||
addressId: createdAddressId,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (customerDefaultAddressUpdate?.customerUserErrors?.length) {
|
||||
const error = customerDefaultAddressUpdate.customerUserErrors[0];
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return json({error: null, createdAddress, defaultAddress});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return json({error: {[addressId]: error.message}}, {status: 400});
|
||||
}
|
||||
return json({error: {[addressId]: error}}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
case 'PUT': {
|
||||
// handle address updates
|
||||
try {
|
||||
const {customerAddressUpdate} = await storefront.mutate(
|
||||
UPDATE_ADDRESS_MUTATION,
|
||||
{
|
||||
variables: {
|
||||
address,
|
||||
customerAccessToken: accessToken,
|
||||
id: decodeURIComponent(addressId),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const updatedAddress = customerAddressUpdate?.customerAddress;
|
||||
|
||||
if (customerAddressUpdate?.customerUserErrors?.length) {
|
||||
const error = customerAddressUpdate.customerUserErrors[0];
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
if (defaultAddress) {
|
||||
const {customerDefaultAddressUpdate} = await storefront.mutate(
|
||||
UPDATE_DEFAULT_ADDRESS_MUTATION,
|
||||
{
|
||||
variables: {
|
||||
customerAccessToken: accessToken,
|
||||
addressId: decodeURIComponent(addressId),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (customerDefaultAddressUpdate?.customerUserErrors?.length) {
|
||||
const error = customerDefaultAddressUpdate.customerUserErrors[0];
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return json({error: null, updatedAddress, defaultAddress});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return json({error: {[addressId]: error.message}}, {status: 400});
|
||||
}
|
||||
return json({error: {[addressId]: error}}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
case 'DELETE': {
|
||||
// handles address deletion
|
||||
try {
|
||||
const {customerAddressDelete} = await storefront.mutate(
|
||||
DELETE_ADDRESS_MUTATION,
|
||||
{
|
||||
variables: {customerAccessToken: accessToken, id: addressId},
|
||||
},
|
||||
);
|
||||
|
||||
if (customerAddressDelete?.customerUserErrors?.length) {
|
||||
const error = customerAddressDelete.customerUserErrors[0];
|
||||
throw new Error(error.message);
|
||||
}
|
||||
return json({error: null, deletedAddress: addressId});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return json({error: {[addressId]: error.message}}, {status: 400});
|
||||
}
|
||||
return json({error: {[addressId]: error}}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
return json(
|
||||
{error: {[addressId]: 'Method not allowed'}},
|
||||
{status: 405},
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return json({error: error.message}, {status: 400});
|
||||
}
|
||||
return json({error}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Addresses() {
|
||||
const {customer} = useOutletContext<{customer: CustomerFragment}>();
|
||||
const {defaultAddress, addresses} = customer;
|
||||
|
||||
return (
|
||||
<div className="account-addresses">
|
||||
<h2>Addresses</h2>
|
||||
<br />
|
||||
{!addresses.nodes.length ? (
|
||||
<p>You have no addresses saved.</p>
|
||||
) : (
|
||||
<div>
|
||||
<div>
|
||||
<legend>Create address</legend>
|
||||
<NewAddressForm />
|
||||
</div>
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<ExistingAddresses
|
||||
addresses={addresses}
|
||||
defaultAddress={defaultAddress}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewAddressForm() {
|
||||
const newAddress = {
|
||||
address1: '',
|
||||
address2: '',
|
||||
city: '',
|
||||
company: '',
|
||||
country: '',
|
||||
firstName: '',
|
||||
id: 'new',
|
||||
lastName: '',
|
||||
phone: '',
|
||||
province: '',
|
||||
zip: '',
|
||||
} as AddressFragment;
|
||||
|
||||
return (
|
||||
<AddressForm address={newAddress} defaultAddress={null}>
|
||||
{({stateForMethod}) => (
|
||||
<div>
|
||||
<button
|
||||
disabled={stateForMethod('POST') !== 'idle'}
|
||||
formMethod="POST"
|
||||
type="submit"
|
||||
>
|
||||
{stateForMethod('POST') !== 'idle' ? 'Creating' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</AddressForm>
|
||||
);
|
||||
}
|
||||
|
||||
function ExistingAddresses({
|
||||
addresses,
|
||||
defaultAddress,
|
||||
}: Pick<CustomerFragment, 'addresses' | 'defaultAddress'>) {
|
||||
return (
|
||||
<div>
|
||||
<legend>Existing addresses</legend>
|
||||
{addresses.nodes.map((address) => (
|
||||
<AddressForm
|
||||
key={address.id}
|
||||
address={address}
|
||||
defaultAddress={defaultAddress}
|
||||
>
|
||||
{({stateForMethod}) => (
|
||||
<div>
|
||||
<button
|
||||
disabled={stateForMethod('PUT') !== 'idle'}
|
||||
formMethod="PUT"
|
||||
type="submit"
|
||||
>
|
||||
{stateForMethod('PUT') !== 'idle' ? 'Saving' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
disabled={stateForMethod('DELETE') !== 'idle'}
|
||||
formMethod="DELETE"
|
||||
type="submit"
|
||||
>
|
||||
{stateForMethod('DELETE') !== 'idle' ? 'Deleting' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</AddressForm>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddressForm({
|
||||
address,
|
||||
defaultAddress,
|
||||
children,
|
||||
}: {
|
||||
children: (props: {
|
||||
stateForMethod: (
|
||||
method: 'PUT' | 'POST' | 'DELETE',
|
||||
) => ReturnType<typeof useNavigation>['state'];
|
||||
}) => React.ReactNode;
|
||||
defaultAddress: CustomerFragment['defaultAddress'];
|
||||
address: AddressFragment;
|
||||
}) {
|
||||
const {state, formMethod} = useNavigation();
|
||||
const action = useActionData<ActionResponse>();
|
||||
const error = action?.error?.[address.id];
|
||||
const isDefaultAddress = defaultAddress?.id === address.id;
|
||||
return (
|
||||
<Form id={address.id}>
|
||||
<fieldset>
|
||||
<input type="hidden" name="addressId" defaultValue={address.id} />
|
||||
<label htmlFor="firstName">First name*</label>
|
||||
<input
|
||||
aria-label="First name"
|
||||
autoComplete="given-name"
|
||||
defaultValue={address?.firstName ?? ''}
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
placeholder="First name"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="lastName">Last name*</label>
|
||||
<input
|
||||
aria-label="Last name"
|
||||
autoComplete="family-name"
|
||||
defaultValue={address?.lastName ?? ''}
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
placeholder="Last name"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="company">Company</label>
|
||||
<input
|
||||
aria-label="Company"
|
||||
autoComplete="organization"
|
||||
defaultValue={address?.company ?? ''}
|
||||
id="company"
|
||||
name="company"
|
||||
placeholder="Company"
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="address1">Address line*</label>
|
||||
<input
|
||||
aria-label="Address line 1"
|
||||
autoComplete="address-line1"
|
||||
defaultValue={address?.address1 ?? ''}
|
||||
id="address1"
|
||||
name="address1"
|
||||
placeholder="Address line 1*"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="address2">Address line 2</label>
|
||||
<input
|
||||
aria-label="Address line 2"
|
||||
autoComplete="address-line2"
|
||||
defaultValue={address?.address2 ?? ''}
|
||||
id="address2"
|
||||
name="address2"
|
||||
placeholder="Address line 2"
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="city">City*</label>
|
||||
<input
|
||||
aria-label="City"
|
||||
autoComplete="address-level2"
|
||||
defaultValue={address?.city ?? ''}
|
||||
id="city"
|
||||
name="city"
|
||||
placeholder="City"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="province">State / Province*</label>
|
||||
<input
|
||||
aria-label="State"
|
||||
autoComplete="address-level1"
|
||||
defaultValue={address?.province ?? ''}
|
||||
id="province"
|
||||
name="province"
|
||||
placeholder="State / Province"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="zip">Zip / Postal Code*</label>
|
||||
<input
|
||||
aria-label="Zip"
|
||||
autoComplete="postal-code"
|
||||
defaultValue={address?.zip ?? ''}
|
||||
id="zip"
|
||||
name="zip"
|
||||
placeholder="Zip / Postal Code"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="country">Country*</label>
|
||||
<input
|
||||
aria-label="Country"
|
||||
autoComplete="country-name"
|
||||
defaultValue={address?.country ?? ''}
|
||||
id="country"
|
||||
name="country"
|
||||
placeholder="Country"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="phone">Phone</label>
|
||||
<input
|
||||
aria-label="Phone"
|
||||
autoComplete="tel"
|
||||
defaultValue={address?.phone ?? ''}
|
||||
id="phone"
|
||||
name="phone"
|
||||
placeholder="+16135551111"
|
||||
pattern="^\+?[1-9]\d{3,14}$"
|
||||
type="tel"
|
||||
/>
|
||||
<div>
|
||||
<input
|
||||
defaultChecked={isDefaultAddress}
|
||||
id="defaultAddress"
|
||||
name="defaultAddress"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label htmlFor="defaultAddress">Set as default address</label>
|
||||
</div>
|
||||
{error ? (
|
||||
<p>
|
||||
<mark>
|
||||
<small>{error}</small>
|
||||
</mark>
|
||||
</p>
|
||||
) : (
|
||||
<br />
|
||||
)}
|
||||
{children({
|
||||
stateForMethod: (method) => (formMethod === method ? state : 'idle'),
|
||||
})}
|
||||
</fieldset>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/2023-04/mutations/customeraddressupdate
|
||||
const UPDATE_ADDRESS_MUTATION = `#graphql
|
||||
mutation customerAddressUpdate(
|
||||
$address: MailingAddressInput!
|
||||
$customerAccessToken: String!
|
||||
$id: ID!
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customerAddressUpdate(
|
||||
address: $address
|
||||
customerAccessToken: $customerAccessToken
|
||||
id: $id
|
||||
) {
|
||||
customerAddress {
|
||||
id
|
||||
}
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customerAddressDelete
|
||||
const DELETE_ADDRESS_MUTATION = `#graphql
|
||||
mutation customerAddressDelete(
|
||||
$customerAccessToken: String!,
|
||||
$id: ID!,
|
||||
$country: CountryCode,
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customerAddressDelete(customerAccessToken: $customerAccessToken, id: $id) {
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
deletedCustomerAddressId
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customerdefaultaddressupdate
|
||||
const UPDATE_DEFAULT_ADDRESS_MUTATION = `#graphql
|
||||
mutation customerDefaultAddressUpdate(
|
||||
$addressId: ID!
|
||||
$customerAccessToken: String!
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customerDefaultAddressUpdate(
|
||||
addressId: $addressId
|
||||
customerAccessToken: $customerAccessToken
|
||||
) {
|
||||
customer {
|
||||
defaultAddress {
|
||||
id
|
||||
}
|
||||
}
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customeraddresscreate
|
||||
const CREATE_ADDRESS_MUTATION = `#graphql
|
||||
mutation customerAddressCreate(
|
||||
$address: MailingAddressInput!
|
||||
$customerAccessToken: String!
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customerAddressCreate(
|
||||
address: $address
|
||||
customerAccessToken: $customerAccessToken
|
||||
) {
|
||||
customerAddress {
|
||||
id
|
||||
}
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
309
examples/hydrogen-2/app/routes/account.orders.$id.tsx
Normal file
309
examples/hydrogen-2/app/routes/account.orders.$id.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import {json, redirect, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {Link, useLoaderData, type V2_MetaFunction} from '@remix-run/react';
|
||||
import {Money, Image, flattenConnection} from '@shopify/hydrogen';
|
||||
import type {OrderLineItemFullFragment} from 'storefrontapi.generated';
|
||||
|
||||
export const meta: V2_MetaFunction<typeof loader> = ({data}) => {
|
||||
return [{title: `Order ${data?.order?.name}`}];
|
||||
};
|
||||
|
||||
export async function loader({params, context}: LoaderArgs) {
|
||||
const {session, storefront} = context;
|
||||
|
||||
if (!params.id) {
|
||||
return redirect('/account/orders');
|
||||
}
|
||||
|
||||
const orderId = atob(params.id);
|
||||
const customerAccessToken = await session.get('customerAccessToken');
|
||||
|
||||
if (!customerAccessToken) {
|
||||
return redirect('/account/login');
|
||||
}
|
||||
|
||||
const {order} = await storefront.query(CUSTOMER_ORDER_QUERY, {
|
||||
variables: {orderId},
|
||||
});
|
||||
|
||||
if (!order || !('lineItems' in order)) {
|
||||
throw new Response('Order not found', {status: 404});
|
||||
}
|
||||
|
||||
const lineItems = flattenConnection(order.lineItems);
|
||||
const discountApplications = flattenConnection(order.discountApplications);
|
||||
|
||||
const firstDiscount = discountApplications[0]?.value;
|
||||
|
||||
const discountValue =
|
||||
firstDiscount?.__typename === 'MoneyV2' && firstDiscount;
|
||||
|
||||
const discountPercentage =
|
||||
firstDiscount?.__typename === 'PricingPercentageValue' &&
|
||||
firstDiscount?.percentage;
|
||||
|
||||
return json({
|
||||
order,
|
||||
lineItems,
|
||||
discountValue,
|
||||
discountPercentage,
|
||||
});
|
||||
}
|
||||
|
||||
export default function OrderRoute() {
|
||||
const {order, lineItems, discountValue, discountPercentage} =
|
||||
useLoaderData<typeof loader>();
|
||||
return (
|
||||
<div className="account-order">
|
||||
<h2>Order {order.name}</h2>
|
||||
<p>Placed on {new Date(order.processedAt!).toDateString()}</p>
|
||||
<br />
|
||||
<div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Product</th>
|
||||
<th scope="col">Price</th>
|
||||
<th scope="col">Quantity</th>
|
||||
<th scope="col">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lineItems.map((lineItem, lineItemIndex) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<OrderLineRow key={lineItemIndex} lineItem={lineItem} />
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
{((discountValue && discountValue.amount) ||
|
||||
discountPercentage) && (
|
||||
<tr>
|
||||
<th scope="row" colSpan={3}>
|
||||
<p>Discounts</p>
|
||||
</th>
|
||||
<th scope="row">
|
||||
<p>Discounts</p>
|
||||
</th>
|
||||
<td>
|
||||
{discountPercentage ? (
|
||||
<span>-{discountPercentage}% OFF</span>
|
||||
) : (
|
||||
discountValue && <Money data={discountValue!} />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<th scope="row" colSpan={3}>
|
||||
<p>Subtotal</p>
|
||||
</th>
|
||||
<th scope="row">
|
||||
<p>Subtotal</p>
|
||||
</th>
|
||||
<td>
|
||||
<Money data={order.subtotalPriceV2!} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row" colSpan={3}>
|
||||
Tax
|
||||
</th>
|
||||
<th scope="row">
|
||||
<p>Tax</p>
|
||||
</th>
|
||||
<td>
|
||||
<Money data={order.totalTaxV2!} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row" colSpan={3}>
|
||||
Total
|
||||
</th>
|
||||
<th scope="row">
|
||||
<p>Total</p>
|
||||
</th>
|
||||
<td>
|
||||
<Money data={order.totalPriceV2!} />
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<div>
|
||||
<h3>Shipping Address</h3>
|
||||
{order?.shippingAddress ? (
|
||||
<address>
|
||||
<p>
|
||||
{order.shippingAddress.firstName &&
|
||||
order.shippingAddress.firstName + ' '}
|
||||
{order.shippingAddress.lastName}
|
||||
</p>
|
||||
{order?.shippingAddress?.formatted ? (
|
||||
order.shippingAddress.formatted.map((line: string) => (
|
||||
<p key={line}>{line}</p>
|
||||
))
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</address>
|
||||
) : (
|
||||
<p>No shipping address defined</p>
|
||||
)}
|
||||
<h3>Status</h3>
|
||||
<div>
|
||||
<p>{order.fulfillmentStatus}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<p>
|
||||
<a target="_blank" href={order.statusUrl} rel="noreferrer">
|
||||
View Order Status →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderLineRow({lineItem}: {lineItem: OrderLineItemFullFragment}) {
|
||||
return (
|
||||
<tr key={lineItem.variant!.id}>
|
||||
<td>
|
||||
<div>
|
||||
<Link to={`/products/${lineItem.variant!.product!.handle}`}>
|
||||
{lineItem?.variant?.image && (
|
||||
<div>
|
||||
<Image data={lineItem.variant.image} width={96} height={96} />
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<div>
|
||||
<p>{lineItem.title}</p>
|
||||
<small>{lineItem.variant!.title}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<Money data={lineItem.variant!.price!} />
|
||||
</td>
|
||||
<td>{lineItem.quantity}</td>
|
||||
<td>
|
||||
<Money data={lineItem.discountedTotalPrice!} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/objects/Order
|
||||
const CUSTOMER_ORDER_QUERY = `#graphql
|
||||
fragment OrderMoney on MoneyV2 {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
fragment AddressFull on MailingAddress {
|
||||
address1
|
||||
address2
|
||||
city
|
||||
company
|
||||
country
|
||||
countryCodeV2
|
||||
firstName
|
||||
formatted
|
||||
id
|
||||
lastName
|
||||
name
|
||||
phone
|
||||
province
|
||||
provinceCode
|
||||
zip
|
||||
}
|
||||
fragment DiscountApplication on DiscountApplication {
|
||||
value {
|
||||
__typename
|
||||
... on MoneyV2 {
|
||||
...OrderMoney
|
||||
}
|
||||
... on PricingPercentageValue {
|
||||
percentage
|
||||
}
|
||||
}
|
||||
}
|
||||
fragment OrderLineProductVariant on ProductVariant {
|
||||
id
|
||||
image {
|
||||
altText
|
||||
height
|
||||
url
|
||||
id
|
||||
width
|
||||
}
|
||||
price {
|
||||
...OrderMoney
|
||||
}
|
||||
product {
|
||||
handle
|
||||
}
|
||||
sku
|
||||
title
|
||||
}
|
||||
fragment OrderLineItemFull on OrderLineItem {
|
||||
title
|
||||
quantity
|
||||
discountAllocations {
|
||||
allocatedAmount {
|
||||
...OrderMoney
|
||||
}
|
||||
discountApplication {
|
||||
...DiscountApplication
|
||||
}
|
||||
}
|
||||
originalTotalPrice {
|
||||
...OrderMoney
|
||||
}
|
||||
discountedTotalPrice {
|
||||
...OrderMoney
|
||||
}
|
||||
variant {
|
||||
...OrderLineProductVariant
|
||||
}
|
||||
}
|
||||
fragment Order on Order {
|
||||
id
|
||||
name
|
||||
orderNumber
|
||||
statusUrl
|
||||
processedAt
|
||||
fulfillmentStatus
|
||||
totalTaxV2 {
|
||||
...OrderMoney
|
||||
}
|
||||
totalPriceV2 {
|
||||
...OrderMoney
|
||||
}
|
||||
subtotalPriceV2 {
|
||||
...OrderMoney
|
||||
}
|
||||
shippingAddress {
|
||||
...AddressFull
|
||||
}
|
||||
discountApplications(first: 100) {
|
||||
nodes {
|
||||
...DiscountApplication
|
||||
}
|
||||
}
|
||||
lineItems(first: 100) {
|
||||
nodes {
|
||||
...OrderLineItemFull
|
||||
}
|
||||
}
|
||||
}
|
||||
query Order(
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
$orderId: ID!
|
||||
) @inContext(country: $country, language: $language) {
|
||||
order: node(id: $orderId) {
|
||||
... on Order {
|
||||
...Order
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
196
examples/hydrogen-2/app/routes/account.orders._index.tsx
Normal file
196
examples/hydrogen-2/app/routes/account.orders._index.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import {Link, useLoaderData} from '@remix-run/react';
|
||||
import {Money, Pagination, getPaginationVariables} from '@shopify/hydrogen';
|
||||
import {
|
||||
json,
|
||||
redirect,
|
||||
type LoaderArgs,
|
||||
type V2_MetaFunction,
|
||||
} from '@shopify/remix-oxygen';
|
||||
import type {
|
||||
CustomerOrdersFragment,
|
||||
OrderItemFragment,
|
||||
} from 'storefrontapi.generated';
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: 'Orders'}];
|
||||
};
|
||||
|
||||
export async function loader({request, context}: LoaderArgs) {
|
||||
const {session, storefront} = context;
|
||||
|
||||
const customerAccessToken = await session.get('customerAccessToken');
|
||||
if (!customerAccessToken?.accessToken) {
|
||||
return redirect('/account/login');
|
||||
}
|
||||
|
||||
try {
|
||||
const paginationVariables = getPaginationVariables(request, {
|
||||
pageBy: 20,
|
||||
});
|
||||
|
||||
const {customer} = await storefront.query(CUSTOMER_ORDERS_QUERY, {
|
||||
variables: {
|
||||
customerAccessToken: customerAccessToken.accessToken,
|
||||
country: storefront.i18n.country,
|
||||
language: storefront.i18n.language,
|
||||
...paginationVariables,
|
||||
},
|
||||
cache: storefront.CacheNone(),
|
||||
});
|
||||
|
||||
if (!customer) {
|
||||
throw new Error('Customer not found');
|
||||
}
|
||||
|
||||
return json({customer});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return json({error: error.message}, {status: 400});
|
||||
}
|
||||
return json({error}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Orders() {
|
||||
const {customer} = useLoaderData<{customer: CustomerOrdersFragment}>();
|
||||
const {orders, numberOfOrders} = customer;
|
||||
return (
|
||||
<div className="orders">
|
||||
<h2>
|
||||
Orders <small>({numberOfOrders})</small>
|
||||
</h2>
|
||||
<br />
|
||||
{orders.nodes.length ? <OrdersTable orders={orders} /> : <EmptyOrders />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrdersTable({orders}: Pick<CustomerOrdersFragment, 'orders'>) {
|
||||
return (
|
||||
<div className="acccount-orders">
|
||||
{orders?.nodes.length ? (
|
||||
<Pagination connection={orders}>
|
||||
{({nodes, isLoading, PreviousLink, NextLink}) => {
|
||||
return (
|
||||
<>
|
||||
<PreviousLink>
|
||||
{isLoading ? 'Loading...' : <span>↑ Load previous</span>}
|
||||
</PreviousLink>
|
||||
{nodes.map((order) => {
|
||||
return <OrderItem key={order.id} order={order} />;
|
||||
})}
|
||||
<NextLink>
|
||||
{isLoading ? 'Loading...' : <span>Load more ↓</span>}
|
||||
</NextLink>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Pagination>
|
||||
) : (
|
||||
<EmptyOrders />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyOrders() {
|
||||
return (
|
||||
<div>
|
||||
<p>You haven't placed any orders yet.</p>
|
||||
<br />
|
||||
<p>
|
||||
<Link to="/collections">Start Shopping →</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderItem({order}: {order: OrderItemFragment}) {
|
||||
return (
|
||||
<>
|
||||
<fieldset>
|
||||
<Link to={`/account/orders/${order.id}`}>
|
||||
<strong>#{order.orderNumber}</strong>
|
||||
</Link>
|
||||
<p>{new Date(order.processedAt).toDateString()}</p>
|
||||
<p>{order.financialStatus}</p>
|
||||
<p>{order.fulfillmentStatus}</p>
|
||||
<Money data={order.currentTotalPrice} />
|
||||
<Link to={`/account/orders/${btoa(order.id)}`}>View Order →</Link>
|
||||
</fieldset>
|
||||
<br />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ORDER_ITEM_FRAGMENT = `#graphql
|
||||
fragment OrderItem on Order {
|
||||
currentTotalPrice {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
financialStatus
|
||||
fulfillmentStatus
|
||||
id
|
||||
lineItems(first: 10) {
|
||||
nodes {
|
||||
title
|
||||
variant {
|
||||
image {
|
||||
url
|
||||
altText
|
||||
height
|
||||
width
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
orderNumber
|
||||
customerUrl
|
||||
statusUrl
|
||||
processedAt
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const CUSTOMER_FRAGMENT = `#graphql
|
||||
fragment CustomerOrders on Customer {
|
||||
numberOfOrders
|
||||
orders(
|
||||
sortKey: PROCESSED_AT,
|
||||
reverse: true,
|
||||
first: $first,
|
||||
last: $last,
|
||||
before: $startCursor,
|
||||
after: $endCursor
|
||||
) {
|
||||
nodes {
|
||||
...OrderItem
|
||||
}
|
||||
pageInfo {
|
||||
hasPreviousPage
|
||||
hasNextPage
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
${ORDER_ITEM_FRAGMENT}
|
||||
` as const;
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/queries/customer
|
||||
const CUSTOMER_ORDERS_QUERY = `#graphql
|
||||
${CUSTOMER_FRAGMENT}
|
||||
query CustomerOrders(
|
||||
$country: CountryCode
|
||||
$customerAccessToken: String!
|
||||
$endCursor: String
|
||||
$first: Int
|
||||
$language: LanguageCode
|
||||
$last: Int
|
||||
$startCursor: String
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customer(customerAccessToken: $customerAccessToken) {
|
||||
...CustomerOrders
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
289
examples/hydrogen-2/app/routes/account.profile.tsx
Normal file
289
examples/hydrogen-2/app/routes/account.profile.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import type {CustomerFragment} from 'storefrontapi.generated';
|
||||
import type {CustomerUpdateInput} from '@shopify/hydrogen/storefront-api-types';
|
||||
import type {ActionArgs, LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {json, redirect, type V2_MetaFunction} from '@shopify/remix-oxygen';
|
||||
import {
|
||||
Form,
|
||||
useActionData,
|
||||
useNavigation,
|
||||
useOutletContext,
|
||||
} from '@remix-run/react';
|
||||
|
||||
export type ActionResponse = {
|
||||
error: string | null;
|
||||
customer: CustomerFragment | null;
|
||||
};
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: 'Profile'}];
|
||||
};
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
const customerAccessToken = await context.session.get('customerAccessToken');
|
||||
if (!customerAccessToken) {
|
||||
return redirect('/account/login');
|
||||
}
|
||||
return json({});
|
||||
}
|
||||
|
||||
export async function action({request, context}: ActionArgs) {
|
||||
const {session, storefront} = context;
|
||||
|
||||
if (request.method !== 'PUT') {
|
||||
return json({error: 'Method not allowed'}, {status: 405});
|
||||
}
|
||||
|
||||
const form = await request.formData();
|
||||
const customerAccessToken = await session.get('customerAccessToken');
|
||||
if (!customerAccessToken) {
|
||||
return json({error: 'Unauthorized'}, {status: 401});
|
||||
}
|
||||
|
||||
try {
|
||||
const password = getPassword(form);
|
||||
const customer: CustomerUpdateInput = {};
|
||||
const validInputKeys = [
|
||||
'firstName',
|
||||
'lastName',
|
||||
'email',
|
||||
'password',
|
||||
'phone',
|
||||
] as const;
|
||||
for (const [key, value] of form.entries()) {
|
||||
if (!validInputKeys.includes(key as any)) {
|
||||
continue;
|
||||
}
|
||||
if (key === 'acceptsMarketing') {
|
||||
customer.acceptsMarketing = value === 'on';
|
||||
}
|
||||
if (typeof value === 'string' && value.length) {
|
||||
customer[key as (typeof validInputKeys)[number]] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (password) {
|
||||
customer.password = password;
|
||||
}
|
||||
|
||||
// update customer and possibly password
|
||||
const updated = await storefront.mutate(CUSTOMER_UPDATE_MUTATION, {
|
||||
variables: {
|
||||
customerAccessToken: customerAccessToken.accessToken,
|
||||
customer,
|
||||
},
|
||||
});
|
||||
|
||||
// check for mutation errors
|
||||
if (updated.customerUpdate?.customerUserErrors?.length) {
|
||||
return json(
|
||||
{error: updated.customerUpdate?.customerUserErrors[0]},
|
||||
{status: 400},
|
||||
);
|
||||
}
|
||||
|
||||
// update session with the updated access token
|
||||
if (updated.customerUpdate?.customerAccessToken?.accessToken) {
|
||||
session.set(
|
||||
'customerAccessToken',
|
||||
updated.customerUpdate?.customerAccessToken,
|
||||
);
|
||||
}
|
||||
|
||||
return json(
|
||||
{error: null, customer: updated.customerUpdate?.customer},
|
||||
{
|
||||
headers: {
|
||||
'Set-Cookie': await session.commit(),
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error: any) {
|
||||
return json({error: error.message, customer: null}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
export default function AccountProfile() {
|
||||
const account = useOutletContext<{customer: CustomerFragment}>();
|
||||
const {state} = useNavigation();
|
||||
const action = useActionData<ActionResponse>();
|
||||
const customer = action?.customer ?? account?.customer;
|
||||
|
||||
return (
|
||||
<div className="account-profile">
|
||||
<h2>My profile</h2>
|
||||
<br />
|
||||
<Form method="PUT">
|
||||
<legend>Personal information</legend>
|
||||
<fieldset>
|
||||
<label htmlFor="firstName">First name</label>
|
||||
<input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
placeholder="First name"
|
||||
aria-label="First name"
|
||||
defaultValue={customer.firstName ?? ''}
|
||||
minLength={2}
|
||||
/>
|
||||
<label htmlFor="lastName">Last name</label>
|
||||
<input
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
type="text"
|
||||
autoComplete="family-name"
|
||||
placeholder="Last name"
|
||||
aria-label="Last name"
|
||||
defaultValue={customer.lastName ?? ''}
|
||||
minLength={2}
|
||||
/>
|
||||
<label htmlFor="phone">Mobile</label>
|
||||
<input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
placeholder="Mobile"
|
||||
aria-label="Mobile"
|
||||
defaultValue={customer.phone ?? ''}
|
||||
/>
|
||||
<label htmlFor="email">Email address</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="Email address"
|
||||
aria-label="Email address"
|
||||
defaultValue={customer.email ?? ''}
|
||||
/>
|
||||
<div className="account-profile-marketing">
|
||||
<input
|
||||
id="acceptsMarketing"
|
||||
name="acceptsMarketing"
|
||||
type="checkbox"
|
||||
placeholder="Accept marketing"
|
||||
aria-label="Accept marketing"
|
||||
defaultChecked={customer.acceptsMarketing}
|
||||
/>
|
||||
<label htmlFor="acceptsMarketing">
|
||||
Subscribed to marketing communications
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<br />
|
||||
<legend>Change password (optional)</legend>
|
||||
<fieldset>
|
||||
<label htmlFor="currentPassword">Current password</label>
|
||||
<input
|
||||
id="currentPassword"
|
||||
name="currentPassword"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Current password"
|
||||
aria-label="Current password"
|
||||
minLength={8}
|
||||
/>
|
||||
|
||||
<label htmlFor="newPassword">New password</label>
|
||||
<input
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
type="password"
|
||||
placeholder="New password"
|
||||
aria-label="New password"
|
||||
minLength={8}
|
||||
/>
|
||||
|
||||
<label htmlFor="newPasswordConfirm">New password (confirm)</label>
|
||||
<input
|
||||
id="newPasswordConfirm"
|
||||
name="newPasswordConfirm"
|
||||
type="password"
|
||||
placeholder="New password (confirm)"
|
||||
aria-label="New password confirm"
|
||||
minLength={8}
|
||||
/>
|
||||
<small>Passwords must be at least 8 characters.</small>
|
||||
</fieldset>
|
||||
{action?.error ? (
|
||||
<p>
|
||||
<mark>
|
||||
<small>{action.error}</small>
|
||||
</mark>
|
||||
</p>
|
||||
) : (
|
||||
<br />
|
||||
)}
|
||||
<button type="submit" disabled={state !== 'idle'}>
|
||||
{state !== 'idle' ? 'Updating' : 'Update'}
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getPassword(form: FormData): string | undefined {
|
||||
let password;
|
||||
const currentPassword = form.get('currentPassword');
|
||||
const newPassword = form.get('newPassword');
|
||||
const newPasswordConfirm = form.get('newPasswordConfirm');
|
||||
|
||||
let passwordError;
|
||||
if (newPassword && !currentPassword) {
|
||||
passwordError = new Error('Current password is required.');
|
||||
}
|
||||
|
||||
if (newPassword && newPassword !== newPasswordConfirm) {
|
||||
passwordError = new Error('New passwords must match.');
|
||||
}
|
||||
|
||||
if (newPassword && currentPassword && newPassword === currentPassword) {
|
||||
passwordError = new Error(
|
||||
'New password must be different than current password.',
|
||||
);
|
||||
}
|
||||
|
||||
if (passwordError) {
|
||||
throw passwordError;
|
||||
}
|
||||
|
||||
if (currentPassword && newPassword) {
|
||||
password = newPassword;
|
||||
} else {
|
||||
password = currentPassword;
|
||||
}
|
||||
|
||||
return String(password);
|
||||
}
|
||||
|
||||
const CUSTOMER_UPDATE_MUTATION = `#graphql
|
||||
# https://shopify.dev/docs/api/storefront/latest/mutations/customerUpdate
|
||||
mutation customerUpdate(
|
||||
$customerAccessToken: String!,
|
||||
$customer: CustomerUpdateInput!
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
) @inContext(language: $language, country: $country) {
|
||||
customerUpdate(customerAccessToken: $customerAccessToken, customer: $customer) {
|
||||
customer {
|
||||
acceptsMarketing
|
||||
email
|
||||
firstName
|
||||
id
|
||||
lastName
|
||||
phone
|
||||
}
|
||||
customerAccessToken {
|
||||
accessToken
|
||||
expiresAt
|
||||
}
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
203
examples/hydrogen-2/app/routes/account.tsx
Normal file
203
examples/hydrogen-2/app/routes/account.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import {Form, NavLink, Outlet, useLoaderData} from '@remix-run/react';
|
||||
import {json, redirect, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import type {CustomerFragment} from 'storefrontapi.generated';
|
||||
|
||||
export function shouldRevalidate() {
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function loader({request, context}: LoaderArgs) {
|
||||
const {session, storefront} = context;
|
||||
const {pathname} = new URL(request.url);
|
||||
const customerAccessToken = await session.get('customerAccessToken');
|
||||
const isLoggedIn = Boolean(customerAccessToken?.accessToken);
|
||||
const isAccountHome = pathname === '/account' || pathname === '/account/';
|
||||
const isPrivateRoute =
|
||||
/^\/account\/(orders|orders\/.*|profile|addresses|addresses\/.*)$/.test(
|
||||
pathname,
|
||||
);
|
||||
|
||||
if (!isLoggedIn) {
|
||||
if (isPrivateRoute || isAccountHome) {
|
||||
session.unset('customerAccessToken');
|
||||
return redirect('/account/login', {
|
||||
headers: {
|
||||
'Set-Cookie': await session.commit(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// public subroute such as /account/login...
|
||||
return json({
|
||||
isLoggedIn: false,
|
||||
isAccountHome,
|
||||
isPrivateRoute,
|
||||
customer: null,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// loggedIn, default redirect to the orders page
|
||||
if (isAccountHome) {
|
||||
return redirect('/account/orders');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const {customer} = await storefront.query(CUSTOMER_QUERY, {
|
||||
variables: {
|
||||
customerAccessToken: customerAccessToken.accessToken,
|
||||
country: storefront.i18n.country,
|
||||
language: storefront.i18n.language,
|
||||
},
|
||||
cache: storefront.CacheNone(),
|
||||
});
|
||||
|
||||
if (!customer) {
|
||||
throw new Error('Customer not found');
|
||||
}
|
||||
|
||||
return json(
|
||||
{isLoggedIn, isPrivateRoute, isAccountHome, customer},
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('There was a problem loading account', error);
|
||||
session.unset('customerAccessToken');
|
||||
return redirect('/account/login', {
|
||||
headers: {
|
||||
'Set-Cookie': await session.commit(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Acccount() {
|
||||
const {customer, isPrivateRoute, isAccountHome} =
|
||||
useLoaderData<typeof loader>();
|
||||
|
||||
if (!isPrivateRoute && !isAccountHome) {
|
||||
return <Outlet context={{customer}} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AccountLayout customer={customer as CustomerFragment}>
|
||||
<br />
|
||||
<br />
|
||||
<Outlet context={{customer}} />
|
||||
</AccountLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountLayout({
|
||||
customer,
|
||||
children,
|
||||
}: {
|
||||
customer: CustomerFragment;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const heading = customer
|
||||
? customer.firstName
|
||||
? `Welcome, ${customer.firstName}`
|
||||
: `Welcome to your account.`
|
||||
: 'Account Details';
|
||||
|
||||
return (
|
||||
<div className="account">
|
||||
<h1>{heading}</h1>
|
||||
<br />
|
||||
<AcccountMenu />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AcccountMenu() {
|
||||
function isActiveStyle({
|
||||
isActive,
|
||||
isPending,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
return {
|
||||
fontWeight: isActive ? 'bold' : '',
|
||||
color: isPending ? 'grey' : 'black',
|
||||
};
|
||||
}
|
||||
return (
|
||||
<nav role="navigation">
|
||||
<NavLink to="/account/orders" style={isActiveStyle}>
|
||||
Orders
|
||||
</NavLink>
|
||||
|
|
||||
<NavLink to="/account/profile" style={isActiveStyle}>
|
||||
Profile
|
||||
</NavLink>
|
||||
|
|
||||
<NavLink to="/account/addresses" style={isActiveStyle}>
|
||||
Addresses
|
||||
</NavLink>
|
||||
|
|
||||
<Logout />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function Logout() {
|
||||
return (
|
||||
<Form className="account-logout" method="POST" action="/account/logout">
|
||||
<button type="submit">Sign out</button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export const CUSTOMER_FRAGMENT = `#graphql
|
||||
fragment Customer on Customer {
|
||||
acceptsMarketing
|
||||
addresses(first: 6) {
|
||||
nodes {
|
||||
...Address
|
||||
}
|
||||
}
|
||||
defaultAddress {
|
||||
...Address
|
||||
}
|
||||
email
|
||||
firstName
|
||||
lastName
|
||||
numberOfOrders
|
||||
phone
|
||||
}
|
||||
fragment Address on MailingAddress {
|
||||
id
|
||||
formatted
|
||||
firstName
|
||||
lastName
|
||||
company
|
||||
address1
|
||||
address2
|
||||
country
|
||||
province
|
||||
city
|
||||
zip
|
||||
phone
|
||||
}
|
||||
` as const;
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/queries/customer
|
||||
const CUSTOMER_QUERY = `#graphql
|
||||
query Customer(
|
||||
$customerAccessToken: String!
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customer(customerAccessToken: $customerAccessToken) {
|
||||
...Customer
|
||||
}
|
||||
}
|
||||
${CUSTOMER_FRAGMENT}
|
||||
` as const;
|
||||
@@ -0,0 +1,157 @@
|
||||
import type {ActionArgs, LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {json, redirect} from '@shopify/remix-oxygen';
|
||||
import {Form, useActionData, type V2_MetaFunction} from '@remix-run/react';
|
||||
|
||||
type ActionResponse = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: 'Activate Account'}];
|
||||
};
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
if (await context.session.get('customerAccessToken')) {
|
||||
return redirect('/account');
|
||||
}
|
||||
return json({});
|
||||
}
|
||||
|
||||
export async function action({request, context, params}: ActionArgs) {
|
||||
const {session, storefront} = context;
|
||||
const {id, activationToken} = params;
|
||||
|
||||
if (request.method !== 'POST') {
|
||||
return json({error: 'Method not allowed'}, {status: 405});
|
||||
}
|
||||
|
||||
try {
|
||||
if (!id || !activationToken) {
|
||||
throw new Error('Missing token. The link you followed might be wrong.');
|
||||
}
|
||||
|
||||
const form = await request.formData();
|
||||
const password = form.has('password') ? String(form.get('password')) : null;
|
||||
const passwordConfirm = form.has('passwordConfirm')
|
||||
? String(form.get('passwordConfirm'))
|
||||
: null;
|
||||
|
||||
const validPasswords =
|
||||
password && passwordConfirm && password === passwordConfirm;
|
||||
|
||||
if (!validPasswords) {
|
||||
throw new Error('Passwords do not match');
|
||||
}
|
||||
|
||||
const {customerActivate} = await storefront.mutate(
|
||||
CUSTOMER_ACTIVATE_MUTATION,
|
||||
{
|
||||
variables: {
|
||||
id: `gid://shopify/Customer/${id}`,
|
||||
input: {
|
||||
password,
|
||||
activationToken,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (customerActivate?.customerUserErrors?.length) {
|
||||
throw new Error(customerActivate.customerUserErrors[0].message);
|
||||
}
|
||||
|
||||
const {customerAccessToken} = customerActivate ?? {};
|
||||
if (!customerAccessToken) {
|
||||
throw new Error('Could not activate account.');
|
||||
}
|
||||
session.set('customerAccessToken', customerAccessToken);
|
||||
|
||||
return redirect('/account', {
|
||||
headers: {
|
||||
'Set-Cookie': await session.commit(),
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return json({error: error.message}, {status: 400});
|
||||
}
|
||||
return json({error}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Activate() {
|
||||
const action = useActionData<ActionResponse>();
|
||||
const error = action?.error ?? null;
|
||||
|
||||
return (
|
||||
<div className="account-activate">
|
||||
<h1>Activate Account.</h1>
|
||||
<p>Create your password to activate your account.</p>
|
||||
<Form method="POST">
|
||||
<fieldset>
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Password"
|
||||
aria-label="Password"
|
||||
minLength={8}
|
||||
required
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
/>
|
||||
<label htmlFor="passwordConfirm">Re-enter password</label>
|
||||
<input
|
||||
id="passwordConfirm"
|
||||
name="passwordConfirm"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Re-enter password"
|
||||
aria-label="Re-enter password"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</fieldset>
|
||||
{error ? (
|
||||
<p>
|
||||
<mark>
|
||||
<small>{error}</small>
|
||||
</mark>
|
||||
</p>
|
||||
) : (
|
||||
<br />
|
||||
)}
|
||||
<button
|
||||
className="bg-primary text-contrast rounded py-2 px-4 focus:shadow-outline block w-full"
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customeractivate
|
||||
const CUSTOMER_ACTIVATE_MUTATION = `#graphql
|
||||
mutation customerActivate(
|
||||
$id: ID!,
|
||||
$input: CustomerActivateInput!,
|
||||
$country: CountryCode,
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customerActivate(id: $id, input: $input) {
|
||||
customerAccessToken {
|
||||
accessToken
|
||||
expiresAt
|
||||
}
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
143
examples/hydrogen-2/app/routes/account_.login.tsx
Normal file
143
examples/hydrogen-2/app/routes/account_.login.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
json,
|
||||
redirect,
|
||||
type ActionArgs,
|
||||
type LoaderArgs,
|
||||
type V2_MetaFunction,
|
||||
} from '@shopify/remix-oxygen';
|
||||
import {Form, Link, useActionData} from '@remix-run/react';
|
||||
|
||||
type ActionResponse = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: 'Login'}];
|
||||
};
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
if (await context.session.get('customerAccessToken')) {
|
||||
return redirect('/account');
|
||||
}
|
||||
return json({});
|
||||
}
|
||||
|
||||
export async function action({request, context}: ActionArgs) {
|
||||
const {session, storefront} = context;
|
||||
|
||||
if (request.method !== 'POST') {
|
||||
return json({error: 'Method not allowed'}, {status: 405});
|
||||
}
|
||||
|
||||
try {
|
||||
const form = await request.formData();
|
||||
const email = String(form.has('email') ? form.get('email') : '');
|
||||
const password = String(form.has('password') ? form.get('password') : '');
|
||||
const validInputs = Boolean(email && password);
|
||||
|
||||
if (!validInputs) {
|
||||
throw new Error('Please provide both an email and a password.');
|
||||
}
|
||||
|
||||
const {customerAccessTokenCreate} = await storefront.mutate(
|
||||
LOGIN_MUTATION,
|
||||
{
|
||||
variables: {
|
||||
input: {email, password},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!customerAccessTokenCreate?.customerAccessToken?.accessToken) {
|
||||
throw new Error(customerAccessTokenCreate?.customerUserErrors[0].message);
|
||||
}
|
||||
|
||||
const {customerAccessToken} = customerAccessTokenCreate;
|
||||
session.set('customerAccessToken', customerAccessToken);
|
||||
|
||||
return redirect('/account', {
|
||||
headers: {
|
||||
'Set-Cookie': await session.commit(),
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return json({error: error.message}, {status: 400});
|
||||
}
|
||||
return json({error}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const data = useActionData<ActionResponse>();
|
||||
const error = data?.error || null;
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<h1>Sign in.</h1>
|
||||
<Form method="POST">
|
||||
<fieldset>
|
||||
<label htmlFor="email">Email address</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="Email address"
|
||||
aria-label="Email address"
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
/>
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Password"
|
||||
aria-label="Password"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</fieldset>
|
||||
{error ? (
|
||||
<p>
|
||||
<mark>
|
||||
<small>{error}</small>
|
||||
</mark>
|
||||
</p>
|
||||
) : (
|
||||
<br />
|
||||
)}
|
||||
<button type="submit">Sign in</button>
|
||||
</Form>
|
||||
<br />
|
||||
<div>
|
||||
<p>
|
||||
<Link to="/account/recover">Forgot password →</Link>
|
||||
</p>
|
||||
<p>
|
||||
<Link to="/account/register">Register →</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customeraccesstokencreate
|
||||
const LOGIN_MUTATION = `#graphql
|
||||
mutation login($input: CustomerAccessTokenCreateInput!) {
|
||||
customerAccessTokenCreate(input: $input) {
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
customerAccessToken {
|
||||
accessToken
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
33
examples/hydrogen-2/app/routes/account_.logout.tsx
Normal file
33
examples/hydrogen-2/app/routes/account_.logout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
json,
|
||||
redirect,
|
||||
type ActionArgs,
|
||||
type V2_MetaFunction,
|
||||
} from '@shopify/remix-oxygen';
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: 'Logout'}];
|
||||
};
|
||||
|
||||
export async function loader() {
|
||||
return redirect('/account/login');
|
||||
}
|
||||
|
||||
export async function action({request, context}: ActionArgs) {
|
||||
const {session} = context;
|
||||
session.unset('customerAccessToken');
|
||||
|
||||
if (request.method !== 'POST') {
|
||||
return json({error: 'Method not allowed'}, {status: 405});
|
||||
}
|
||||
|
||||
return redirect('/', {
|
||||
headers: {
|
||||
'Set-Cookie': await session.commit(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default function Logout() {
|
||||
return null;
|
||||
}
|
||||
124
examples/hydrogen-2/app/routes/account_.recover.tsx
Normal file
124
examples/hydrogen-2/app/routes/account_.recover.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import {json, redirect, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {Form, Link, useActionData} from '@remix-run/react';
|
||||
|
||||
type ActionResponse = {
|
||||
error?: string;
|
||||
resetRequested?: boolean;
|
||||
};
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
const customerAccessToken = await context.session.get('customerAccessToken');
|
||||
if (customerAccessToken) {
|
||||
return redirect('/account');
|
||||
}
|
||||
|
||||
return json({});
|
||||
}
|
||||
|
||||
export async function action({request, context}: LoaderArgs) {
|
||||
const {storefront} = context;
|
||||
const form = await request.formData();
|
||||
const email = form.has('email') ? String(form.get('email')) : null;
|
||||
|
||||
if (request.method !== 'POST') {
|
||||
return json({error: 'Method not allowed'}, {status: 405});
|
||||
}
|
||||
|
||||
try {
|
||||
if (!email) {
|
||||
throw new Error('Please provide an email.');
|
||||
}
|
||||
await storefront.mutate(CUSTOMER_RECOVER_MUTATION, {
|
||||
variables: {email},
|
||||
});
|
||||
|
||||
return json({resetRequested: true});
|
||||
} catch (error: unknown) {
|
||||
const resetRequested = false;
|
||||
if (error instanceof Error) {
|
||||
return json({error: error.message, resetRequested}, {status: 400});
|
||||
}
|
||||
return json({error, resetRequested}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Recover() {
|
||||
const action = useActionData<ActionResponse>();
|
||||
|
||||
return (
|
||||
<div className="account-recover">
|
||||
<div>
|
||||
{action?.resetRequested ? (
|
||||
<>
|
||||
<h1>Request Sent.</h1>
|
||||
<p>
|
||||
If that email address is in our system, you will receive an email
|
||||
with instructions about how to reset your password in a few
|
||||
minutes.
|
||||
</p>
|
||||
<br />
|
||||
<Link to="/account/login">Return to Login</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1>Forgot Password.</h1>
|
||||
<p>
|
||||
Enter the email address associated with your account to receive a
|
||||
link to reset your password.
|
||||
</p>
|
||||
<br />
|
||||
<Form method="POST">
|
||||
<fieldset>
|
||||
<label htmlFor="email">Email</label>
|
||||
<input
|
||||
aria-label="Email address"
|
||||
autoComplete="email"
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="Email address"
|
||||
required
|
||||
type="email"
|
||||
/>
|
||||
</fieldset>
|
||||
{action?.error ? (
|
||||
<p>
|
||||
<mark>
|
||||
<small>{action.error}</small>
|
||||
</mark>
|
||||
</p>
|
||||
) : (
|
||||
<br />
|
||||
)}
|
||||
<button type="submit">Request Reset Link</button>
|
||||
</Form>
|
||||
<div>
|
||||
<br />
|
||||
<p>
|
||||
<Link to="/account/login">Login →</Link>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customerrecover
|
||||
const CUSTOMER_RECOVER_MUTATION = `#graphql
|
||||
mutation customerRecover(
|
||||
$email: String!,
|
||||
$country: CountryCode,
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customerRecover(email: $email) {
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
207
examples/hydrogen-2/app/routes/account_.register.tsx
Normal file
207
examples/hydrogen-2/app/routes/account_.register.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
json,
|
||||
redirect,
|
||||
type ActionFunction,
|
||||
type LoaderArgs,
|
||||
} from '@shopify/remix-oxygen';
|
||||
import {Form, Link, useActionData} from '@remix-run/react';
|
||||
import type {CustomerCreateMutation} from 'storefrontapi.generated';
|
||||
|
||||
type ActionResponse = {
|
||||
error: string | null;
|
||||
newCustomer:
|
||||
| NonNullable<CustomerCreateMutation['customerCreate']>['customer']
|
||||
| null;
|
||||
};
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
const customerAccessToken = await context.session.get('customerAccessToken');
|
||||
if (customerAccessToken) {
|
||||
return redirect('/account');
|
||||
}
|
||||
|
||||
return json({});
|
||||
}
|
||||
|
||||
export const action: ActionFunction = async ({request, context}) => {
|
||||
if (request.method !== 'POST') {
|
||||
return json({error: 'Method not allowed'}, {status: 405});
|
||||
}
|
||||
|
||||
const {storefront, session} = context;
|
||||
const form = await request.formData();
|
||||
const email = String(form.has('email') ? form.get('email') : '');
|
||||
const password = form.has('password') ? String(form.get('password')) : null;
|
||||
const passwordConfirm = form.has('passwordConfirm')
|
||||
? String(form.get('passwordConfirm'))
|
||||
: null;
|
||||
|
||||
const validPasswords =
|
||||
password && passwordConfirm && password === passwordConfirm;
|
||||
|
||||
const validInputs = Boolean(email && password);
|
||||
try {
|
||||
if (!validPasswords) {
|
||||
throw new Error('Passwords do not match');
|
||||
}
|
||||
|
||||
if (!validInputs) {
|
||||
throw new Error('Please provide both an email and a password.');
|
||||
}
|
||||
|
||||
const {customerCreate} = await storefront.mutate(CUSTOMER_CREATE_MUTATION, {
|
||||
variables: {
|
||||
input: {email, password},
|
||||
},
|
||||
});
|
||||
|
||||
if (customerCreate?.customerUserErrors?.length) {
|
||||
throw new Error(customerCreate?.customerUserErrors[0].message);
|
||||
}
|
||||
|
||||
const newCustomer = customerCreate?.customer;
|
||||
if (!newCustomer?.id) {
|
||||
throw new Error('Could not create customer');
|
||||
}
|
||||
|
||||
// get an access token for the new customer
|
||||
const {customerAccessTokenCreate} = await storefront.mutate(
|
||||
REGISTER_LOGIN_MUTATION,
|
||||
{
|
||||
variables: {
|
||||
input: {
|
||||
email,
|
||||
password,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!customerAccessTokenCreate?.customerAccessToken?.accessToken) {
|
||||
throw new Error('Missing access token');
|
||||
}
|
||||
session.set(
|
||||
'customerAccessToken',
|
||||
customerAccessTokenCreate?.customerAccessToken,
|
||||
);
|
||||
|
||||
return json(
|
||||
{error: null, newCustomer},
|
||||
{
|
||||
status: 302,
|
||||
headers: {
|
||||
'Set-Cookie': await session.commit(),
|
||||
Location: '/account',
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return json({error: error.message}, {status: 400});
|
||||
}
|
||||
return json({error}, {status: 400});
|
||||
}
|
||||
};
|
||||
|
||||
export default function Register() {
|
||||
const data = useActionData<ActionResponse>();
|
||||
const error = data?.error || null;
|
||||
return (
|
||||
<div className="login">
|
||||
<h1>Register.</h1>
|
||||
<Form method="POST">
|
||||
<fieldset>
|
||||
<label htmlFor="email">Email address</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="Email address"
|
||||
aria-label="Email address"
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
/>
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Password"
|
||||
aria-label="Password"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
<label htmlFor="passwordConfirm">Re-enter password</label>
|
||||
<input
|
||||
id="passwordConfirm"
|
||||
name="passwordConfirm"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Re-enter password"
|
||||
aria-label="Re-enter password"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</fieldset>
|
||||
{error ? (
|
||||
<p>
|
||||
<mark>
|
||||
<small>{error}</small>
|
||||
</mark>
|
||||
</p>
|
||||
) : (
|
||||
<br />
|
||||
)}
|
||||
<button type="submit">Register</button>
|
||||
</Form>
|
||||
<br />
|
||||
<p>
|
||||
<Link to="/account/login">Login →</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customerCreate
|
||||
const CUSTOMER_CREATE_MUTATION = `#graphql
|
||||
mutation customerCreate(
|
||||
$input: CustomerCreateInput!,
|
||||
$country: CountryCode,
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customerCreate(input: $input) {
|
||||
customer {
|
||||
id
|
||||
}
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customeraccesstokencreate
|
||||
const REGISTER_LOGIN_MUTATION = `#graphql
|
||||
mutation registerLogin(
|
||||
$input: CustomerAccessTokenCreateInput!,
|
||||
$country: CountryCode,
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customerAccessTokenCreate(input: $input) {
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
customerAccessToken {
|
||||
accessToken
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
@@ -0,0 +1,136 @@
|
||||
import {type ActionArgs, json, redirect} from '@shopify/remix-oxygen';
|
||||
import {Form, useActionData, type V2_MetaFunction} from '@remix-run/react';
|
||||
|
||||
type ActionResponse = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: 'Reset Password'}];
|
||||
};
|
||||
|
||||
export async function action({request, context, params}: ActionArgs) {
|
||||
if (request.method !== 'POST') {
|
||||
return json({error: 'Method not allowed'}, {status: 405});
|
||||
}
|
||||
const {id, resetToken} = params;
|
||||
const {session, storefront} = context;
|
||||
|
||||
try {
|
||||
if (!id || !resetToken) {
|
||||
throw new Error('customer token or id not found');
|
||||
}
|
||||
|
||||
const form = await request.formData();
|
||||
const password = form.has('password') ? String(form.get('password')) : '';
|
||||
const passwordConfirm = form.has('passwordConfirm')
|
||||
? String(form.get('passwordConfirm'))
|
||||
: '';
|
||||
const validInputs = Boolean(password && passwordConfirm);
|
||||
if (validInputs && password !== passwordConfirm) {
|
||||
throw new Error('Please provide matching passwords');
|
||||
}
|
||||
|
||||
const {customerReset} = await storefront.mutate(CUSTOMER_RESET_MUTATION, {
|
||||
variables: {
|
||||
id: `gid://shopify/Customer/${id}`,
|
||||
input: {password, resetToken},
|
||||
},
|
||||
});
|
||||
|
||||
if (customerReset?.customerUserErrors?.length) {
|
||||
throw new Error(customerReset?.customerUserErrors[0].message);
|
||||
}
|
||||
|
||||
if (!customerReset?.customerAccessToken) {
|
||||
throw new Error('Access token not found. Please try again.');
|
||||
}
|
||||
session.set('customerAccessToken', customerReset.customerAccessToken);
|
||||
|
||||
return redirect('/account', {
|
||||
headers: {
|
||||
'Set-Cookie': await session.commit(),
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return json({error: error.message}, {status: 400});
|
||||
}
|
||||
return json({error}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Reset() {
|
||||
const action = useActionData<ActionResponse>();
|
||||
|
||||
return (
|
||||
<div className="account-reset">
|
||||
<h1>Reset Password.</h1>
|
||||
<p>Enter a new password for your account.</p>
|
||||
<Form method="POST">
|
||||
<fieldset>
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
aria-label="Password"
|
||||
autoComplete="current-password"
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
id="password"
|
||||
minLength={8}
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
required
|
||||
type="password"
|
||||
/>
|
||||
<label htmlFor="passwordConfirm">Re-enter password</label>
|
||||
<input
|
||||
aria-label="Re-enter password"
|
||||
autoComplete="current-password"
|
||||
id="passwordConfirm"
|
||||
minLength={8}
|
||||
name="passwordConfirm"
|
||||
placeholder="Re-enter password"
|
||||
required
|
||||
type="password"
|
||||
/>
|
||||
</fieldset>
|
||||
{action?.error ? (
|
||||
<p>
|
||||
<mark>
|
||||
<small>{action.error}</small>
|
||||
</mark>
|
||||
</p>
|
||||
) : (
|
||||
<br />
|
||||
)}
|
||||
<button type="submit">Reset</button>
|
||||
</Form>
|
||||
<br />
|
||||
<p>
|
||||
<a href="/account/login">Back to login →</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customerreset
|
||||
const CUSTOMER_RESET_MUTATION = `#graphql
|
||||
mutation customerReset(
|
||||
$id: ID!,
|
||||
$input: CustomerResetInput!
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customerReset(id: $id, input: $input) {
|
||||
customerAccessToken {
|
||||
accessToken
|
||||
expiresAt
|
||||
}
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
342
examples/hydrogen-2/app/routes/api.predictive-search.tsx
Normal file
342
examples/hydrogen-2/app/routes/api.predictive-search.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import {json, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import type {
|
||||
NormalizedPredictiveSearch,
|
||||
NormalizedPredictiveSearchResults,
|
||||
} from '~/components/Search';
|
||||
import {NO_PREDICTIVE_SEARCH_RESULTS} from '~/components/Search';
|
||||
|
||||
import type {
|
||||
PredictiveArticleFragment,
|
||||
PredictiveCollectionFragment,
|
||||
PredictivePageFragment,
|
||||
PredictiveProductFragment,
|
||||
PredictiveQueryFragment,
|
||||
PredictiveSearchQuery,
|
||||
} from 'storefrontapi.generated';
|
||||
|
||||
type PredictiveSearchResultItem =
|
||||
| PredictiveArticleFragment
|
||||
| PredictiveCollectionFragment
|
||||
| PredictivePageFragment
|
||||
| PredictiveProductFragment;
|
||||
|
||||
type PredictiveSearchTypes =
|
||||
| 'ARTICLE'
|
||||
| 'COLLECTION'
|
||||
| 'PAGE'
|
||||
| 'PRODUCT'
|
||||
| 'QUERY';
|
||||
|
||||
const DEFAULT_SEARCH_TYPES: PredictiveSearchTypes[] = [
|
||||
'ARTICLE',
|
||||
'COLLECTION',
|
||||
'PAGE',
|
||||
'PRODUCT',
|
||||
'QUERY',
|
||||
];
|
||||
|
||||
/**
|
||||
* Fetches the search results from the predictive search API
|
||||
* requested by the SearchForm component
|
||||
*/
|
||||
export async function action({request, params, context}: LoaderArgs) {
|
||||
if (request.method !== 'POST') {
|
||||
throw new Error('Invalid request method');
|
||||
}
|
||||
|
||||
const search = await fetchPredictiveSearchResults({
|
||||
params,
|
||||
request,
|
||||
context,
|
||||
});
|
||||
|
||||
return json(search);
|
||||
}
|
||||
|
||||
async function fetchPredictiveSearchResults({
|
||||
params,
|
||||
request,
|
||||
context,
|
||||
}: Pick<LoaderArgs, 'params' | 'context' | 'request'>) {
|
||||
const url = new URL(request.url);
|
||||
const searchParams = new URLSearchParams(url.search);
|
||||
let body;
|
||||
try {
|
||||
body = await request.formData();
|
||||
} catch (error) {}
|
||||
const searchTerm = String(body?.get('q') || searchParams.get('q') || '');
|
||||
const limit = Number(body?.get('limit') || searchParams.get('limit') || 10);
|
||||
const rawTypes = String(
|
||||
body?.get('type') || searchParams.get('type') || 'ANY',
|
||||
);
|
||||
const searchTypes =
|
||||
rawTypes === 'ANY'
|
||||
? DEFAULT_SEARCH_TYPES
|
||||
: rawTypes
|
||||
.split(',')
|
||||
.map((t) => t.toUpperCase() as PredictiveSearchTypes)
|
||||
.filter((t) => DEFAULT_SEARCH_TYPES.includes(t));
|
||||
|
||||
if (!searchTerm) {
|
||||
return {
|
||||
searchResults: {results: null, totalResults: 0},
|
||||
searchTerm,
|
||||
searchTypes,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await context.storefront.query(PREDICTIVE_SEARCH_QUERY, {
|
||||
variables: {
|
||||
limit,
|
||||
limitScope: 'EACH',
|
||||
searchTerm,
|
||||
types: searchTypes,
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
throw new Error('No data returned from Shopify API');
|
||||
}
|
||||
|
||||
const searchResults = normalizePredictiveSearchResults(
|
||||
data.predictiveSearch,
|
||||
params.locale,
|
||||
);
|
||||
|
||||
return {searchResults, searchTerm, searchTypes};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize results and apply tracking qurery parameters to each result url
|
||||
* @param predictiveSearch
|
||||
* @param locale
|
||||
*/
|
||||
export function normalizePredictiveSearchResults(
|
||||
predictiveSearch: PredictiveSearchQuery['predictiveSearch'],
|
||||
locale: LoaderArgs['params']['locale'],
|
||||
): NormalizedPredictiveSearch {
|
||||
let totalResults = 0;
|
||||
if (!predictiveSearch) {
|
||||
return {
|
||||
results: NO_PREDICTIVE_SEARCH_RESULTS,
|
||||
totalResults,
|
||||
};
|
||||
}
|
||||
|
||||
function applyTrackingParams(
|
||||
resource: PredictiveSearchResultItem | PredictiveQueryFragment,
|
||||
params?: string,
|
||||
) {
|
||||
if (params) {
|
||||
return resource.trackingParameters
|
||||
? `?${params}&${resource.trackingParameters}`
|
||||
: `?${params}`;
|
||||
} else {
|
||||
return resource.trackingParameters
|
||||
? `?${resource.trackingParameters}`
|
||||
: '';
|
||||
}
|
||||
}
|
||||
|
||||
const localePrefix = locale ? `/${locale}` : '';
|
||||
const results: NormalizedPredictiveSearchResults = [];
|
||||
|
||||
if (predictiveSearch.queries.length) {
|
||||
results.push({
|
||||
type: 'queries',
|
||||
items: predictiveSearch.queries.map((query: PredictiveQueryFragment) => {
|
||||
const trackingParams = applyTrackingParams(
|
||||
query,
|
||||
`q=${encodeURIComponent(query.text)}`,
|
||||
);
|
||||
|
||||
totalResults++;
|
||||
return {
|
||||
__typename: query.__typename,
|
||||
handle: '',
|
||||
id: query.text,
|
||||
image: undefined,
|
||||
title: query.text,
|
||||
styledTitle: query.styledText,
|
||||
url: `${localePrefix}/search${trackingParams}`,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (predictiveSearch.products.length) {
|
||||
results.push({
|
||||
type: 'products',
|
||||
items: predictiveSearch.products.map(
|
||||
(product: PredictiveProductFragment) => {
|
||||
totalResults++;
|
||||
const trackingParams = applyTrackingParams(product);
|
||||
return {
|
||||
__typename: product.__typename,
|
||||
handle: product.handle,
|
||||
id: product.id,
|
||||
image: product.variants?.nodes?.[0]?.image,
|
||||
title: product.title,
|
||||
url: `${localePrefix}/products/${product.handle}${trackingParams}`,
|
||||
price: product.variants.nodes[0].price,
|
||||
};
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (predictiveSearch.collections.length) {
|
||||
results.push({
|
||||
type: 'collections',
|
||||
items: predictiveSearch.collections.map(
|
||||
(collection: PredictiveCollectionFragment) => {
|
||||
totalResults++;
|
||||
const trackingParams = applyTrackingParams(collection);
|
||||
return {
|
||||
__typename: collection.__typename,
|
||||
handle: collection.handle,
|
||||
id: collection.id,
|
||||
image: collection.image,
|
||||
title: collection.title,
|
||||
url: `${localePrefix}/collections/${collection.handle}${trackingParams}`,
|
||||
};
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (predictiveSearch.pages.length) {
|
||||
results.push({
|
||||
type: 'pages',
|
||||
items: predictiveSearch.pages.map((page: PredictivePageFragment) => {
|
||||
totalResults++;
|
||||
const trackingParams = applyTrackingParams(page);
|
||||
return {
|
||||
__typename: page.__typename,
|
||||
handle: page.handle,
|
||||
id: page.id,
|
||||
image: undefined,
|
||||
title: page.title,
|
||||
url: `${localePrefix}/pages/${page.handle}${trackingParams}`,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (predictiveSearch.articles.length) {
|
||||
results.push({
|
||||
type: 'articles',
|
||||
items: predictiveSearch.articles.map(
|
||||
(article: PredictiveArticleFragment) => {
|
||||
totalResults++;
|
||||
const trackingParams = applyTrackingParams(article);
|
||||
return {
|
||||
__typename: article.__typename,
|
||||
handle: article.handle,
|
||||
id: article.id,
|
||||
image: article.image,
|
||||
title: article.title,
|
||||
url: `${localePrefix}/blog/${article.handle}${trackingParams}`,
|
||||
};
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return {results, totalResults};
|
||||
}
|
||||
|
||||
const PREDICTIVE_SEARCH_QUERY = `#graphql
|
||||
fragment PredictiveArticle on Article {
|
||||
__typename
|
||||
id
|
||||
title
|
||||
handle
|
||||
image {
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
trackingParameters
|
||||
}
|
||||
fragment PredictiveCollection on Collection {
|
||||
__typename
|
||||
id
|
||||
title
|
||||
handle
|
||||
image {
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
trackingParameters
|
||||
}
|
||||
fragment PredictivePage on Page {
|
||||
__typename
|
||||
id
|
||||
title
|
||||
handle
|
||||
trackingParameters
|
||||
}
|
||||
fragment PredictiveProduct on Product {
|
||||
__typename
|
||||
id
|
||||
title
|
||||
handle
|
||||
trackingParameters
|
||||
variants(first: 1) {
|
||||
nodes {
|
||||
id
|
||||
image {
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
price {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fragment PredictiveQuery on SearchQuerySuggestion {
|
||||
__typename
|
||||
text
|
||||
styledText
|
||||
trackingParameters
|
||||
}
|
||||
query predictiveSearch(
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
$limit: Int!
|
||||
$limitScope: PredictiveSearchLimitScope!
|
||||
$searchTerm: String!
|
||||
$types: [PredictiveSearchType!]
|
||||
) @inContext(country: $country, language: $language) {
|
||||
predictiveSearch(
|
||||
limit: $limit,
|
||||
limitScope: $limitScope,
|
||||
query: $searchTerm,
|
||||
types: $types,
|
||||
) {
|
||||
articles {
|
||||
...PredictiveArticle
|
||||
}
|
||||
collections {
|
||||
...PredictiveCollection
|
||||
}
|
||||
pages {
|
||||
...PredictivePage
|
||||
}
|
||||
products {
|
||||
...PredictiveProduct
|
||||
}
|
||||
queries {
|
||||
...PredictiveQuery
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
@@ -0,0 +1,88 @@
|
||||
import type {V2_MetaFunction} from '@shopify/remix-oxygen';
|
||||
import {json, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {useLoaderData} from '@remix-run/react';
|
||||
import {Image} from '@shopify/hydrogen';
|
||||
|
||||
export const meta: V2_MetaFunction = ({data}) => {
|
||||
return [{title: `Hydrogen | ${data.article.title} article`}];
|
||||
};
|
||||
|
||||
export async function loader({params, context}: LoaderArgs) {
|
||||
const {blogHandle, articleHandle} = params;
|
||||
|
||||
if (!articleHandle || !blogHandle) {
|
||||
throw new Response('Not found', {status: 404});
|
||||
}
|
||||
|
||||
const {blog} = await context.storefront.query(ARTICLE_QUERY, {
|
||||
variables: {blogHandle, articleHandle},
|
||||
});
|
||||
|
||||
if (!blog?.articleByHandle) {
|
||||
throw new Response(null, {status: 404});
|
||||
}
|
||||
|
||||
const article = blog.articleByHandle;
|
||||
|
||||
return json({article});
|
||||
}
|
||||
|
||||
export default function Article() {
|
||||
const {article} = useLoaderData<typeof loader>();
|
||||
const {title, image, contentHtml, author} = article;
|
||||
|
||||
const publishedDate = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(new Date(article.publishedAt));
|
||||
|
||||
return (
|
||||
<div className="article">
|
||||
<h1>
|
||||
{title}
|
||||
<span>
|
||||
{publishedDate} · {author?.name}
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{image && <Image data={image} sizes="90vw" loading="eager" />}
|
||||
<div
|
||||
dangerouslySetInnerHTML={{__html: contentHtml}}
|
||||
className="article"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/objects/blog#field-blog-articlebyhandle
|
||||
const ARTICLE_QUERY = `#graphql
|
||||
query Article(
|
||||
$articleHandle: String!
|
||||
$blogHandle: String!
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
) @inContext(language: $language, country: $country) {
|
||||
blog(handle: $blogHandle) {
|
||||
articleByHandle(handle: $articleHandle) {
|
||||
title
|
||||
contentHtml
|
||||
publishedAt
|
||||
author: authorV2 {
|
||||
name
|
||||
}
|
||||
image {
|
||||
id
|
||||
altText
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
seo {
|
||||
description
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
162
examples/hydrogen-2/app/routes/blogs.$blogHandle._index.tsx
Normal file
162
examples/hydrogen-2/app/routes/blogs.$blogHandle._index.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import type {V2_MetaFunction} from '@shopify/remix-oxygen';
|
||||
import {json, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {Link, useLoaderData} from '@remix-run/react';
|
||||
import {Image, Pagination, getPaginationVariables} from '@shopify/hydrogen';
|
||||
import type {ArticleItemFragment} from 'storefrontapi.generated';
|
||||
|
||||
export const meta: V2_MetaFunction = ({data}) => {
|
||||
return [{title: `Hydrogen | ${data.blog.title} blog`}];
|
||||
};
|
||||
|
||||
export const loader = async ({
|
||||
request,
|
||||
params,
|
||||
context: {storefront},
|
||||
}: LoaderArgs) => {
|
||||
const paginationVariables = getPaginationVariables(request, {
|
||||
pageBy: 4,
|
||||
});
|
||||
|
||||
if (!params.blogHandle) {
|
||||
throw new Response(`blog not found`, {status: 404});
|
||||
}
|
||||
|
||||
const {blog} = await storefront.query(BLOGS_QUERY, {
|
||||
variables: {
|
||||
blogHandle: params.blogHandle,
|
||||
...paginationVariables,
|
||||
},
|
||||
});
|
||||
|
||||
if (!blog?.articles) {
|
||||
throw new Response('Not found', {status: 404});
|
||||
}
|
||||
|
||||
return json({blog});
|
||||
};
|
||||
|
||||
export default function Blog() {
|
||||
const {blog} = useLoaderData<typeof loader>();
|
||||
const {articles} = blog;
|
||||
|
||||
return (
|
||||
<div className="blog">
|
||||
<h1>{blog.title}</h1>
|
||||
<div className="blog-grid">
|
||||
<Pagination connection={articles}>
|
||||
{({nodes, isLoading, PreviousLink, NextLink}) => {
|
||||
return (
|
||||
<>
|
||||
<PreviousLink>
|
||||
{isLoading ? 'Loading...' : <span>↑ Load previous</span>}
|
||||
</PreviousLink>
|
||||
{nodes.map((article, index) => {
|
||||
return (
|
||||
<ArticleItem
|
||||
article={article}
|
||||
key={article.id}
|
||||
loading={index < 2 ? 'eager' : 'lazy'}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<NextLink>
|
||||
{isLoading ? 'Loading...' : <span>Load more ↓</span>}
|
||||
</NextLink>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArticleItem({
|
||||
article,
|
||||
loading,
|
||||
}: {
|
||||
article: ArticleItemFragment;
|
||||
loading?: HTMLImageElement['loading'];
|
||||
}) {
|
||||
const publishedAt = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(new Date(article.publishedAt!));
|
||||
return (
|
||||
<div className="blog-article" key={article.id}>
|
||||
<Link to={`/blogs/${article.blog.handle}/${article.handle}`}>
|
||||
{article.image && (
|
||||
<div className="blog-article-image">
|
||||
<Image
|
||||
alt={article.image.altText || article.title}
|
||||
aspectRatio="3/2"
|
||||
data={article.image}
|
||||
loading={loading}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<h3>{article.title}</h3>
|
||||
<small>{publishedAt}</small>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/objects/blog
|
||||
const BLOGS_QUERY = `#graphql
|
||||
query Blog(
|
||||
$language: LanguageCode
|
||||
$blogHandle: String!
|
||||
$first: Int
|
||||
$last: Int
|
||||
$startCursor: String
|
||||
$endCursor: String
|
||||
) @inContext(language: $language) {
|
||||
blog(handle: $blogHandle) {
|
||||
title
|
||||
seo {
|
||||
title
|
||||
description
|
||||
}
|
||||
articles(
|
||||
first: $first,
|
||||
last: $last,
|
||||
before: $startCursor,
|
||||
after: $endCursor
|
||||
) {
|
||||
nodes {
|
||||
...ArticleItem
|
||||
}
|
||||
pageInfo {
|
||||
hasPreviousPage
|
||||
hasNextPage
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
fragment ArticleItem on Article {
|
||||
author: authorV2 {
|
||||
name
|
||||
}
|
||||
contentHtml
|
||||
handle
|
||||
id
|
||||
image {
|
||||
id
|
||||
altText
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
publishedAt
|
||||
title
|
||||
blog {
|
||||
handle
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
94
examples/hydrogen-2/app/routes/blogs._index.tsx
Normal file
94
examples/hydrogen-2/app/routes/blogs._index.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import type {V2_MetaFunction} from '@shopify/remix-oxygen';
|
||||
import {json, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {Link, useLoaderData} from '@remix-run/react';
|
||||
import {Pagination, getPaginationVariables} from '@shopify/hydrogen';
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: `Hydrogen | Logs`}];
|
||||
};
|
||||
|
||||
export const loader = async ({request, context: {storefront}}: LoaderArgs) => {
|
||||
const paginationVariables = getPaginationVariables(request, {
|
||||
pageBy: 10,
|
||||
});
|
||||
|
||||
const {blogs} = await storefront.query(BLOGS_QUERY, {
|
||||
variables: {
|
||||
...paginationVariables,
|
||||
},
|
||||
});
|
||||
|
||||
return json({blogs});
|
||||
};
|
||||
|
||||
export default function Blogs() {
|
||||
const {blogs} = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="blogs">
|
||||
<h1>Blogs</h1>
|
||||
<div className="blogs-grid">
|
||||
<Pagination connection={blogs}>
|
||||
{({nodes, isLoading, PreviousLink, NextLink}) => {
|
||||
return (
|
||||
<>
|
||||
<PreviousLink>
|
||||
{isLoading ? 'Loading...' : <span>↑ Load previous</span>}
|
||||
</PreviousLink>
|
||||
{nodes.map((blog) => {
|
||||
return (
|
||||
<Link
|
||||
className="blog"
|
||||
key={blog.handle}
|
||||
prefetch="intent"
|
||||
to={`/blogs/${blog.handle}`}
|
||||
>
|
||||
<h2>{blog.title}</h2>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<NextLink>
|
||||
{isLoading ? 'Loading...' : <span>Load more ↓</span>}
|
||||
</NextLink>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/objects/blog
|
||||
const BLOGS_QUERY = `#graphql
|
||||
query Blogs(
|
||||
$country: CountryCode
|
||||
$endCursor: String
|
||||
$first: Int
|
||||
$language: LanguageCode
|
||||
$last: Int
|
||||
$startCursor: String
|
||||
) @inContext(country: $country, language: $language) {
|
||||
blogs(
|
||||
first: $first,
|
||||
last: $last,
|
||||
before: $startCursor,
|
||||
after: $endCursor
|
||||
) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
title
|
||||
handle
|
||||
seo {
|
||||
title
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
104
examples/hydrogen-2/app/routes/cart.tsx
Normal file
104
examples/hydrogen-2/app/routes/cart.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import {Await, useMatches} from '@remix-run/react';
|
||||
import {Suspense} from 'react';
|
||||
import type {CartQueryData} from '@shopify/hydrogen';
|
||||
import {CartForm} from '@shopify/hydrogen';
|
||||
import type {V2_MetaFunction} from '@shopify/remix-oxygen';
|
||||
import {type ActionArgs, json} from '@shopify/remix-oxygen';
|
||||
import type {CartApiQueryFragment} from 'storefrontapi.generated';
|
||||
import {CartMain} from '~/components/Cart';
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: `Hydrogen | Cart`}];
|
||||
};
|
||||
|
||||
export async function action({request, context}: ActionArgs) {
|
||||
const {session, cart} = context;
|
||||
|
||||
const [formData, customerAccessToken] = await Promise.all([
|
||||
request.formData(),
|
||||
session.get('customerAccessToken'),
|
||||
]);
|
||||
|
||||
const {action, inputs} = CartForm.getFormInput(formData);
|
||||
|
||||
if (!action) {
|
||||
throw new Error('No action provided');
|
||||
}
|
||||
|
||||
let status = 200;
|
||||
let result: CartQueryData;
|
||||
|
||||
switch (action) {
|
||||
case CartForm.ACTIONS.LinesAdd:
|
||||
result = await cart.addLines(inputs.lines);
|
||||
break;
|
||||
case CartForm.ACTIONS.LinesUpdate:
|
||||
result = await cart.updateLines(inputs.lines);
|
||||
break;
|
||||
case CartForm.ACTIONS.LinesRemove:
|
||||
result = await cart.removeLines(inputs.lineIds);
|
||||
break;
|
||||
case CartForm.ACTIONS.DiscountCodesUpdate: {
|
||||
const formDiscountCode = inputs.discountCode;
|
||||
|
||||
// User inputted discount code
|
||||
const discountCodes = (
|
||||
formDiscountCode ? [formDiscountCode] : []
|
||||
) as string[];
|
||||
|
||||
// Combine discount codes already applied on cart
|
||||
discountCodes.push(...inputs.discountCodes);
|
||||
|
||||
result = await cart.updateDiscountCodes(discountCodes);
|
||||
break;
|
||||
}
|
||||
case CartForm.ACTIONS.BuyerIdentityUpdate: {
|
||||
result = await cart.updateBuyerIdentity({
|
||||
...inputs.buyerIdentity,
|
||||
customerAccessToken,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`${action} cart action is not defined`);
|
||||
}
|
||||
|
||||
const cartId = result.cart.id;
|
||||
const headers = cart.setCartId(result.cart.id);
|
||||
const {cart: cartResult, errors} = result;
|
||||
|
||||
const redirectTo = formData.get('redirectTo') ?? null;
|
||||
if (typeof redirectTo === 'string') {
|
||||
status = 303;
|
||||
headers.set('Location', redirectTo);
|
||||
}
|
||||
|
||||
return json(
|
||||
{
|
||||
cart: cartResult,
|
||||
errors,
|
||||
analytics: {
|
||||
cartId,
|
||||
},
|
||||
},
|
||||
{status, headers},
|
||||
);
|
||||
}
|
||||
|
||||
export default function Cart() {
|
||||
const [root] = useMatches();
|
||||
const cart = root.data?.cart as Promise<CartApiQueryFragment | null>;
|
||||
|
||||
return (
|
||||
<div className="cart">
|
||||
<h1>Cart</h1>
|
||||
<Suspense fallback={<p>Loading cart ...</p>}>
|
||||
<Await errorElement={<div>An error occurred</div>} resolve={cart}>
|
||||
{(cart) => {
|
||||
return <CartMain layout="page" cart={cart} />;
|
||||
}}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
examples/hydrogen-2/app/routes/collections.$handle.tsx
Normal file
184
examples/hydrogen-2/app/routes/collections.$handle.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import type {V2_MetaFunction} from '@shopify/remix-oxygen';
|
||||
import {json, redirect, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {useLoaderData, Link} from '@remix-run/react';
|
||||
import {
|
||||
Pagination,
|
||||
getPaginationVariables,
|
||||
Image,
|
||||
Money,
|
||||
} from '@shopify/hydrogen';
|
||||
import type {ProductItemFragment} from 'storefrontapi.generated';
|
||||
import {useVariantUrl} from '~/utils';
|
||||
|
||||
export const meta: V2_MetaFunction = ({data}) => {
|
||||
return [{title: `Hydrogen | ${data.collection.title} Collection`}];
|
||||
};
|
||||
|
||||
export async function loader({request, params, context}: LoaderArgs) {
|
||||
const {handle} = params;
|
||||
const {storefront} = context;
|
||||
const paginationVariables = getPaginationVariables(request, {
|
||||
pageBy: 8,
|
||||
});
|
||||
|
||||
if (!handle) {
|
||||
return redirect('/collections');
|
||||
}
|
||||
|
||||
const {collection} = await storefront.query(COLLECTION_QUERY, {
|
||||
variables: {handle, ...paginationVariables},
|
||||
});
|
||||
|
||||
if (!collection) {
|
||||
throw new Response(`Collection ${handle} not found`, {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
return json({collection});
|
||||
}
|
||||
|
||||
export default function Collection() {
|
||||
const {collection} = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="collection">
|
||||
<h1>{collection.title}</h1>
|
||||
<p className="collection-description">{collection.description}</p>
|
||||
<Pagination connection={collection.products}>
|
||||
{({nodes, isLoading, PreviousLink, NextLink}) => (
|
||||
<>
|
||||
<PreviousLink>
|
||||
{isLoading ? 'Loading...' : <span>↑ Load previous</span>}
|
||||
</PreviousLink>
|
||||
<ProductsGrid products={nodes} />
|
||||
<br />
|
||||
<NextLink>
|
||||
{isLoading ? 'Loading...' : <span>Load more ↓</span>}
|
||||
</NextLink>
|
||||
</>
|
||||
)}
|
||||
</Pagination>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductsGrid({products}: {products: ProductItemFragment[]}) {
|
||||
return (
|
||||
<div className="products-grid">
|
||||
{products.map((product, index) => {
|
||||
return (
|
||||
<ProductItem
|
||||
key={product.id}
|
||||
product={product}
|
||||
loading={index < 8 ? 'eager' : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductItem({
|
||||
product,
|
||||
loading,
|
||||
}: {
|
||||
product: ProductItemFragment;
|
||||
loading?: 'eager' | 'lazy';
|
||||
}) {
|
||||
const variant = product.variants.nodes[0];
|
||||
const variantUrl = useVariantUrl(product.handle, variant.selectedOptions);
|
||||
return (
|
||||
<Link
|
||||
className="product-item"
|
||||
key={product.id}
|
||||
prefetch="intent"
|
||||
to={variantUrl}
|
||||
>
|
||||
{product.featuredImage && (
|
||||
<Image
|
||||
alt={product.featuredImage.altText || product.title}
|
||||
aspectRatio="1/1"
|
||||
data={product.featuredImage}
|
||||
loading={loading}
|
||||
sizes="(min-width: 45em) 400px, 100vw"
|
||||
/>
|
||||
)}
|
||||
<h4>{product.title}</h4>
|
||||
<small>
|
||||
<Money data={product.priceRange.minVariantPrice} />
|
||||
</small>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const PRODUCT_ITEM_FRAGMENT = `#graphql
|
||||
fragment MoneyProductItem on MoneyV2 {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
fragment ProductItem on Product {
|
||||
id
|
||||
handle
|
||||
title
|
||||
featuredImage {
|
||||
id
|
||||
altText
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
priceRange {
|
||||
minVariantPrice {
|
||||
...MoneyProductItem
|
||||
}
|
||||
maxVariantPrice {
|
||||
...MoneyProductItem
|
||||
}
|
||||
}
|
||||
variants(first: 1) {
|
||||
nodes {
|
||||
selectedOptions {
|
||||
name
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/2022-04/objects/collection
|
||||
const COLLECTION_QUERY = `#graphql
|
||||
${PRODUCT_ITEM_FRAGMENT}
|
||||
query Collection(
|
||||
$handle: String!
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
$first: Int
|
||||
$last: Int
|
||||
$startCursor: String
|
||||
$endCursor: String
|
||||
) @inContext(country: $country, language: $language) {
|
||||
collection(handle: $handle) {
|
||||
id
|
||||
handle
|
||||
title
|
||||
description
|
||||
products(
|
||||
first: $first,
|
||||
last: $last,
|
||||
before: $startCursor,
|
||||
after: $endCursor
|
||||
) {
|
||||
nodes {
|
||||
...ProductItem
|
||||
}
|
||||
pageInfo {
|
||||
hasPreviousPage
|
||||
hasNextPage
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
120
examples/hydrogen-2/app/routes/collections._index.tsx
Normal file
120
examples/hydrogen-2/app/routes/collections._index.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import {useLoaderData, Link} from '@remix-run/react';
|
||||
import {json, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {Pagination, getPaginationVariables, Image} from '@shopify/hydrogen';
|
||||
import type {CollectionFragment} from 'storefrontapi.generated';
|
||||
|
||||
export async function loader({context, request}: LoaderArgs) {
|
||||
const paginationVariables = getPaginationVariables(request, {
|
||||
pageBy: 4,
|
||||
});
|
||||
|
||||
const {collections} = await context.storefront.query(COLLECTIONS_QUERY, {
|
||||
variables: paginationVariables,
|
||||
});
|
||||
|
||||
return json({collections});
|
||||
}
|
||||
|
||||
export default function Collections() {
|
||||
const {collections} = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="collections">
|
||||
<h1>Collections</h1>
|
||||
<Pagination connection={collections}>
|
||||
{({nodes, isLoading, PreviousLink, NextLink}) => (
|
||||
<div>
|
||||
<PreviousLink>
|
||||
{isLoading ? 'Loading...' : <span>↑ Load previous</span>}
|
||||
</PreviousLink>
|
||||
<CollectionsGrid collections={nodes} />
|
||||
<NextLink>
|
||||
{isLoading ? 'Loading...' : <span>Load more ↓</span>}
|
||||
</NextLink>
|
||||
</div>
|
||||
)}
|
||||
</Pagination>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectionsGrid({collections}: {collections: CollectionFragment[]}) {
|
||||
return (
|
||||
<div className="collections-grid">
|
||||
{collections.map((collection, index) => (
|
||||
<CollectionItem
|
||||
key={collection.id}
|
||||
collection={collection}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectionItem({
|
||||
collection,
|
||||
index,
|
||||
}: {
|
||||
collection: CollectionFragment;
|
||||
index: number;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
className="collection-item"
|
||||
key={collection.id}
|
||||
to={`/collections/${collection.handle}`}
|
||||
prefetch="intent"
|
||||
>
|
||||
{collection.image && (
|
||||
<Image
|
||||
alt={collection.image.altText || collection.title}
|
||||
aspectRatio="1/1"
|
||||
data={collection.image}
|
||||
loading={index < 3 ? 'eager' : undefined}
|
||||
/>
|
||||
)}
|
||||
<h5>{collection.title}</h5>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const COLLECTIONS_QUERY = `#graphql
|
||||
fragment Collection on Collection {
|
||||
id
|
||||
title
|
||||
handle
|
||||
image {
|
||||
id
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
}
|
||||
query StoreCollections(
|
||||
$country: CountryCode
|
||||
$endCursor: String
|
||||
$first: Int
|
||||
$language: LanguageCode
|
||||
$last: Int
|
||||
$startCursor: String
|
||||
) @inContext(country: $country, language: $language) {
|
||||
collections(
|
||||
first: $first,
|
||||
last: $last,
|
||||
before: $startCursor,
|
||||
after: $endCursor
|
||||
) {
|
||||
nodes {
|
||||
...Collection
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
57
examples/hydrogen-2/app/routes/pages.$handle.tsx
Normal file
57
examples/hydrogen-2/app/routes/pages.$handle.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type {V2_MetaFunction} from '@shopify/remix-oxygen';
|
||||
import {json, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {useLoaderData} from '@remix-run/react';
|
||||
|
||||
export const meta: V2_MetaFunction = ({data}) => {
|
||||
return [{title: `Hydrogen | ${data.page.title}`}];
|
||||
};
|
||||
|
||||
export async function loader({params, context}: LoaderArgs) {
|
||||
if (!params.handle) {
|
||||
throw new Error('Missing page handle');
|
||||
}
|
||||
|
||||
const {page} = await context.storefront.query(PAGE_QUERY, {
|
||||
variables: {
|
||||
handle: params.handle,
|
||||
},
|
||||
});
|
||||
|
||||
if (!page) {
|
||||
throw new Response('Not Found', {status: 404});
|
||||
}
|
||||
|
||||
return json({page});
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const {page} = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header>
|
||||
<h1>{page.title}</h1>
|
||||
</header>
|
||||
<main dangerouslySetInnerHTML={{__html: page.body}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PAGE_QUERY = `#graphql
|
||||
query Page(
|
||||
$language: LanguageCode,
|
||||
$country: CountryCode,
|
||||
$handle: String!
|
||||
)
|
||||
@inContext(language: $language, country: $country) {
|
||||
page(handle: $handle) {
|
||||
id
|
||||
title
|
||||
body
|
||||
seo {
|
||||
description
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
94
examples/hydrogen-2/app/routes/policies.$handle.tsx
Normal file
94
examples/hydrogen-2/app/routes/policies.$handle.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import type {V2_MetaFunction} from '@shopify/remix-oxygen';
|
||||
import {json, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {Link, useLoaderData} from '@remix-run/react';
|
||||
import {type Shop} from '@shopify/hydrogen-react/storefront-api-types';
|
||||
|
||||
type SelectedPolicies = keyof Pick<
|
||||
Shop,
|
||||
'privacyPolicy' | 'shippingPolicy' | 'termsOfService' | 'refundPolicy'
|
||||
>;
|
||||
|
||||
export const meta: V2_MetaFunction = ({data}) => {
|
||||
return [{title: `Hydrogen | ${data.policy.title}`}];
|
||||
};
|
||||
|
||||
export async function loader({params, context}: LoaderArgs) {
|
||||
if (!params.handle) {
|
||||
throw new Response('No handle was passed in', {status: 404});
|
||||
}
|
||||
|
||||
const policyName = params.handle.replace(
|
||||
/-([a-z])/g,
|
||||
(_: unknown, m1: string) => m1.toUpperCase(),
|
||||
) as SelectedPolicies;
|
||||
|
||||
const data = await context.storefront.query(POLICY_CONTENT_QUERY, {
|
||||
variables: {
|
||||
privacyPolicy: false,
|
||||
shippingPolicy: false,
|
||||
termsOfService: false,
|
||||
refundPolicy: false,
|
||||
[policyName]: true,
|
||||
language: context.storefront.i18n?.language,
|
||||
},
|
||||
});
|
||||
|
||||
const policy = data.shop?.[policyName];
|
||||
|
||||
if (!policy) {
|
||||
throw new Response('Could not find the policy', {status: 404});
|
||||
}
|
||||
|
||||
return json({policy});
|
||||
}
|
||||
|
||||
export default function Policy() {
|
||||
const {policy} = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="policy">
|
||||
<br />
|
||||
<br />
|
||||
<div>
|
||||
<Link to="/policies">← Back to Policies</Link>
|
||||
</div>
|
||||
<br />
|
||||
<h1>{policy.title}</h1>
|
||||
<div dangerouslySetInnerHTML={{__html: policy.body}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/objects/Shop
|
||||
const POLICY_CONTENT_QUERY = `#graphql
|
||||
fragment Policy on ShopPolicy {
|
||||
body
|
||||
handle
|
||||
id
|
||||
title
|
||||
url
|
||||
}
|
||||
query Policy(
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
$privacyPolicy: Boolean!
|
||||
$refundPolicy: Boolean!
|
||||
$shippingPolicy: Boolean!
|
||||
$termsOfService: Boolean!
|
||||
) @inContext(language: $language, country: $country) {
|
||||
shop {
|
||||
privacyPolicy @include(if: $privacyPolicy) {
|
||||
...Policy
|
||||
}
|
||||
shippingPolicy @include(if: $shippingPolicy) {
|
||||
...Policy
|
||||
}
|
||||
termsOfService @include(if: $termsOfService) {
|
||||
...Policy
|
||||
}
|
||||
refundPolicy @include(if: $refundPolicy) {
|
||||
...Policy
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
63
examples/hydrogen-2/app/routes/policies._index.tsx
Normal file
63
examples/hydrogen-2/app/routes/policies._index.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import {json, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {useLoaderData, Link} from '@remix-run/react';
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
const data = await context.storefront.query(POLICIES_QUERY);
|
||||
const policies = Object.values(data.shop || {});
|
||||
|
||||
if (!policies.length) {
|
||||
throw new Response('No policies found', {status: 404});
|
||||
}
|
||||
|
||||
return json({policies});
|
||||
}
|
||||
|
||||
export default function Policies() {
|
||||
const {policies} = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="policies">
|
||||
<h1>Policies</h1>
|
||||
<div>
|
||||
{policies.map((policy) => {
|
||||
if (!policy) return null;
|
||||
return (
|
||||
<fieldset key={policy.id}>
|
||||
<Link to={`/policies/${policy.handle}`}>{policy.title}</Link>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const POLICIES_QUERY = `#graphql
|
||||
fragment PolicyItem on ShopPolicy {
|
||||
id
|
||||
title
|
||||
handle
|
||||
}
|
||||
query Policies ($country: CountryCode, $language: LanguageCode)
|
||||
@inContext(country: $country, language: $language) {
|
||||
shop {
|
||||
privacyPolicy {
|
||||
...PolicyItem
|
||||
}
|
||||
shippingPolicy {
|
||||
...PolicyItem
|
||||
}
|
||||
termsOfService {
|
||||
...PolicyItem
|
||||
}
|
||||
refundPolicy {
|
||||
...PolicyItem
|
||||
}
|
||||
subscriptionPolicy {
|
||||
id
|
||||
title
|
||||
handle
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
418
examples/hydrogen-2/app/routes/products.$handle.tsx
Normal file
418
examples/hydrogen-2/app/routes/products.$handle.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
import {Suspense} from 'react';
|
||||
import type {V2_MetaFunction} from '@shopify/remix-oxygen';
|
||||
import {defer, redirect, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import type {FetcherWithComponents} from '@remix-run/react';
|
||||
import {Await, Link, useLoaderData} from '@remix-run/react';
|
||||
import type {
|
||||
ProductFragment,
|
||||
ProductVariantsQuery,
|
||||
ProductVariantFragment,
|
||||
} from 'storefrontapi.generated';
|
||||
|
||||
import {
|
||||
Image,
|
||||
Money,
|
||||
VariantSelector,
|
||||
type VariantOption,
|
||||
getSelectedProductOptions,
|
||||
CartForm,
|
||||
} from '@shopify/hydrogen';
|
||||
import type {CartLineInput} from '@shopify/hydrogen/storefront-api-types';
|
||||
import {getVariantUrl} from '~/utils';
|
||||
|
||||
export const meta: V2_MetaFunction = ({data}) => {
|
||||
return [{title: `Hydrogen | ${data.product.title}`}];
|
||||
};
|
||||
|
||||
export async function loader({params, request, context}: LoaderArgs) {
|
||||
const {handle} = params;
|
||||
const {storefront} = context;
|
||||
|
||||
const selectedOptions = getSelectedProductOptions(request).filter(
|
||||
(option) =>
|
||||
// Filter out Shopify predictive search query params
|
||||
!option.name.startsWith('_sid') &&
|
||||
!option.name.startsWith('_pos') &&
|
||||
!option.name.startsWith('_psq') &&
|
||||
!option.name.startsWith('_ss') &&
|
||||
!option.name.startsWith('_v'),
|
||||
);
|
||||
|
||||
if (!handle) {
|
||||
throw new Error('Expected product handle to be defined');
|
||||
}
|
||||
|
||||
// await the query for the critical product data
|
||||
const {product} = await storefront.query(PRODUCT_QUERY, {
|
||||
variables: {handle, selectedOptions},
|
||||
});
|
||||
|
||||
// In order to show which variants are available in the UI, we need to query
|
||||
// all of them. But there might be a *lot*, so instead separate the variants
|
||||
// into it's own separate query that is deferred. So there's a brief moment
|
||||
// where variant options might show as available when they're not, but after
|
||||
// this deffered query resolves, the UI will update.
|
||||
const variants = storefront.query(VARIANTS_QUERY, {
|
||||
variables: {handle},
|
||||
});
|
||||
|
||||
if (!product?.id) {
|
||||
throw new Response(null, {status: 404});
|
||||
}
|
||||
|
||||
const firstVariant = product.variants.nodes[0];
|
||||
const firstVariantIsDefault = Boolean(
|
||||
firstVariant.selectedOptions.find(
|
||||
(option) => option.name === 'Title' && option.value === 'Default Title',
|
||||
),
|
||||
);
|
||||
|
||||
if (firstVariantIsDefault) {
|
||||
product.selectedVariant = firstVariant;
|
||||
} else {
|
||||
// if no selected variant was returned from the selected options,
|
||||
// we redirect to the first variant's url with it's selected options applied
|
||||
if (!product.selectedVariant) {
|
||||
return redirectToFirstVariant({product, request});
|
||||
}
|
||||
}
|
||||
return defer({product, variants});
|
||||
}
|
||||
|
||||
function redirectToFirstVariant({
|
||||
product,
|
||||
request,
|
||||
}: {
|
||||
product: ProductFragment;
|
||||
request: Request;
|
||||
}) {
|
||||
const url = new URL(request.url);
|
||||
const firstVariant = product.variants.nodes[0];
|
||||
|
||||
throw redirect(
|
||||
getVariantUrl({
|
||||
pathname: url.pathname,
|
||||
handle: product.handle,
|
||||
selectedOptions: firstVariant.selectedOptions,
|
||||
searchParams: new URLSearchParams(url.search),
|
||||
}),
|
||||
{
|
||||
status: 302,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export default function Product() {
|
||||
const {product, variants} = useLoaderData<typeof loader>();
|
||||
const {selectedVariant} = product;
|
||||
return (
|
||||
<div className="product">
|
||||
<ProductImage image={selectedVariant?.image} />
|
||||
<ProductMain
|
||||
selectedVariant={selectedVariant}
|
||||
product={product}
|
||||
variants={variants}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductImage({image}: {image: ProductVariantFragment['image']}) {
|
||||
if (!image) {
|
||||
return <div className="product-image" />;
|
||||
}
|
||||
return (
|
||||
<div className="product-image">
|
||||
<Image
|
||||
alt={image.altText || 'Product Image'}
|
||||
aspectRatio="1/1"
|
||||
data={image}
|
||||
key={image.id}
|
||||
sizes="(min-width: 45em) 50vw, 100vw"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductMain({
|
||||
selectedVariant,
|
||||
product,
|
||||
variants,
|
||||
}: {
|
||||
product: ProductFragment;
|
||||
selectedVariant: ProductFragment['selectedVariant'];
|
||||
variants: Promise<ProductVariantsQuery>;
|
||||
}) {
|
||||
const {title, descriptionHtml} = product;
|
||||
return (
|
||||
<div className="product-main">
|
||||
<h1>{title}</h1>
|
||||
<ProductPrice selectedVariant={selectedVariant} />
|
||||
<br />
|
||||
<Suspense
|
||||
fallback={
|
||||
<ProductForm
|
||||
product={product}
|
||||
selectedVariant={selectedVariant}
|
||||
variants={[]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Await
|
||||
errorElement="There was a problem loading product variants"
|
||||
resolve={variants}
|
||||
>
|
||||
{(data) => (
|
||||
<ProductForm
|
||||
product={product}
|
||||
selectedVariant={selectedVariant}
|
||||
variants={data.product?.variants.nodes || []}
|
||||
/>
|
||||
)}
|
||||
</Await>
|
||||
</Suspense>
|
||||
<br />
|
||||
<br />
|
||||
<p>
|
||||
<strong>Description</strong>
|
||||
</p>
|
||||
<br />
|
||||
<div dangerouslySetInnerHTML={{__html: descriptionHtml}} />
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductPrice({
|
||||
selectedVariant,
|
||||
}: {
|
||||
selectedVariant: ProductFragment['selectedVariant'];
|
||||
}) {
|
||||
return (
|
||||
<div className="product-price">
|
||||
{selectedVariant?.compareAtPrice ? (
|
||||
<>
|
||||
<p>Sale</p>
|
||||
<br />
|
||||
<div className="product-price-on-sale">
|
||||
{selectedVariant ? <Money data={selectedVariant.price} /> : null}
|
||||
<s>
|
||||
<Money data={selectedVariant.compareAtPrice} />
|
||||
</s>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
selectedVariant?.price && <Money data={selectedVariant?.price} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductForm({
|
||||
product,
|
||||
selectedVariant,
|
||||
variants,
|
||||
}: {
|
||||
product: ProductFragment;
|
||||
selectedVariant: ProductFragment['selectedVariant'];
|
||||
variants: Array<ProductVariantFragment>;
|
||||
}) {
|
||||
return (
|
||||
<div className="product-form">
|
||||
<VariantSelector
|
||||
handle={product.handle}
|
||||
options={product.options}
|
||||
variants={variants}
|
||||
>
|
||||
{({option}) => <ProductOptions key={option.name} option={option} />}
|
||||
</VariantSelector>
|
||||
<br />
|
||||
<AddToCartButton
|
||||
disabled={!selectedVariant || !selectedVariant.availableForSale}
|
||||
onClick={() => {
|
||||
window.location.href = window.location.href + '#cart-aside';
|
||||
}}
|
||||
lines={
|
||||
selectedVariant
|
||||
? [
|
||||
{
|
||||
merchandiseId: selectedVariant.id,
|
||||
quantity: 1,
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
>
|
||||
{selectedVariant?.availableForSale ? 'Add to cart' : 'Sold out'}
|
||||
</AddToCartButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductOptions({option}: {option: VariantOption}) {
|
||||
return (
|
||||
<div className="product-options" key={option.name}>
|
||||
<h5>{option.name}</h5>
|
||||
<div className="product-options-grid">
|
||||
{option.values.map(({value, isAvailable, isActive, to}) => {
|
||||
return (
|
||||
<Link
|
||||
className="product-options-item"
|
||||
key={option.name + value}
|
||||
prefetch="intent"
|
||||
preventScrollReset
|
||||
replace
|
||||
to={to}
|
||||
style={{
|
||||
border: isActive ? '1px solid black' : '1px solid transparent',
|
||||
opacity: isAvailable ? 1 : 0.3,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddToCartButton({
|
||||
analytics,
|
||||
children,
|
||||
disabled,
|
||||
lines,
|
||||
onClick,
|
||||
}: {
|
||||
analytics?: unknown;
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
lines: CartLineInput[];
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<CartForm route="/cart" inputs={{lines}} action={CartForm.ACTIONS.LinesAdd}>
|
||||
{(fetcher: FetcherWithComponents<any>) => (
|
||||
<>
|
||||
<input
|
||||
name="analytics"
|
||||
type="hidden"
|
||||
value={JSON.stringify(analytics)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
onClick={onClick}
|
||||
disabled={disabled ?? fetcher.state !== 'idle'}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</CartForm>
|
||||
);
|
||||
}
|
||||
|
||||
const PRODUCT_VARIANT_FRAGMENT = `#graphql
|
||||
fragment ProductVariant on ProductVariant {
|
||||
availableForSale
|
||||
compareAtPrice {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
id
|
||||
image {
|
||||
__typename
|
||||
id
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
price {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
product {
|
||||
title
|
||||
handle
|
||||
}
|
||||
quantityAvailable
|
||||
selectedOptions {
|
||||
name
|
||||
value
|
||||
}
|
||||
sku
|
||||
title
|
||||
unitPrice {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
const PRODUCT_FRAGMENT = `#graphql
|
||||
fragment Product on Product {
|
||||
id
|
||||
title
|
||||
vendor
|
||||
handle
|
||||
descriptionHtml
|
||||
description
|
||||
options {
|
||||
name
|
||||
values
|
||||
}
|
||||
selectedVariant: variantBySelectedOptions(selectedOptions: $selectedOptions) {
|
||||
...ProductVariant
|
||||
}
|
||||
variants(first: 1) {
|
||||
nodes {
|
||||
...ProductVariant
|
||||
}
|
||||
}
|
||||
seo {
|
||||
description
|
||||
title
|
||||
}
|
||||
}
|
||||
${PRODUCT_VARIANT_FRAGMENT}
|
||||
` as const;
|
||||
|
||||
const PRODUCT_QUERY = `#graphql
|
||||
query Product(
|
||||
$country: CountryCode
|
||||
$handle: String!
|
||||
$language: LanguageCode
|
||||
$selectedOptions: [SelectedOptionInput!]!
|
||||
) @inContext(country: $country, language: $language) {
|
||||
product(handle: $handle) {
|
||||
...Product
|
||||
}
|
||||
}
|
||||
${PRODUCT_FRAGMENT}
|
||||
` as const;
|
||||
|
||||
const PRODUCT_VARIANTS_FRAGMENT = `#graphql
|
||||
fragment ProductVariants on Product {
|
||||
variants(first: 250) {
|
||||
nodes {
|
||||
...ProductVariant
|
||||
}
|
||||
}
|
||||
}
|
||||
${PRODUCT_VARIANT_FRAGMENT}
|
||||
` as const;
|
||||
|
||||
const VARIANTS_QUERY = `#graphql
|
||||
${PRODUCT_VARIANTS_FRAGMENT}
|
||||
query ProductVariants(
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
$handle: String!
|
||||
) @inContext(country: $country, language: $language) {
|
||||
product(handle: $handle) {
|
||||
...ProductVariants
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
168
examples/hydrogen-2/app/routes/search.tsx
Normal file
168
examples/hydrogen-2/app/routes/search.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import type {V2_MetaFunction} from '@shopify/remix-oxygen';
|
||||
import {defer, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {useLoaderData} from '@remix-run/react';
|
||||
import {getPaginationVariables} from '@shopify/hydrogen';
|
||||
|
||||
import {SearchForm, SearchResults, NoSearchResults} from '~/components/Search';
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: `Hydrogen | Search`}];
|
||||
};
|
||||
|
||||
export async function loader({request, context}: LoaderArgs) {
|
||||
const url = new URL(request.url);
|
||||
const searchParams = new URLSearchParams(url.search);
|
||||
const variables = getPaginationVariables(request, {pageBy: 8});
|
||||
const searchTerm = String(searchParams.get('q') || '');
|
||||
|
||||
if (!searchTerm) {
|
||||
return {
|
||||
searchResults: {results: null, totalResults: 0},
|
||||
searchTerm,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await context.storefront.query(SEARCH_QUERY, {
|
||||
variables: {
|
||||
query: searchTerm,
|
||||
...variables,
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
throw new Error('No search data returned from Shopify API');
|
||||
}
|
||||
|
||||
const totalResults = Object.values(data).reduce((total, value) => {
|
||||
return total + value.nodes.length;
|
||||
}, 0);
|
||||
|
||||
const searchResults = {
|
||||
results: data,
|
||||
totalResults,
|
||||
};
|
||||
|
||||
return defer({searchTerm, searchResults});
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
const {searchTerm, searchResults} = useLoaderData<typeof loader>();
|
||||
return (
|
||||
<div className="search">
|
||||
<h1>Search</h1>
|
||||
<SearchForm searchTerm={searchTerm} />
|
||||
{!searchTerm || !searchResults.totalResults ? (
|
||||
<NoSearchResults />
|
||||
) : (
|
||||
<SearchResults results={searchResults.results} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SEARCH_QUERY = `#graphql
|
||||
fragment SearchProduct on Product {
|
||||
__typename
|
||||
handle
|
||||
id
|
||||
publishedAt
|
||||
title
|
||||
trackingParameters
|
||||
vendor
|
||||
variants(first: 1) {
|
||||
nodes {
|
||||
id
|
||||
image {
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
price {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
compareAtPrice {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
selectedOptions {
|
||||
name
|
||||
value
|
||||
}
|
||||
product {
|
||||
handle
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fragment SearchPage on Page {
|
||||
__typename
|
||||
handle
|
||||
id
|
||||
title
|
||||
trackingParameters
|
||||
}
|
||||
fragment SearchArticle on Article {
|
||||
__typename
|
||||
handle
|
||||
id
|
||||
title
|
||||
trackingParameters
|
||||
}
|
||||
query search(
|
||||
$country: CountryCode
|
||||
$endCursor: String
|
||||
$first: Int
|
||||
$language: LanguageCode
|
||||
$last: Int
|
||||
$query: String!
|
||||
$startCursor: String
|
||||
) @inContext(country: $country, language: $language) {
|
||||
products: search(
|
||||
query: $query,
|
||||
unavailableProducts: HIDE,
|
||||
types: [PRODUCT],
|
||||
first: $first,
|
||||
sortKey: RELEVANCE,
|
||||
last: $last,
|
||||
before: $startCursor,
|
||||
after: $endCursor
|
||||
) {
|
||||
nodes {
|
||||
...on Product {
|
||||
...SearchProduct
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
pages: search(
|
||||
query: $query,
|
||||
types: [PAGE],
|
||||
first: 10
|
||||
) {
|
||||
nodes {
|
||||
...on Page {
|
||||
...SearchPage
|
||||
}
|
||||
}
|
||||
}
|
||||
articles: search(
|
||||
query: $query,
|
||||
types: [ARTICLE],
|
||||
first: 10
|
||||
) {
|
||||
nodes {
|
||||
...on Article {
|
||||
...SearchArticle
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
473
examples/hydrogen-2/app/styles/app.css
Normal file
473
examples/hydrogen-2/app/styles/app.css
Normal file
@@ -0,0 +1,473 @@
|
||||
:root {
|
||||
--aside-width: 400px;
|
||||
--cart-aside-summary-height-with-discount: 300px;
|
||||
--cart-aside-summary-height: 250px;
|
||||
--grid-item-width: 355px;
|
||||
--header-height: 64px;
|
||||
--color-dark: #000;
|
||||
--color-light: #fff;
|
||||
}
|
||||
|
||||
img {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/*
|
||||
* --------------------------------------------------
|
||||
* components/Aside
|
||||
* --------------------------------------------------
|
||||
*/
|
||||
aside {
|
||||
background: var(--color-light);
|
||||
box-shadow: 0 0 50px rgba(0, 0, 0, 0.3);
|
||||
height: 100vh;
|
||||
max-width: var(--aside-width);
|
||||
min-width: var(--aside-width);
|
||||
position: fixed;
|
||||
right: calc(-1 * var(--aside-width));
|
||||
top: 0;
|
||||
transition: transform 200ms ease-in-out;
|
||||
}
|
||||
|
||||
aside header {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--color-dark);
|
||||
display: flex;
|
||||
height: var(--header-height);
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
aside header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
aside header .close {
|
||||
font-weight: bold;
|
||||
opacity: 0.8;
|
||||
text-decoration: none;
|
||||
transition: all 200ms;
|
||||
width: 20px;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
aside header h2 {
|
||||
margin-bottom: 0.6rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
aside main {
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
aside p {
|
||||
margin: 0 0 0.25rem;
|
||||
&:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
aside li {
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transition: opacity 400ms ease-in-out;
|
||||
transition: opacity 400ms;
|
||||
visibility: hidden;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.overlay .close-outside {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: transparent;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: calc(100% - var(--aside-width));
|
||||
}
|
||||
|
||||
.overlay .light {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.overlay .cancel {
|
||||
cursor: default;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
&:target {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
visibility: visible;
|
||||
}
|
||||
/* reveal aside */
|
||||
&:target aside {
|
||||
transform: translateX(calc(var(--aside-width) * -1));
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* --------------------------------------------------
|
||||
* components/Header
|
||||
* --------------------------------------------------
|
||||
*/
|
||||
.header {
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
height: var(--header-height);
|
||||
padding: 0 1rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.header-menu-mobile-toggle {
|
||||
@media (min-width: 48em) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.header-menu-mobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-gap: 1rem;
|
||||
}
|
||||
|
||||
.header-menu-desktop {
|
||||
display: none;
|
||||
grid-gap: 1rem;
|
||||
@media (min-width: 45em) {
|
||||
display: flex;
|
||||
grid-gap: 1rem;
|
||||
margin-left: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.header-menu-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header-ctas {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
grid-gap: 1rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* --------------------------------------------------
|
||||
* components/Footer
|
||||
* --------------------------------------------------
|
||||
*/
|
||||
.footer {
|
||||
background: var(--color-dark);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.footer-menu-missing {
|
||||
display: inline-block;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.footer-menu {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
grid-gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.footer-menu a {
|
||||
color: var(--color-light);
|
||||
}
|
||||
|
||||
/*
|
||||
* --------------------------------------------------
|
||||
* components/Cart
|
||||
* --------------------------------------------------
|
||||
*/
|
||||
.cart-main {
|
||||
height: 100%;
|
||||
max-height: calc(100vh - var(--cart-aside-summary-height));
|
||||
overflow-y: auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.cart-main.with-discount {
|
||||
max-height: calc(100vh - var(--cart-aside-summary-height-with-discount));
|
||||
}
|
||||
|
||||
.cart-line {
|
||||
display: flex;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.cart-line img {
|
||||
height: 100%;
|
||||
display: block;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.cart-summary-page {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cart-summary-aside {
|
||||
background: white;
|
||||
border-top: 1px solid var(--color-dark);
|
||||
bottom: 0;
|
||||
padding-top: 0.75rem;
|
||||
position: absolute;
|
||||
width: calc(var(--aside-width) - 40px);
|
||||
}
|
||||
|
||||
.cart-line-quantiy {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cart-discount {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.cart-subtotal {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
/*
|
||||
* --------------------------------------------------
|
||||
* components/Search
|
||||
* --------------------------------------------------
|
||||
*/
|
||||
.predictive-search {
|
||||
height: calc(100vh - var(--header-height) - 40px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.predictive-search-form {
|
||||
background: var(--color-light);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.predictive-search-result {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.predictive-search-result h5 {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.predictive-search-result-item {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.predictive-search-result-item a {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.predictive-search-result-item a img {
|
||||
margin-right: 0.75rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.search-result {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.search-results-item {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* --------------------------------------------------
|
||||
* routes/__index
|
||||
* --------------------------------------------------
|
||||
*/
|
||||
.featured-collection {
|
||||
display: block;
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.featured-collection-image {
|
||||
aspect-ratio: 1 / 1;
|
||||
@media (min-width: 45em) {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
}
|
||||
|
||||
.featured-collection img {
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.recommended-products-grid {
|
||||
display: grid;
|
||||
grid-gap: 1.5rem;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
@media (min-width: 45em) {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.recommended-product img {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* --------------------------------------------------
|
||||
* routes/collections._index.tsx
|
||||
* --------------------------------------------------
|
||||
*/
|
||||
.collections-grid {
|
||||
display: grid;
|
||||
grid-gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(var(--grid-item-width), 1fr));
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.collection-item img {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* --------------------------------------------------
|
||||
* routes/collections.$handle.tsx
|
||||
* --------------------------------------------------
|
||||
*/
|
||||
.collection-description {
|
||||
margin-bottom: 1rem;
|
||||
max-width: 95%;
|
||||
@media (min-width: 45em) {
|
||||
max-width: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
.products-grid {
|
||||
display: grid;
|
||||
grid-gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(var(--grid-item-width), 1fr));
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.product-item img {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
* --------------------------------------------------
|
||||
* routes/products.$handle.tsx
|
||||
* --------------------------------------------------
|
||||
*/
|
||||
.product {
|
||||
display: grid;
|
||||
@media (min-width: 45em) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-gap: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.product h1 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.product-images {
|
||||
display: grid;
|
||||
grid-gap: 1rem;
|
||||
}
|
||||
|
||||
.product-image img {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-main {
|
||||
align-self: start;
|
||||
position: sticky;
|
||||
top: 6rem;
|
||||
}
|
||||
|
||||
.product-price-on-sale {
|
||||
display: flex;
|
||||
grid-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.product-price-on-sale s {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.product-options-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
grid-gap: 0.75rem;
|
||||
}
|
||||
|
||||
.product-options-item {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* --------------------------------------------------
|
||||
* routes/blog._index.tsx
|
||||
* --------------------------------------------------
|
||||
*/
|
||||
.blog-grid {
|
||||
display: grid;
|
||||
grid-gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(var(--grid-item-width), 1fr));
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.blog-article-image {
|
||||
aspect-ratio: 3/2;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.blog-article-image img {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
* --------------------------------------------------
|
||||
* routes/blog.$articlehandle.tsx
|
||||
* --------------------------------------------------
|
||||
*/
|
||||
.article img {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
* --------------------------------------------------
|
||||
* routes/account
|
||||
* --------------------------------------------------
|
||||
*/
|
||||
.account-profile-marketing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.account-logout {
|
||||
display: inline-block;
|
||||
}
|
||||
129
examples/hydrogen-2/app/styles/reset.css
Normal file
129
examples/hydrogen-2/app/styles/reset.css
Normal file
@@ -0,0 +1,129 @@
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 2rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
h5 {
|
||||
margin-bottom: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #000;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-bottom: none;
|
||||
border-top: 1px solid #000;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body > main {
|
||||
margin: 0 1rem 1rem 1rem;
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 1rem 0;
|
||||
@media (min-width: 768px) {
|
||||
padding: 2rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
fieldset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
form {
|
||||
max-width: 100%;
|
||||
@media (min-width: 768px) {
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
border-radius: 4px;
|
||||
border: 1px solid #000;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
legend {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
dl {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #ddd;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
46
examples/hydrogen-2/app/utils.ts
Normal file
46
examples/hydrogen-2/app/utils.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {useLocation} from '@remix-run/react';
|
||||
import type {SelectedOption} from '@shopify/hydrogen/storefront-api-types';
|
||||
import {useMemo} from 'react';
|
||||
|
||||
export function useVariantUrl(
|
||||
handle: string,
|
||||
selectedOptions: SelectedOption[],
|
||||
) {
|
||||
const {pathname} = useLocation();
|
||||
|
||||
return useMemo(() => {
|
||||
return getVariantUrl({
|
||||
handle,
|
||||
pathname,
|
||||
searchParams: new URLSearchParams(),
|
||||
selectedOptions,
|
||||
});
|
||||
}, [handle, selectedOptions, pathname]);
|
||||
}
|
||||
|
||||
export function getVariantUrl({
|
||||
handle,
|
||||
pathname,
|
||||
searchParams,
|
||||
selectedOptions,
|
||||
}: {
|
||||
handle: string;
|
||||
pathname: string;
|
||||
searchParams: URLSearchParams;
|
||||
selectedOptions: SelectedOption[];
|
||||
}) {
|
||||
const match = /(\/[a-zA-Z]{2}-[a-zA-Z]{2}\/)/g.exec(pathname);
|
||||
const isLocalePathname = match && match.length > 0;
|
||||
|
||||
const path = isLocalePathname
|
||||
? `${match![0]}products/${handle}`
|
||||
: `/products/${handle}`;
|
||||
|
||||
selectedOptions.forEach((option) => {
|
||||
searchParams.set(option.name, option.value);
|
||||
});
|
||||
|
||||
const searchString = searchParams.toString();
|
||||
|
||||
return path + (searchString ? '?' + searchParams.toString() : '');
|
||||
}
|
||||
44
examples/hydrogen-2/package.json
Normal file
44
examples/hydrogen-2/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "hydrogen-2",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"build": "shopify hydrogen build",
|
||||
"dev": "shopify hydrogen dev --codegen-unstable",
|
||||
"preview": "npm run build && shopify hydrogen preview",
|
||||
"lint": "eslint --no-error-on-unmatched-pattern --ext .js,.ts,.jsx,.tsx .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"codegen": "shopify hydrogen codegen-unstable"
|
||||
},
|
||||
"prettier": "@shopify/prettier-config",
|
||||
"dependencies": {
|
||||
"@remix-run/react": "1.17.1",
|
||||
"@shopify/cli": "3.47.5",
|
||||
"@shopify/cli-hydrogen": "^5.1.0",
|
||||
"@shopify/hydrogen": "^2023.7.0",
|
||||
"@shopify/remix-oxygen": "^1.1.1",
|
||||
"@vercel/remix": "1.17.0",
|
||||
"graphql": "^16.6.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"isbot": "^3.6.6",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@remix-run/dev": "1.17.1",
|
||||
"@shopify/oxygen-workers-types": "^3.17.2",
|
||||
"@shopify/prettier-config": "^1.1.2",
|
||||
"@total-typescript/ts-reset": "^0.4.2",
|
||||
"@types/eslint": "^8.4.10",
|
||||
"@types/react": "^18.0.20",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"eslint": "^8.20.0",
|
||||
"eslint-plugin-hydrogen": "0.12.2",
|
||||
"prettier": "^2.8.4",
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.13"
|
||||
}
|
||||
}
|
||||
10738
examples/hydrogen-2/pnpm-lock.yaml
generated
Normal file
10738
examples/hydrogen-2/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
examples/hydrogen-2/public/favicon.svg
Normal file
28
examples/hydrogen-2/public/favicon.svg
Normal file
@@ -0,0 +1,28 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none">
|
||||
<style>
|
||||
.stroke {
|
||||
stroke: #000;
|
||||
}
|
||||
.fill {
|
||||
fill: #000;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.stroke {
|
||||
stroke: #fff;
|
||||
}
|
||||
.fill {
|
||||
fill: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path
|
||||
class="stroke"
|
||||
fill-rule="evenodd"
|
||||
d="M16.1 16.04 1 8.02 6.16 5.3l5.82 3.09 4.88-2.57-5.82-3.1L16.21 0l15.1 8.02-5.17 2.72-5.5-2.91-4.88 2.57 5.5 2.92-5.16 2.72Z"
|
||||
/>
|
||||
<path
|
||||
class="fill"
|
||||
fill-rule="evenodd"
|
||||
d="M16.1 32 1 23.98l5.16-2.72 5.82 3.08 4.88-2.57-5.82-3.08 5.17-2.73 15.1 8.02-5.17 2.72-5.5-2.92-4.88 2.58 5.5 2.92L16.1 32Z"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 690 B |
26
examples/hydrogen-2/remix.config.js
Normal file
26
examples/hydrogen-2/remix.config.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/** @type {import('@remix-run/dev').AppConfig} */
|
||||
module.exports = {
|
||||
appDirectory: 'app',
|
||||
ignoredRouteFiles: ['**/.*'],
|
||||
watchPaths: ['./public', './.env'],
|
||||
server: './server.ts',
|
||||
/**
|
||||
* The following settings are required to deploy Hydrogen apps to Oxygen:
|
||||
*/
|
||||
publicPath: (process.env.HYDROGEN_ASSET_BASE_URL ?? '/') + 'build/',
|
||||
assetsBuildDirectory: 'dist/client/build',
|
||||
serverBuildPath: 'dist/worker/index.js',
|
||||
serverMainFields: ['browser', 'module', 'main'],
|
||||
serverConditions: ['worker', process.env.NODE_ENV],
|
||||
serverDependenciesToBundle: 'all',
|
||||
serverModuleFormat: 'esm',
|
||||
serverPlatform: 'neutral',
|
||||
serverMinify: process.env.NODE_ENV === 'production',
|
||||
future: {
|
||||
v2_meta: true,
|
||||
v2_headers: true,
|
||||
v2_errorBoundary: true,
|
||||
v2_routeConvention: true,
|
||||
v2_normalizeFormMethod: true,
|
||||
},
|
||||
};
|
||||
39
examples/hydrogen-2/remix.env.d.ts
vendored
Normal file
39
examples/hydrogen-2/remix.env.d.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
/// <reference types="@remix-run/dev" />
|
||||
/// <reference types="@shopify/remix-oxygen" />
|
||||
/// <reference types="@shopify/oxygen-workers-types" />
|
||||
|
||||
// Enhance TypeScript's built-in typings.
|
||||
import '@total-typescript/ts-reset';
|
||||
|
||||
import type {Storefront, HydrogenCart} from '@shopify/hydrogen';
|
||||
import type {HydrogenSession} from './server';
|
||||
|
||||
declare global {
|
||||
/**
|
||||
* A global `process` object is only available during build to access NODE_ENV.
|
||||
*/
|
||||
const process: {env: {NODE_ENV: 'production' | 'development'}};
|
||||
|
||||
/**
|
||||
* Declare expected Env parameter in fetch handler.
|
||||
*/
|
||||
interface Env {
|
||||
SESSION_SECRET: string;
|
||||
PUBLIC_STOREFRONT_API_TOKEN: string;
|
||||
PRIVATE_STOREFRONT_API_TOKEN: string;
|
||||
PUBLIC_STORE_DOMAIN: string;
|
||||
PUBLIC_STOREFRONT_ID: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare local additions to `AppLoadContext` to include the session utilities we injected in `server.ts`.
|
||||
*/
|
||||
declare module '@shopify/remix-oxygen' {
|
||||
export interface AppLoadContext {
|
||||
env: Env;
|
||||
cart: HydrogenCart;
|
||||
storefront: Storefront;
|
||||
session: HydrogenSession;
|
||||
}
|
||||
}
|
||||
253
examples/hydrogen-2/server.ts
Normal file
253
examples/hydrogen-2/server.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
// Virtual entry point for the app
|
||||
import * as remixBuild from '@remix-run/dev/server-build';
|
||||
import {
|
||||
cartGetIdDefault,
|
||||
cartSetIdDefault,
|
||||
createCartHandler,
|
||||
createStorefrontClient,
|
||||
storefrontRedirect,
|
||||
} from '@shopify/hydrogen';
|
||||
import {
|
||||
createRequestHandler,
|
||||
getStorefrontHeaders,
|
||||
createCookieSessionStorage,
|
||||
type SessionStorage,
|
||||
type Session,
|
||||
} from '@shopify/remix-oxygen';
|
||||
|
||||
/**
|
||||
* Export a fetch handler in module format.
|
||||
*/
|
||||
export default {
|
||||
async fetch(
|
||||
request: Request,
|
||||
env: Env,
|
||||
executionContext: ExecutionContext,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
/**
|
||||
* Open a cache instance in the worker and a custom session instance.
|
||||
*/
|
||||
if (!env?.SESSION_SECRET) {
|
||||
throw new Error('SESSION_SECRET environment variable is not set');
|
||||
}
|
||||
|
||||
const waitUntil = (p: Promise<any>) => executionContext.waitUntil(p);
|
||||
const [cache, session] = await Promise.all([
|
||||
caches.open('hydrogen'),
|
||||
HydrogenSession.init(request, [env.SESSION_SECRET]),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Create Hydrogen's Storefront client.
|
||||
*/
|
||||
const {storefront} = createStorefrontClient({
|
||||
cache,
|
||||
waitUntil,
|
||||
i18n: {language: 'EN', country: 'US'},
|
||||
publicStorefrontToken: env.PUBLIC_STOREFRONT_API_TOKEN,
|
||||
privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN,
|
||||
storeDomain: env.PUBLIC_STORE_DOMAIN,
|
||||
storefrontId: env.PUBLIC_STOREFRONT_ID,
|
||||
storefrontHeaders: getStorefrontHeaders(request),
|
||||
});
|
||||
|
||||
/*
|
||||
* Create a cart handler that will be used to
|
||||
* create and update the cart in the session.
|
||||
*/
|
||||
const cart = createCartHandler({
|
||||
storefront,
|
||||
getCartId: cartGetIdDefault(request.headers),
|
||||
setCartId: cartSetIdDefault(),
|
||||
cartQueryFragment: CART_QUERY_FRAGMENT,
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a Remix request handler and pass
|
||||
* Hydrogen's Storefront client to the loader context.
|
||||
*/
|
||||
const handleRequest = createRequestHandler({
|
||||
build: remixBuild,
|
||||
mode: process.env.NODE_ENV,
|
||||
getLoadContext: () => ({session, storefront, env, cart}),
|
||||
});
|
||||
|
||||
const response = await handleRequest(request);
|
||||
|
||||
if (response.status === 404) {
|
||||
/**
|
||||
* Check for redirects only when there's a 404 from the app.
|
||||
* If the redirect doesn't exist, then `storefrontRedirect`
|
||||
* will pass through the 404 response.
|
||||
*/
|
||||
return storefrontRedirect({request, response, storefront});
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
return new Response('An unexpected error occurred', {status: 500});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* This is a custom session implementation for your Hydrogen shop.
|
||||
* Feel free to customize it to your needs, add helper methods, or
|
||||
* swap out the cookie-based implementation with something else!
|
||||
*/
|
||||
export class HydrogenSession {
|
||||
constructor(
|
||||
private sessionStorage: SessionStorage,
|
||||
private session: Session,
|
||||
) {}
|
||||
|
||||
static async init(request: Request, secrets: string[]) {
|
||||
const storage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
name: 'session',
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
secrets,
|
||||
},
|
||||
});
|
||||
|
||||
const session = await storage.getSession(request.headers.get('Cookie'));
|
||||
|
||||
return new this(storage, session);
|
||||
}
|
||||
|
||||
has(key: string) {
|
||||
return this.session.has(key);
|
||||
}
|
||||
|
||||
get(key: string) {
|
||||
return this.session.get(key);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
return this.sessionStorage.destroySession(this.session);
|
||||
}
|
||||
|
||||
flash(key: string, value: any) {
|
||||
this.session.flash(key, value);
|
||||
}
|
||||
|
||||
unset(key: string) {
|
||||
this.session.unset(key);
|
||||
}
|
||||
|
||||
set(key: string, value: any) {
|
||||
this.session.set(key, value);
|
||||
}
|
||||
|
||||
commit() {
|
||||
return this.sessionStorage.commitSession(this.session);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/queries/cart
|
||||
const CART_QUERY_FRAGMENT = `#graphql
|
||||
fragment Money on MoneyV2 {
|
||||
currencyCode
|
||||
amount
|
||||
}
|
||||
fragment CartLine on CartLine {
|
||||
id
|
||||
quantity
|
||||
attributes {
|
||||
key
|
||||
value
|
||||
}
|
||||
cost {
|
||||
totalAmount {
|
||||
...Money
|
||||
}
|
||||
amountPerQuantity {
|
||||
...Money
|
||||
}
|
||||
compareAtAmountPerQuantity {
|
||||
...Money
|
||||
}
|
||||
}
|
||||
merchandise {
|
||||
... on ProductVariant {
|
||||
id
|
||||
availableForSale
|
||||
compareAtPrice {
|
||||
...Money
|
||||
}
|
||||
price {
|
||||
...Money
|
||||
}
|
||||
requiresShipping
|
||||
title
|
||||
image {
|
||||
id
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
|
||||
}
|
||||
product {
|
||||
handle
|
||||
title
|
||||
id
|
||||
}
|
||||
selectedOptions {
|
||||
name
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fragment CartApiQuery on Cart {
|
||||
id
|
||||
checkoutUrl
|
||||
totalQuantity
|
||||
buyerIdentity {
|
||||
countryCode
|
||||
customer {
|
||||
id
|
||||
email
|
||||
firstName
|
||||
lastName
|
||||
displayName
|
||||
}
|
||||
email
|
||||
phone
|
||||
}
|
||||
lines(first: $numCartLines) {
|
||||
nodes {
|
||||
...CartLine
|
||||
}
|
||||
}
|
||||
cost {
|
||||
subtotalAmount {
|
||||
...Money
|
||||
}
|
||||
totalAmount {
|
||||
...Money
|
||||
}
|
||||
totalDutyAmount {
|
||||
...Money
|
||||
}
|
||||
totalTaxAmount {
|
||||
...Money
|
||||
}
|
||||
}
|
||||
note
|
||||
attributes {
|
||||
key
|
||||
value
|
||||
}
|
||||
discountCodes {
|
||||
code
|
||||
applicable
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
1906
examples/hydrogen-2/storefrontapi.generated.d.ts
vendored
Normal file
1906
examples/hydrogen-2/storefrontapi.generated.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
22
examples/hydrogen-2/tsconfig.json
Normal file
22
examples/hydrogen-2/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"include": ["./**/*.d.ts", "./**/*.ts", "./**/*.tsx"],
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"target": "ES2022",
|
||||
"strict": true,
|
||||
"allowJs": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"types": ["@shopify/oxygen-workers-types"],
|
||||
"paths": {
|
||||
"~/*": ["app/*"]
|
||||
},
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
5
examples/nextjs/.gitignore
vendored
5
examples/nextjs/.gitignore
vendored
@@ -23,10 +23,13 @@
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
@@ -14,11 +14,7 @@ pnpm dev
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
|
||||
|
||||
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`.
|
||||
|
||||
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
|
||||
You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
17
examples/nextjs/app/layout.js
Normal file
17
examples/nextjs/app/layout.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import './globals.css'
|
||||
import { Inter } from 'next/font/google'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata = {
|
||||
title: 'Create Next App',
|
||||
description: 'Generated by create next app',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
95
examples/nextjs/app/page.js
Normal file
95
examples/nextjs/app/page.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import Image from 'next/image'
|
||||
import styles from './page.module.css'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
<div className={styles.description}>
|
||||
<p>
|
||||
Get started by editing
|
||||
<code className={styles.code}>app/page.js</code>
|
||||
</p>
|
||||
<div>
|
||||
<a
|
||||
href="https://vercel.com?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
By{' '}
|
||||
<Image
|
||||
src="/vercel.svg"
|
||||
alt="Vercel Logo"
|
||||
className={styles.vercelLogo}
|
||||
width={100}
|
||||
height={24}
|
||||
priority
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.center}>
|
||||
<Image
|
||||
className={styles.logo}
|
||||
src="/next.svg"
|
||||
alt="Next.js Logo"
|
||||
width={180}
|
||||
height={37}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.grid}>
|
||||
<a
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
className={styles.card}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<h2>
|
||||
Docs <span>-></span>
|
||||
</h2>
|
||||
<p>Find in-depth information about Next.js features and API.</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
className={styles.card}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<h2>
|
||||
Learn <span>-></span>
|
||||
</h2>
|
||||
<p>Learn about Next.js in an interactive course with quizzes!</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
className={styles.card}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<h2>
|
||||
Templates <span>-></span>
|
||||
</h2>
|
||||
<p>Explore the Next.js 13 playground.</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
className={styles.card}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<h2>
|
||||
Deploy <span>-></span>
|
||||
</h2>
|
||||
<p>
|
||||
Instantly deploy your Next.js site to a shareable URL with Vercel.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -104,53 +104,9 @@
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.logo,
|
||||
.thirteen {
|
||||
.logo {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.thirteen {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 75px;
|
||||
height: 75px;
|
||||
padding: 25px 10px;
|
||||
margin-left: 16px;
|
||||
transform: translateZ(0);
|
||||
border-radius: var(--border-radius);
|
||||
overflow: hidden;
|
||||
box-shadow: 0px 2px 8px -1px #0000001a;
|
||||
}
|
||||
|
||||
.thirteen::before,
|
||||
.thirteen::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* Conic Gradient Animation */
|
||||
.thirteen::before {
|
||||
animation: 6s rotate linear infinite;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: var(--tile-border);
|
||||
}
|
||||
|
||||
/* Inner Square */
|
||||
.thirteen::after {
|
||||
inset: 0;
|
||||
padding: 1px;
|
||||
border-radius: var(--border-radius);
|
||||
background: linear-gradient(
|
||||
to bottom right,
|
||||
rgba(var(--tile-start-rgb), 1),
|
||||
rgba(var(--tile-end-rgb), 1)
|
||||
);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
/* Enable hover only on non-touch devices */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.card:hover {
|
||||
@@ -164,10 +120,6 @@
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
.thirteen::before {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.card:hover span {
|
||||
transform: none;
|
||||
}
|
||||
@@ -262,8 +214,7 @@
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.logo,
|
||||
.thirteen img {
|
||||
.logo {
|
||||
filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
}
|
||||
const nextConfig = {}
|
||||
|
||||
module.exports = nextConfig
|
||||
|
||||
1373
examples/nextjs/package-lock.json
generated
1373
examples/nextjs/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,9 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"eslint": "8.35.0",
|
||||
"eslint-config-next": "13.2.4",
|
||||
"next": "13.2.4",
|
||||
"eslint": "8.45.0",
|
||||
"eslint-config-next": "13.4.12",
|
||||
"next": "13.4.12",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import '@/styles/globals.css'
|
||||
|
||||
export default function App({ Component, pageProps }) {
|
||||
return <Component {...pageProps} />
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Html, Head, Main, NextScript } from 'next/document'
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head />
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
|
||||
export default function handler(req, res) {
|
||||
res.status(200).json({ name: 'John Doe' })
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import Head from 'next/head'
|
||||
import Image from 'next/image'
|
||||
import { Inter } from 'next/font/google'
|
||||
import styles from '@/styles/Home.module.css'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Create Next App</title>
|
||||
<meta name="description" content="Generated by create next app" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<main className={styles.main}>
|
||||
<div className={styles.description}>
|
||||
<p>
|
||||
Get started by editing
|
||||
<code className={styles.code}>pages/index.js</code>
|
||||
</p>
|
||||
<div>
|
||||
<a
|
||||
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
By{' '}
|
||||
<Image
|
||||
src="/vercel.svg"
|
||||
alt="Vercel Logo"
|
||||
className={styles.vercelLogo}
|
||||
width={100}
|
||||
height={24}
|
||||
priority
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.center}>
|
||||
<Image
|
||||
className={styles.logo}
|
||||
src="/next.svg"
|
||||
alt="Next.js Logo"
|
||||
width={180}
|
||||
height={37}
|
||||
priority
|
||||
/>
|
||||
<div className={styles.thirteen}>
|
||||
<Image
|
||||
src="/thirteen.svg"
|
||||
alt="13"
|
||||
width={40}
|
||||
height={31}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.grid}>
|
||||
<a
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||
className={styles.card}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<h2 className={inter.className}>
|
||||
Docs <span>-></span>
|
||||
</h2>
|
||||
<p className={inter.className}>
|
||||
Find in-depth information about Next.js features and API.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||
className={styles.card}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<h2 className={inter.className}>
|
||||
Learn <span>-></span>
|
||||
</h2>
|
||||
<p className={inter.className}>
|
||||
Learn about Next.js in an interactive course with quizzes!
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||
className={styles.card}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<h2 className={inter.className}>
|
||||
Templates <span>-></span>
|
||||
</h2>
|
||||
<p className={inter.className}>
|
||||
Discover and deploy boilerplate example Next.js projects.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||
className={styles.card}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<h2 className={inter.className}>
|
||||
Deploy <span>-></span>
|
||||
</h2>
|
||||
<p className={inter.className}>
|
||||
Instantly deploy your Next.js site to a shareable URL
|
||||
with Vercel.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="31" fill="none"><g opacity=".9"><path fill="url(#a)" d="M13 .4v29.3H7V6.3h-.2L0 10.5V5L7.2.4H13Z"/><path fill="url(#b)" d="M28.8 30.1c-2.2 0-4-.3-5.7-1-1.7-.8-3-1.8-4-3.1a7.7 7.7 0 0 1-1.4-4.6h6.2c0 .8.3 1.4.7 2 .4.5 1 .9 1.7 1.2.7.3 1.6.4 2.5.4 1 0 1.7-.2 2.5-.5.7-.3 1.3-.8 1.7-1.4.4-.6.6-1.2.6-2s-.2-1.5-.7-2.1c-.4-.6-1-1-1.8-1.4-.8-.4-1.8-.5-2.9-.5h-2.7v-4.6h2.7a6 6 0 0 0 2.5-.5 4 4 0 0 0 1.7-1.3c.4-.6.6-1.3.6-2a3.5 3.5 0 0 0-2-3.3 5.6 5.6 0 0 0-4.5 0 4 4 0 0 0-1.7 1.2c-.4.6-.6 1.2-.6 2h-6c0-1.7.6-3.2 1.5-4.5 1-1.3 2.2-2.3 3.8-3C25 .4 26.8 0 28.8 0s3.8.4 5.3 1.1c1.5.7 2.7 1.7 3.6 3a7.2 7.2 0 0 1 1.2 4.2c0 1.6-.5 3-1.5 4a7 7 0 0 1-4 2.2v.2c2.2.3 3.8 1 5 2.2a6.4 6.4 0 0 1 1.6 4.6c0 1.7-.5 3.1-1.4 4.4a9.7 9.7 0 0 1-4 3.1c-1.7.8-3.7 1.1-5.8 1.1Z"/></g><defs><linearGradient id="a" x1="20" x2="20" y1="0" y2="30.1" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-color="#3D3D3D"/></linearGradient><linearGradient id="b" x1="20" x2="20" y1="0" y2="30.1" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-color="#3D3D3D"/></linearGradient></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
2
examples/package.json
vendored
2
examples/package.json
vendored
@@ -9,6 +9,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "27.4.1",
|
||||
"@vercel/frameworks": "1.3.0"
|
||||
"@vercel/frameworks": "1.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "preact build",
|
||||
"build": "NODE_OPTIONS=--openssl-legacy-provider preact build",
|
||||
"serve": "sirv build --port 8080 --cors --single",
|
||||
"dev": "preact watch",
|
||||
"lint": "eslint src",
|
||||
"test": "jest"
|
||||
},
|
||||
"engines": {
|
||||
"node": "16.x"
|
||||
"node": "18.x"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "preact",
|
||||
@@ -17,19 +17,19 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"enzyme": "^3.10.0",
|
||||
"enzyme-adapter-preact-pure": "^2.0.0",
|
||||
"eslint": "^6.0.1",
|
||||
"eslint-config-preact": "^1.1.0",
|
||||
"jest": "^24.9.0",
|
||||
"jest-preset-preact": "^1.0.0",
|
||||
"preact-cli": "^3.0.0",
|
||||
"sirv-cli": "1.0.3"
|
||||
"enzyme": "^3.11.0",
|
||||
"enzyme-adapter-preact-pure": "^4.1.0",
|
||||
"eslint": "^8.41.0",
|
||||
"eslint-config-preact": "^1.3.0",
|
||||
"jest": "^29.5.0",
|
||||
"jest-preset-preact": "^4.0.4",
|
||||
"preact-cli": "^3.4.5",
|
||||
"sirv-cli": "2.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"preact": "^10.3.2",
|
||||
"preact-render-to-string": "^5.1.4",
|
||||
"preact-router": "^3.2.1"
|
||||
"preact": "^10.15.0",
|
||||
"preact-render-to-string": "6.0.3",
|
||||
"preact-router": "^4.1.1"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "jest-preset-preact",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"],
|
||||
};
|
||||
|
||||
7
examples/remix/.gitignore
vendored
7
examples/remix/.gitignore
vendored
@@ -1,11 +1,12 @@
|
||||
node_modules
|
||||
|
||||
.cache
|
||||
/.cache
|
||||
/build
|
||||
/public/build
|
||||
.env
|
||||
|
||||
.vercel
|
||||
.output
|
||||
|
||||
/build/
|
||||
/public/build
|
||||
/api/index.js
|
||||
/api/index.js.map
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user