Compare commits

...

14 Commits

Author SHA1 Message Date
Vercel Release Bot
f43e413ba5 Version Packages (#10302)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-08-08 17:10:58 -05:00
Nathan Rajlich
0945d24cbe [remix] Add initial support for Hydrogen v2 (#10305)
Enables support for Hydrogen v2 apps using the "Remix" preset. This initial support works with the Hydrogen demo store template unmodified, and all pages will use Edge functions.

Node.js runtime, and also any other configuration via `export const config`, are not supported at this time, due to some pending blockers.
2023-08-08 21:39:57 +00:00
Steven
a8ecf40d6f [build-utils] Fix getPrefixedEnvVars() to handle VERCEL_BRANCH_URL (#10315)
- Fixes [NEXT-1500](https://linear.app/vercel/issue/NEXT-1500)
- See
[documentation](https://vercel.com/docs/concepts/projects/environment-variables/system-environment-variables#framework-environment-variables)
2023-08-08 14:43:20 -04:00
Steven
2995781a58 Revert "Just testing changeset stuff"
This reverts commit e2096c268d.
2023-08-07 16:36:40 -04:00
Trek Glowacki
e2096c268d Just testing changeset stuff 2023-08-07 15:33:26 -05:00
Amy
663d8e6437 Update discussion templates (#10297)
The current note on discussion templates is not bold enough and easily overlooked.

In an effort to reduce off-topic discussion clutter in this repo and direct folks to the general support community which I monitor more closely, this update makes the note more prominent.
2023-08-07 18:33:41 +00:00
Trek Glowacki
153ca440fa Add basic tests for remove command (#10304)
Adds some minimal unit testing for the `remove` command. There is currently none.
2023-08-07 18:14:21 +00:00
Steven
c5e0cb4812 chore(ci): fix turbo logs to no longer group (#10310)
When we upgraded `turbo`, the default `--log-order` changed from `stream` to `grouped`.

This PR changes it back to `stream` which will prevent the collapsed group view that we currently see in CI.

https://turbo.build/repo/docs/reference/command-line-reference/run#--log-order

> If log order is set to auto and turbo detects that it is running on GitHub Actions, then turbo will create grouped logs(opens in a new tab). You can opt out of this behavior by setting a log order of your own.
2023-08-07 16:44:41 +00:00
Seiya Nuta
5bf1fe4c74 [next] Preserve sourceMappingURL comments in template literals (#10275)
We noticed some edge function builds are failing unexpectedly due to `SyntaxError`. It's triggered by valid code in [Webpack's style-loader](16e401b17a/src/runtime/styleDomAPI.js (L35C1-L40C1)):

```
  if (sourceMap && typeof btoa !== "undefined") {
    css += `\n/*# sourceMappingURL=data:application/json;base64,${btoa(
      unescape(encodeURIComponent(JSON.stringify(sourceMap)))
    )} */`;
  }
```

After minification this script looks like:

```
sourceMap&&typeof btoa!="undefined"&&(css+=`
/*# sourceMappingURL=data:application/json;base64,${btoa(unescape(encodeURIComponent(JSON.stringify(sourceMap))))} */`);
```

The key point here is `\n` is replaced with newline character (`0x0A`) and the second line starts with `/*# sourceMappingURL=`. This confuses `removeComments` API in [convert-source-map](https://github.com/thlorenz/convert-source-map/tree/master) package and it removes the second line:

```
sourceMap&&typeof btoa!="undefined"&&(css+=`

```

In this PR, we implement source map stripping based on that package's regex but add some heuristic checks to prevent false positives like above.
2023-08-07 16:05:39 +00:00
Vercel Release Bot
011f836aa7 [examples][tests] Upgrade Next.js to version 13.4.13 (#10308)
This auto-generated PR updates 3 packages to Next.js version 13.4.13
2023-08-07 15:12:14 +00:00
Trek Glowacki
ec107d7c91 Handle calls for deployment aliases when mocking deployment API (#10303)
Attempting to add tests to unblock a contributor https://github.com/vercel/vercel/pull/10236#issuecomment-1664183891 and finding a deployment also checks its aliases. This PR updates the mock deployment code to also create a handler for its aliases.
2023-08-07 14:34:49 +00:00
Shu Uesugi
0d27ae3b1a Remove unused code (#10309)
Reference was removed in https://github.com/vercel/vercel/pull/8377 but the code wasn't removed.
2023-08-07 14:10:22 +00:00
Vercel Release Bot
08da4b9c92 [remix] Update @remix-run/dev to v1.19.2 (#10299)
This auto-generated PR updates `@remix-run/dev` to version 1.19.2.
2023-08-04 23:17:40 +00:00
Trek Glowacki
877f09ff5c Be looser with mock server URLs (#10300)
Co-authored-by: Trek Glowacki <trek.glowacki@vercel.com>
2023-08-04 10:20:33 -05:00
150 changed files with 26435 additions and 498 deletions

View File

@@ -2,7 +2,9 @@ body:
- type: markdown
attributes:
value: |
> **Note**: For discussions not related to Vercel CLI or Runtimes, please visit the [Vercel Community](https://github.com/orgs/vercel/discussions)
**Note**: This category is intended for discussions related to Vercel CLI or Runtimes.
If you post in this repository seeking help with other Vercel tools and features, it may be missed by our support team. For help with topics other than the CLI and Runtimes, please visit the [Vercel Community](https://github.com/orgs/vercel/discussions).
- type: textarea
attributes:
label: Description

View File

@@ -2,9 +2,11 @@ body:
- type: markdown
attributes:
value: |
> **Note**: For discussions not related to Vercel CLI or Runtimes, please visit the [Vercel Community](https://github.com/orgs/vercel/discussions/categories/help)
**Note**: This category is intended for discussions related to Vercel CLI or Runtimes.
If you post in this repository seeking help with other Vercel tools and features, it may be missed by our support team. For help with topics other than the CLI and Runtimes, please visit the [Vercel Community](https://github.com/orgs/vercel/discussions).
- type: textarea
attributes:
label: Problem Description
label: Question
validations:
required: true

View File

@@ -2,7 +2,9 @@ body:
- type: markdown
attributes:
value: |
> **Note**: For discussions not related to Vercel CLI or Runtimes, please visit the [Vercel Community](https://github.com/orgs/vercel/discussions/categories/ideas)
**Note**: This category is intended for sharing ideas related to Vercel CLI or Runtimes.
Please visit the [Vercel Community](https://github.com/orgs/vercel/discussions) to share ideas for other Vercel tools and features.
- type: textarea
attributes:
label: Idea

View File

@@ -1,10 +0,0 @@
body:
- type: markdown
attributes:
value: |
> **Note**: For discussions not related to Vercel CLI or Runtimes, please visit the [Vercel Community](https://github.com/orgs/vercel/discussions)
- type: textarea
attributes:
label: Description
validations:
required: true

View File

@@ -2,7 +2,9 @@ body:
- type: markdown
attributes:
value: |
> **Note**: For discussions not related to Vercel CLI or Runtimes, please visit the [Vercel Community](https://github.com/orgs/vercel/discussions)
**Note**: This category is intended for discussions related to Vercel CLI or Runtimes.
For topics related to other Vercel features, please visit the [Vercel Community](https://github.com/orgs/vercel/discussions).
- type: textarea
attributes:
label: Description

View File

@@ -1,5 +0,0 @@
body:
- type: markdown
attributes:
value: |
> **Note**: This category should not be used for new discussions. Please visit the [Vercel Community](https://github.com/orgs/vercel/discussions)

View File

@@ -81,11 +81,11 @@ jobs:
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
run: node utils/gen.js && node_modules/.bin/turbo run build --cache-dir=".turbo" --log-order=stream --scope=${{matrix.packageName}} --include-dependencies --no-deps
env:
FORCE_COLOR: '1'
- name: Test ${{matrix.packageName}}
run: node utils/gen.js && node_modules/.bin/turbo run test --cache-dir=".turbo" --scope=${{matrix.packageName}} --no-deps -- ${{ join(matrix.testPaths, ' ') }}
run: node utils/gen.js && node_modules/.bin/turbo run test --cache-dir=".turbo" --log-order=stream --scope=${{matrix.packageName}} --no-deps -- ${{ join(matrix.testPaths, ' ') }}
shell: bash
env:
VERCEL_CLI_VERSION: ${{ needs.setup.outputs.dplUrl }}/tarballs/vercel.tgz

View File

@@ -42,8 +42,8 @@
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(25%, auto));
width: var(--max-width);
max-width: 100%;
width: var(--max-width);
}
.card {

View File

@@ -8,9 +8,9 @@
"name": "nextjs",
"version": "0.1.0",
"dependencies": {
"eslint": "8.45.0",
"eslint-config-next": "13.4.12",
"next": "13.4.12",
"eslint": "8.46.0",
"eslint-config-next": "13.4.13",
"next": "13.4.13",
"react": "18.2.0",
"react-dom": "18.2.0"
}
@@ -49,17 +49,17 @@
}
},
"node_modules/@eslint-community/regexpp": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.1.tgz",
"integrity": "sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==",
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.6.2.tgz",
"integrity": "sha512-pPTNuaAG3QMH+buKyBIGJs3g/S5y0caxw0ygM3YyE6yJFySwiGGSzA+mM3KJ8QQvzeLh3blwgSonkFjgQdxzMw==",
"engines": {
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
},
"node_modules/@eslint/eslintrc": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.0.tgz",
"integrity": "sha512-Lj7DECXqIVCqnqjjHMPna4vn6GJcMgul/wuS0je9OZ9gsL0zzDpKPVtcG1HaDVc+9y+qgXneTeUMbCqXJNpH1A==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.1.tgz",
"integrity": "sha512-9t7ZA7NGGK8ckelF0PQCfcxIUzs1Md5rrO6U/c+FIQNanea5UZC0wqKXH4vHBccmu4ZJgZ2idtPeW7+Q2npOEA==",
"dependencies": {
"ajv": "^6.12.4",
"debug": "^4.3.2",
@@ -79,9 +79,9 @@
}
},
"node_modules/@eslint/js": {
"version": "8.44.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.44.0.tgz",
"integrity": "sha512-Ag+9YM4ocKQx9AarydN0KY2j0ErMHNIocPDrVo8zAE44xLTjEtz81OdR68/cydGtk6m6jDb5Za3r2useMzYmSw==",
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.46.0.tgz",
"integrity": "sha512-a8TLtmPi8xzPkCbp/OGFUo5yhRkHM2Ko9kOWP4znJr0WAhWyThaw3PnwX4vOTWOAMsV2uRt32PPDcEz63esSaA==",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
@@ -117,22 +117,22 @@
"integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA=="
},
"node_modules/@next/env": {
"version": "13.4.12",
"resolved": "https://registry.npmjs.org/@next/env/-/env-13.4.12.tgz",
"integrity": "sha512-RmHanbV21saP/6OEPBJ7yJMuys68cIf8OBBWd7+uj40LdpmswVAwe1uzeuFyUsd6SfeITWT3XnQfn6wULeKwDQ=="
"version": "13.4.13",
"resolved": "https://registry.npmjs.org/@next/env/-/env-13.4.13.tgz",
"integrity": "sha512-fwz2QgVg08v7ZL7KmbQBLF2PubR/6zQdKBgmHEl3BCyWTEDsAQEijjw2gbFhI1tcKfLdOOJUXntz5vZ4S0Polg=="
},
"node_modules/@next/eslint-plugin-next": {
"version": "13.4.12",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-13.4.12.tgz",
"integrity": "sha512-6rhK9CdxEgj/j1qvXIyLTWEaeFv7zOK8yJMulz3Owel0uek0U9MJCGzmKgYxM3aAUBo3gKeywCZKyQnJKto60A==",
"version": "13.4.13",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-13.4.13.tgz",
"integrity": "sha512-RpZeXlPxQ9FLeYN84XHDqRN20XxmVNclYCraLYdifRsmibtcWUWdwE/ANp2C8kgesFRsvwfsw6eOkYNl9sLJ3A==",
"dependencies": {
"glob": "7.1.7"
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "13.4.12",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.4.12.tgz",
"integrity": "sha512-deUrbCXTMZ6ZhbOoloqecnUeNpUOupi8SE2tx4jPfNS9uyUR9zK4iXBvH65opVcA/9F5I/p8vDXSYbUlbmBjZg==",
"version": "13.4.13",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.4.13.tgz",
"integrity": "sha512-ZptVhHjzUuivnXMNCJ6lER33HN7lC+rZ01z+PM10Ows21NHFYMvGhi5iXkGtBDk6VmtzsbqnAjnx4Oz5um0FjA==",
"cpu": [
"arm64"
],
@@ -145,9 +145,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "13.4.12",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.4.12.tgz",
"integrity": "sha512-WRvH7RxgRHlC1yb5oG0ZLx8F7uci9AivM5/HGGv9ZyG2Als8Ij64GC3d+mQ5sJhWjusyU6T6V1WKTUoTmOB0zQ==",
"version": "13.4.13",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.4.13.tgz",
"integrity": "sha512-t9nTiWCLApw8W4G1kqJyYP7y6/7lyal3PftmRturIxAIBlZss9wrtVN8nci50StDHmIlIDxfguYIEGVr9DbFTg==",
"cpu": [
"x64"
],
@@ -160,9 +160,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "13.4.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.4.12.tgz",
"integrity": "sha512-YEKracAWuxp54tKiAvvq73PUs9lok57cc8meYRibTWe/VdPB2vLgkTVWFcw31YDuRXdEhdX0fWS6Q+ESBhnEig==",
"version": "13.4.13",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.4.13.tgz",
"integrity": "sha512-xEHUqC8eqR5DHe8SOmMnDU1K3ggrJ28uIKltrQAwqFSSSmzjnN/XMocZkcVhuncuxYrpbri0iMQstRyRVdQVWg==",
"cpu": [
"arm64"
],
@@ -175,9 +175,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "13.4.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.4.12.tgz",
"integrity": "sha512-LhJR7/RAjdHJ2Isl2pgc/JaoxNk0KtBgkVpiDJPVExVWA1c6gzY57+3zWuxuyWzTG+fhLZo2Y80pLXgIJv7g3g==",
"version": "13.4.13",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.4.13.tgz",
"integrity": "sha512-sNf3MnLAm8rquSSAoeD9nVcdaDeRYOeey4stOWOyWIgbBDtP+C93amSgH/LPTDoUV7gNiU6f+ghepTjTjRgIUQ==",
"cpu": [
"arm64"
],
@@ -190,9 +190,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "13.4.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.4.12.tgz",
"integrity": "sha512-1DWLL/B9nBNiQRng+1aqs3OaZcxC16Nf+mOnpcrZZSdyKHek3WQh6j/fkbukObgNGwmCoVevLUa/p3UFTTqgqg==",
"version": "13.4.13",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.4.13.tgz",
"integrity": "sha512-WhcRaJJSHyx9OWmKjjz+OWHumiPZWRqmM/09Bt7Up4UqUJFFhGExeztR4trtv3rflvULatu9IH/nTV8fUUgaMA==",
"cpu": [
"x64"
],
@@ -205,9 +205,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "13.4.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.4.12.tgz",
"integrity": "sha512-kEAJmgYFhp0VL+eRWmUkVxLVunn7oL9Mdue/FS8yzRBVj7Z0AnIrHpTIeIUl1bbdQq1VaoOztnKicAjfkLTRCQ==",
"version": "13.4.13",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.4.13.tgz",
"integrity": "sha512-+Y4LLhOWWZQIDKVwr2R17lq2KSN0F1c30QVgGIWfnjjHpH8nrIWHEndhqYU+iFuW8It78CiJjQKTw4f51HD7jA==",
"cpu": [
"x64"
],
@@ -220,9 +220,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "13.4.12",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.4.12.tgz",
"integrity": "sha512-GMLuL/loR6yIIRTnPRY6UGbLL9MBdw2anxkOnANxvLvsml4F0HNIgvnU3Ej4BjbqMTNjD4hcPFdlEow4XHPdZA==",
"version": "13.4.13",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.4.13.tgz",
"integrity": "sha512-rWurdOR20uxjfqd1X9vDAgv0Jb26KjyL8akF9CBeFqX8rVaBAnW/Wf6A2gYEwyYY4Bai3T7p1kro6DFrsvBAAw==",
"cpu": [
"arm64"
],
@@ -235,9 +235,9 @@
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
"version": "13.4.12",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.4.12.tgz",
"integrity": "sha512-PhgNqN2Vnkm7XaMdRmmX0ZSwZXQAtamBVSa9A/V1dfKQCV1rjIZeiy/dbBnVYGdj63ANfsOR/30XpxP71W0eww==",
"version": "13.4.13",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.4.13.tgz",
"integrity": "sha512-E8bSPwRuY5ibJ3CzLQmJEt8qaWrPYuUTwnrwygPUEWoLzD5YRx9SD37oXRdU81TgGwDzCxpl7z5Nqlfk50xAog==",
"cpu": [
"ia32"
],
@@ -250,9 +250,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "13.4.12",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.4.12.tgz",
"integrity": "sha512-Z+56e/Ljt0bUs+T+jPjhFyxYBcdY2RIq9ELFU+qAMQMteHo7ymbV7CKmlcX59RI9C4YzN8PgMgLyAoi916b5HA==",
"version": "13.4.13",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.4.13.tgz",
"integrity": "sha512-4KlyC6jWRubPnppgfYsNTPeWfGCxtWLh5vaOAW/kdzAk9widqho8Qb5S4K2vHmal1tsURi7Onk2MMCV1phvyqA==",
"cpu": [
"x64"
],
@@ -334,24 +334,25 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="
},
"node_modules/@typescript-eslint/parser": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz",
"integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.2.1.tgz",
"integrity": "sha512-Ld+uL1kYFU8e6btqBFpsHkwQ35rw30IWpdQxgOqOh4NfxSDH6uCkah1ks8R/RgQqI5hHPXMaLy9fbFseIe+dIg==",
"dependencies": {
"@typescript-eslint/scope-manager": "5.62.0",
"@typescript-eslint/types": "5.62.0",
"@typescript-eslint/typescript-estree": "5.62.0",
"@typescript-eslint/scope-manager": "6.2.1",
"@typescript-eslint/types": "6.2.1",
"@typescript-eslint/typescript-estree": "6.2.1",
"@typescript-eslint/visitor-keys": "6.2.1",
"debug": "^4.3.4"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
"eslint": "^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"typescript": {
@@ -360,15 +361,15 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz",
"integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==",
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.2.1.tgz",
"integrity": "sha512-UCqBF9WFqv64xNsIEPfBtenbfodPXsJ3nPAr55mGPkQIkiQvgoWNo+astj9ZUfJfVKiYgAZDMnM6dIpsxUMp3Q==",
"dependencies": {
"@typescript-eslint/types": "5.62.0",
"@typescript-eslint/visitor-keys": "5.62.0"
"@typescript-eslint/types": "6.2.1",
"@typescript-eslint/visitor-keys": "6.2.1"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
@@ -376,11 +377,11 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz",
"integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==",
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.2.1.tgz",
"integrity": "sha512-528bGcoelrpw+sETlyM91k51Arl2ajbNT9L4JwoXE2dvRe1yd8Q64E4OL7vHYw31mlnVsf+BeeLyAZUEQtqahQ==",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
@@ -388,20 +389,20 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz",
"integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==",
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.2.1.tgz",
"integrity": "sha512-G+UJeQx9AKBHRQBpmvr8T/3K5bJa485eu+4tQBxFq0KoT22+jJyzo1B50JDT9QdC1DEmWQfdKsa8ybiNWYsi0Q==",
"dependencies": {
"@typescript-eslint/types": "5.62.0",
"@typescript-eslint/visitor-keys": "5.62.0",
"@typescript-eslint/types": "6.2.1",
"@typescript-eslint/visitor-keys": "6.2.1",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
"semver": "^7.3.7",
"tsutils": "^3.21.0"
"semver": "^7.5.4",
"ts-api-utils": "^1.0.1"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
@@ -414,15 +415,15 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz",
"integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==",
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.2.1.tgz",
"integrity": "sha512-iTN6w3k2JEZ7cyVdZJTVJx2Lv7t6zFA8DCrJEHD2mwfc16AEvvBWVhbFh34XyG2NORCd0viIgQY1+u7kPI0WpA==",
"dependencies": {
"@typescript-eslint/types": "5.62.0",
"eslint-visitor-keys": "^3.3.0"
"@typescript-eslint/types": "6.2.1",
"eslint-visitor-keys": "^3.4.1"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
@@ -536,6 +537,24 @@
"node": ">=8"
}
},
"node_modules/array.prototype.findlastindex": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.2.tgz",
"integrity": "sha512-tb5thFFlUcp7NdNF6/MpDk/1r/4awWG1FIz3YqDf+/zJSTezBb+/5WViH41obXULHVpDzoiCLpJ/ZO9YbJMsdw==",
"dependencies": {
"call-bind": "^1.0.2",
"define-properties": "^1.1.4",
"es-abstract": "^1.20.4",
"es-shim-unscopables": "^1.0.0",
"get-intrinsic": "^1.1.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/array.prototype.flat": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.1.tgz",
@@ -723,9 +742,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001517",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001517.tgz",
"integrity": "sha512-Vdhm5S11DaFVLlyiKu4hiUTkpZu+y1KA/rZZqVQfOD5YdDT/eQKlkt7NaE0WGOFgX32diqt9MiP9CAiFeRklaA==",
"version": "1.0.30001519",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001519.tgz",
"integrity": "sha512-0QHgqR+Jv4bxHMp8kZ1Kn8CH55OikjKJ6JmKkZYP1F3D7w+lnFXF70nG5eNfsZS89jadi5Ywy5UCSKLAglIRkg==",
"funding": [
{
"type": "opencollective",
@@ -1027,26 +1046,26 @@
}
},
"node_modules/eslint": {
"version": "8.45.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.45.0.tgz",
"integrity": "sha512-pd8KSxiQpdYRfYa9Wufvdoct3ZPQQuVuU5O6scNgMuOMYuxvH0IGaYK0wUFjo4UYYQQCUndlXiMbnxopwvvTiw==",
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.46.0.tgz",
"integrity": "sha512-cIO74PvbW0qU8e0mIvk5IV3ToWdCq5FYG6gWPHHkx6gNdjlbAYvtfHmlCMXxjcoVaIdwy/IAt3+mDkZkfvb2Dg==",
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.4.0",
"@eslint/eslintrc": "^2.1.0",
"@eslint/js": "8.44.0",
"@eslint-community/regexpp": "^4.6.1",
"@eslint/eslintrc": "^2.1.1",
"@eslint/js": "^8.46.0",
"@humanwhocodes/config-array": "^0.11.10",
"@humanwhocodes/module-importer": "^1.0.1",
"@nodelib/fs.walk": "^1.2.8",
"ajv": "^6.10.0",
"ajv": "^6.12.4",
"chalk": "^4.0.0",
"cross-spawn": "^7.0.2",
"debug": "^4.3.2",
"doctrine": "^3.0.0",
"escape-string-regexp": "^4.0.0",
"eslint-scope": "^7.2.0",
"eslint-visitor-keys": "^3.4.1",
"espree": "^9.6.0",
"eslint-scope": "^7.2.2",
"eslint-visitor-keys": "^3.4.2",
"espree": "^9.6.1",
"esquery": "^1.4.2",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
@@ -1080,13 +1099,13 @@
}
},
"node_modules/eslint-config-next": {
"version": "13.4.12",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-13.4.12.tgz",
"integrity": "sha512-ZF0r5vxKaVazyZH/37Au/XItiG7qUOBw+HaH3PeyXltIMwXorsn6bdrl0Nn9N5v5v9spc+6GM2ryjugbjF6X2g==",
"version": "13.4.13",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-13.4.13.tgz",
"integrity": "sha512-EXAh5h1yG/YTNa5YdskzaSZncBjKjvFe2zclMCi2KXyTsXha22wB6MPs/U7idB6a2qjpBdbZcruQY1TWjfNMZw==",
"dependencies": {
"@next/eslint-plugin-next": "13.4.12",
"@next/eslint-plugin-next": "13.4.13",
"@rushstack/eslint-patch": "^1.1.3",
"@typescript-eslint/parser": "^5.42.0",
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.26.0",
@@ -1105,13 +1124,13 @@
}
},
"node_modules/eslint-import-resolver-node": {
"version": "0.3.7",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.7.tgz",
"integrity": "sha512-gozW2blMLJCeFpBwugLTGyvVjNoeo1knonXAcatC6bjPBZitotxdWf7Gimr25N4c0AAOo4eOUfaG82IJPDpqCA==",
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.8.tgz",
"integrity": "sha512-tEe+Pok22qIGaK3KoMP+N96GVDS66B/zreoVVmiavLvRUEmGRtvb4B8wO9jwnb8d2lvHtrkhZ7UD73dWBVnf/Q==",
"dependencies": {
"debug": "^3.2.7",
"is-core-module": "^2.11.0",
"resolve": "^1.22.1"
"is-core-module": "^2.13.0",
"resolve": "^1.22.4"
}
},
"node_modules/eslint-import-resolver-node/node_modules/debug": {
@@ -1201,25 +1220,28 @@
}
},
"node_modules/eslint-plugin-import": {
"version": "2.27.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.27.5.tgz",
"integrity": "sha512-LmEt3GVofgiGuiE+ORpnvP+kAm3h6MLZJ4Q5HCyHADofsb4VzXFsRiWj3c0OFiV+3DWFh0qg3v9gcPlfc3zRow==",
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.28.0.tgz",
"integrity": "sha512-B8s/n+ZluN7sxj9eUf7/pRFERX0r5bnFA2dCaLHy2ZeaQEAz0k+ZZkFWRFHJAqxfxQDx6KLv9LeIki7cFdwW+Q==",
"dependencies": {
"array-includes": "^3.1.6",
"array.prototype.findlastindex": "^1.2.2",
"array.prototype.flat": "^1.3.1",
"array.prototype.flatmap": "^1.3.1",
"debug": "^3.2.7",
"doctrine": "^2.1.0",
"eslint-import-resolver-node": "^0.3.7",
"eslint-module-utils": "^2.7.4",
"eslint-module-utils": "^2.8.0",
"has": "^1.0.3",
"is-core-module": "^2.11.0",
"is-core-module": "^2.12.1",
"is-glob": "^4.0.3",
"minimatch": "^3.1.2",
"object.fromentries": "^2.0.6",
"object.groupby": "^1.0.0",
"object.values": "^1.1.6",
"resolve": "^1.22.1",
"semver": "^6.3.0",
"tsconfig-paths": "^3.14.1"
"resolve": "^1.22.3",
"semver": "^6.3.1",
"tsconfig-paths": "^3.14.2"
},
"engines": {
"node": ">=4"
@@ -1293,9 +1315,9 @@
}
},
"node_modules/eslint-plugin-react": {
"version": "7.33.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.33.0.tgz",
"integrity": "sha512-qewL/8P34WkY8jAqdQxsiL82pDUeT7nhs8IsuXgfgnsEloKCT4miAV9N9kGtx7/KM9NH/NCGUE7Edt9iGxLXFw==",
"version": "7.33.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.33.1.tgz",
"integrity": "sha512-L093k0WAMvr6VhNwReB8VgOq5s2LesZmrpPdKz/kZElQDzqS7G7+DnKoqT+w4JwuiGeAhAvHO0fvy0Eyk4ejDA==",
"dependencies": {
"array-includes": "^3.1.6",
"array.prototype.flatmap": "^1.3.1",
@@ -1367,9 +1389,9 @@
}
},
"node_modules/eslint-scope": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.1.tgz",
"integrity": "sha512-CvefSOsDdaYYvxChovdrPo/ZGt8d5lrJWleAc1diXRKhHGiTYEI26cvo8Kle/wGnsizoCJjK73FMg1/IkIwiNA==",
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
"integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^5.2.0"
@@ -1382,9 +1404,9 @@
}
},
"node_modules/eslint-visitor-keys": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz",
"integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.2.tgz",
"integrity": "sha512-8drBzUEyZ2llkpCA67iYrgEssKDUu68V8ChqqOfFupIaG/LCVPUT+CoGJpT77zJprs4T/W7p07LP7zAIMuweVw==",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
@@ -1447,9 +1469,9 @@
}
},
"node_modules/execa": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-7.1.1.tgz",
"integrity": "sha512-wH0eMf/UXckdUYnO21+HDztteVv05rq2GXksxT4fCGeHkBhw1DROXh40wcjMcRqDOWE7iPJ4n3M7e2+YFP+76Q==",
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-7.2.0.tgz",
"integrity": "sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==",
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^6.0.1",
@@ -1474,9 +1496,9 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"node_modules/fast-glob": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.0.tgz",
"integrity": "sha512-ChDuvbOypPuNjO8yIDf36x7BlZX1smcUMTTcyoIjycexOxd6DFsKsg21qVBzEmr3G7fUKIRy2/psii+CIUt7FA==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
"integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
@@ -1959,9 +1981,9 @@
}
},
"node_modules/is-core-module": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.1.tgz",
"integrity": "sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==",
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz",
"integrity": "sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==",
"dependencies": {
"has": "^1.0.3"
},
@@ -2237,9 +2259,9 @@
}
},
"node_modules/jsx-ast-utils": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.4.tgz",
"integrity": "sha512-fX2TVdCViod6HwKEtSWGHs57oFhVfCMwieb9PuRDgjDPh5XeqJiHFFFJCHxU5cnTc3Bu/GRL+kPiFmw8XWOfKw==",
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
"integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==",
"dependencies": {
"array-includes": "^3.1.6",
"array.prototype.flat": "^1.3.1",
@@ -2399,11 +2421,11 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="
},
"node_modules/next": {
"version": "13.4.12",
"resolved": "https://registry.npmjs.org/next/-/next-13.4.12.tgz",
"integrity": "sha512-eHfnru9x6NRmTMcjQp6Nz0J4XH9OubmzOa7CkWL+AUrUxpibub3vWwttjduu9No16dug1kq04hiUUpo7J3m3Xw==",
"version": "13.4.13",
"resolved": "https://registry.npmjs.org/next/-/next-13.4.13.tgz",
"integrity": "sha512-A3YVbVDNeXLhWsZ8Nf6IkxmNlmTNz0yVg186NJ97tGZqPDdPzTrHotJ+A1cuJm2XfuWPrKOUZILl5iBQkIf8Jw==",
"dependencies": {
"@next/env": "13.4.12",
"@next/env": "13.4.13",
"@swc/helpers": "0.5.1",
"busboy": "1.6.0",
"caniuse-lite": "^1.0.30001406",
@@ -2419,19 +2441,18 @@
"node": ">=16.8.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "13.4.12",
"@next/swc-darwin-x64": "13.4.12",
"@next/swc-linux-arm64-gnu": "13.4.12",
"@next/swc-linux-arm64-musl": "13.4.12",
"@next/swc-linux-x64-gnu": "13.4.12",
"@next/swc-linux-x64-musl": "13.4.12",
"@next/swc-win32-arm64-msvc": "13.4.12",
"@next/swc-win32-ia32-msvc": "13.4.12",
"@next/swc-win32-x64-msvc": "13.4.12"
"@next/swc-darwin-arm64": "13.4.13",
"@next/swc-darwin-x64": "13.4.13",
"@next/swc-linux-arm64-gnu": "13.4.13",
"@next/swc-linux-arm64-musl": "13.4.13",
"@next/swc-linux-x64-gnu": "13.4.13",
"@next/swc-linux-x64-musl": "13.4.13",
"@next/swc-win32-arm64-msvc": "13.4.13",
"@next/swc-win32-ia32-msvc": "13.4.13",
"@next/swc-win32-x64-msvc": "13.4.13"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
"fibers": ">= 3.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sass": "^1.3.0"
@@ -2440,9 +2461,6 @@
"@opentelemetry/api": {
"optional": true
},
"fibers": {
"optional": true
},
"sass": {
"optional": true
}
@@ -2543,6 +2561,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object.groupby": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.0.tgz",
"integrity": "sha512-70MWG6NfRH9GnbZOikuhPPYzpUpof9iW2J9E4dW7FXTqPNb6rllE6u39SKwwiNh8lCwX3DDb5OgcKGiEBrTTyw==",
"dependencies": {
"call-bind": "^1.0.2",
"define-properties": "^1.2.0",
"es-abstract": "^1.21.2",
"get-intrinsic": "^1.2.1"
}
},
"node_modules/object.hasown": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.2.tgz",
@@ -2836,11 +2865,11 @@
}
},
"node_modules/resolve": {
"version": "1.22.2",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz",
"integrity": "sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==",
"version": "1.22.4",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.4.tgz",
"integrity": "sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg==",
"dependencies": {
"is-core-module": "^2.11.0",
"is-core-module": "^2.13.0",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
@@ -3316,6 +3345,17 @@
"node": ">=8.0"
}
},
"node_modules/ts-api-utils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.1.tgz",
"integrity": "sha512-lC/RGlPmwdrIBFTX59wwNzqh7aR2otPNPR/5brHZm/XKFYKsfqxihXUe9pU3JI+3vGkl+vyCoNNnPhJn3aLK1A==",
"engines": {
"node": ">=16.13.0"
},
"peerDependencies": {
"typescript": ">=4.2.0"
}
},
"node_modules/tsconfig-paths": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz",
@@ -3328,28 +3368,9 @@
}
},
"node_modules/tslib": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz",
"integrity": "sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA=="
},
"node_modules/tsutils": {
"version": "3.21.0",
"resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz",
"integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==",
"dependencies": {
"tslib": "^1.8.1"
},
"engines": {
"node": ">= 6"
},
"peerDependencies": {
"typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta"
}
},
"node_modules/tsutils/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.1.tgz",
"integrity": "sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig=="
},
"node_modules/type-check": {
"version": "0.4.0",

View File

@@ -9,9 +9,9 @@
"lint": "next lint"
},
"dependencies": {
"eslint": "8.45.0",
"eslint-config-next": "13.4.12",
"next": "13.4.12",
"eslint": "8.46.0",
"eslint-config-next": "13.4.13",
"next": "13.4.13",
"react": "18.2.0",
"react-dom": "18.2.0"
}

View File

@@ -1,5 +1,12 @@
# @vercel-internals/types
## 1.0.6
### Patch Changes
- Updated dependencies [[`a8ecf40d6`](https://github.com/vercel/vercel/commit/a8ecf40d6f50e2fc8b13b02c8ef50b3dcafad3a6)]:
- @vercel/build-utils@6.8.3
## 1.0.5
### Patch Changes

View File

@@ -1,13 +1,13 @@
{
"private": true,
"name": "@vercel-internals/types",
"version": "1.0.5",
"version": "1.0.6",
"types": "index.d.ts",
"main": "index.d.ts",
"dependencies": {
"@types/node": "14.14.31",
"@vercel-internals/constants": "1.0.4",
"@vercel/build-utils": "6.8.2",
"@vercel/build-utils": "6.8.3",
"@vercel/routing-utils": "2.2.1"
},
"devDependencies": {

View File

@@ -1,5 +1,11 @@
# @vercel/build-utils
## 6.8.3
### Patch Changes
- Fix `getPrefixedEnvVars()` to handle `VERCEL_BRANCH_URL` ([#10315](https://github.com/vercel/vercel/pull/10315))
## 6.8.2
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@vercel/build-utils",
"version": "6.8.2",
"version": "6.8.3",
"license": "Apache-2.0",
"main": "./dist/index.js",
"types": "./dist/index.d.js",

View File

@@ -14,7 +14,12 @@ export function getPrefixedEnvVars({
envs: Envs;
}): Envs {
const vercelSystemEnvPrefix = 'VERCEL_';
const allowed = ['VERCEL_URL', 'VERCEL_ENV', 'VERCEL_REGION'];
const allowed = [
'VERCEL_URL',
'VERCEL_ENV',
'VERCEL_REGION',
'VERCEL_BRANCH_URL',
];
const newEnvs: Envs = {};
if (envPrefix && envs.VERCEL_URL) {
Object.keys(envs)

View File

@@ -13,6 +13,8 @@ describe('Test `getPrefixedEnvVars()`', () => {
envs: {
VERCEL: '1',
VERCEL_URL: 'example.vercel.sh',
VERCEL_ENV: 'production',
VERCEL_BRANCH_URL: 'example-git-main-acme.vercel.app',
USER_ENV_VAR_NOT_VERCEL: 'example.com',
VERCEL_ARTIFACTS_TOKEN: 'abc123',
FOO: 'bar',
@@ -20,6 +22,8 @@ describe('Test `getPrefixedEnvVars()`', () => {
},
want: {
NEXT_PUBLIC_VERCEL_URL: 'example.vercel.sh',
NEXT_PUBLIC_VERCEL_ENV: 'production',
NEXT_PUBLIC_VERCEL_BRANCH_URL: 'example-git-main-acme.vercel.app',
TURBO_CI_VENDOR_ENV_KEY: 'NEXT_PUBLIC_VERCEL_',
},
},

View File

@@ -1,5 +1,22 @@
# vercel
## 31.2.3
### Patch Changes
- Be looser in tests with mock server urls ([#10300](https://github.com/vercel/vercel/pull/10300))
- Handle calls for deployment aliases when mocking deployments ([#10303](https://github.com/vercel/vercel/pull/10303))
- Remove unused code ([#10309](https://github.com/vercel/vercel/pull/10309))
- Updated dependencies [[`5bf1fe4c7`](https://github.com/vercel/vercel/commit/5bf1fe4c743f6be3f7d5a24447ea5b083a68dc67), [`a8ecf40d6`](https://github.com/vercel/vercel/commit/a8ecf40d6f50e2fc8b13b02c8ef50b3dcafad3a6), [`08da4b9c9`](https://github.com/vercel/vercel/commit/08da4b9c923501d9d28eb6e3f26f4605fee83042), [`0945d24cb`](https://github.com/vercel/vercel/commit/0945d24cbe901ca3f0eedd011251ad499c72d472)]:
- @vercel/next@3.9.4
- @vercel/build-utils@6.8.3
- @vercel/remix-builder@1.10.0
- @vercel/node@2.15.9
- @vercel/static-build@1.3.45
## 31.2.2
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "vercel",
"version": "31.2.2",
"version": "31.2.3",
"preferGlobal": true,
"license": "Apache-2.0",
"description": "The command-line interface for Vercel",
@@ -31,16 +31,16 @@
"node": ">= 14"
},
"dependencies": {
"@vercel/build-utils": "6.8.2",
"@vercel/build-utils": "6.8.3",
"@vercel/go": "2.5.1",
"@vercel/hydrogen": "0.0.64",
"@vercel/next": "3.9.3",
"@vercel/node": "2.15.8",
"@vercel/next": "3.9.4",
"@vercel/node": "2.15.9",
"@vercel/python": "3.1.60",
"@vercel/redwood": "1.1.15",
"@vercel/remix-builder": "1.9.1",
"@vercel/remix-builder": "1.10.0",
"@vercel/ruby": "1.3.76",
"@vercel/static-build": "1.3.44"
"@vercel/static-build": "1.3.45"
},
"devDependencies": {
"@alex_neo/jest-expect-message": "1.0.5",
@@ -86,8 +86,8 @@
"@types/yauzl-promise": "2.1.0",
"@vercel-internals/constants": "1.0.4",
"@vercel-internals/get-package-json": "1.0.0",
"@vercel-internals/types": "1.0.5",
"@vercel/client": "12.6.5",
"@vercel-internals/types": "1.0.6",
"@vercel/client": "12.6.6",
"@vercel/error-utils": "1.0.10",
"@vercel/frameworks": "1.5.0",
"@vercel/fs-detectors": "4.1.1",
@@ -157,7 +157,6 @@
"semver": "5.5.0",
"serve-handler": "6.1.1",
"strip-ansi": "6.0.1",
"stripe": "5.1.0",
"supports-hyperlinks": "3.0.0",
"tar-fs": "1.16.3",
"test-listen": "1.1.0",

View File

@@ -1,82 +0,0 @@
import stripeFactory from 'stripe';
import Now from '.';
const stripe = stripeFactory('pk_live_alyEi3lN0kSwbdevK0nrGwTw');
export default class CreditCards extends Now {
async ls() {
const res = await this._fetch('/stripe/sources/');
const body = await res.json();
if (res.status !== 200) {
const e = new Error(body.error.message);
e.code = body.error.code;
throw e;
}
return body;
}
async setDefault(source) {
await this._fetch('/stripe/sources/', {
method: 'POST',
body: {
source,
makeDefault: true,
},
});
return true;
}
async rm(source) {
await this._fetch(`/stripe/sources/`, {
method: 'DELETE',
body: { source },
});
return true;
}
async add(card) {
if (!card.expDate) {
throw new Error(`Please define an expiration date for your card`);
}
const expDateParts = card.expDate.split(' / ');
card = {
name: card.name,
number: card.cardNumber,
cvc: card.ccv,
};
card.exp_month = expDateParts[0];
card.exp_year = expDateParts[1];
try {
const token = (await stripe.tokens.create({ card })).id;
const res = await this._fetch('/stripe/sources/', {
method: 'POST',
body: {
source: token,
},
});
const { source, error } = await res.json();
if (source && source.id) {
return {
last4: source.last4,
};
} else if (error && error.message) {
throw new Error(error.message);
} else {
throw new Error('Unknown error');
}
} catch (err) {
throw new Error(err.message || 'Unknown error');
}
}
}

View File

@@ -170,6 +170,14 @@ function setupDeploymentEndpoints(): void {
res.json({ builds });
});
client.scenario.get('/:version/deployments/:id/aliases', (req, res) => {
const limit = parseInt(req.query.limit);
res.json({
aliases: [],
pagination: { count: limit, total: limit, page: 1, pages: 1 },
});
});
function handleGetDeployments(req: Request, res: Response) {
const currentDeployments = Array.from(deployments.values()).sort(
(a: Deployment, b: Deployment) => {

View File

@@ -144,7 +144,7 @@ export const defaultProject: Project = {
*/
export function useUnknownProject() {
let project: Project;
client.scenario.get(`/v8/projects/:projectNameOrId`, (_req, res) => {
client.scenario.get(`/:version/projects/:projectNameOrId`, (_req, res) => {
res.status(404).send();
});
client.scenario.post(`/:version/projects`, (req, res) => {

View File

@@ -0,0 +1,59 @@
import { client } from '../../mocks/client';
import {
defaultProject,
useProject,
useUnknownProject,
} from '../../mocks/project';
import remove from '../../../src/commands/remove';
import { useDeployment } from '../../mocks/deployment';
import { useUser } from '../../mocks/user';
describe('remove', () => {
it('should error if missing deployment url', async () => {
client.setArgv('remove');
const exitCodePromise = remove(client);
await expect(client.stderr).toOutput(
'Error: `vercel rm` expects at least one argument'
);
await expect(exitCodePromise).resolves.toEqual(1);
});
it('should error without calling API for invalid names', async () => {
const badDeployName = '/#';
client.setArgv('remove', badDeployName);
const exitCodePromise = remove(client);
await expect(client.stderr).toOutput(
`Error: The provided argument "${badDeployName}" is not a valid deployment or project`
);
await expect(exitCodePromise).resolves.toEqual(1);
});
it('calls API to delete a project ', async () => {
let deleteAPIWasCalled = false;
const user = useUser();
const project = useProject({
...defaultProject,
id: '123',
});
useUnknownProject();
const deployment = useDeployment({
creator: user,
project,
});
client.scenario.delete('/now/deployments/:id', (req, res) => {
deleteAPIWasCalled = true;
res.json({});
});
client.setArgv('remove', deployment.url, '--yes');
await remove(client);
expect(deleteAPIWasCalled);
});
});

View File

@@ -1,5 +1,12 @@
# @vercel/client
## 12.6.6
### Patch Changes
- Updated dependencies [[`a8ecf40d6`](https://github.com/vercel/vercel/commit/a8ecf40d6f50e2fc8b13b02c8ef50b3dcafad3a6)]:
- @vercel/build-utils@6.8.3
## 12.6.5
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@vercel/client",
"version": "12.6.5",
"version": "12.6.6",
"main": "dist/index.js",
"typings": "dist/index.d.ts",
"homepage": "https://vercel.com",
@@ -35,7 +35,7 @@
"typescript": "4.9.5"
},
"dependencies": {
"@vercel/build-utils": "6.8.2",
"@vercel/build-utils": "6.8.3",
"@vercel/routing-utils": "2.2.1",
"@zeit/fetch": "5.2.0",
"async-retry": "1.2.3",

View File

@@ -36,7 +36,7 @@
"@types/minimatch": "3.0.5",
"@types/node": "14.18.33",
"@types/semver": "7.3.10",
"@vercel/build-utils": "6.8.2",
"@vercel/build-utils": "6.8.3",
"typescript": "4.9.5"
}
}

View File

@@ -1,5 +1,13 @@
# @vercel/gatsby-plugin-vercel-builder
## 1.3.17
### Patch Changes
- Updated dependencies [[`a8ecf40d6`](https://github.com/vercel/vercel/commit/a8ecf40d6f50e2fc8b13b02c8ef50b3dcafad3a6)]:
- @vercel/build-utils@6.8.3
- @vercel/node@2.15.9
## 1.3.16
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@vercel/gatsby-plugin-vercel-builder",
"version": "1.3.16",
"version": "1.3.17",
"main": "dist/index.js",
"files": [
"dist",
@@ -20,8 +20,8 @@
},
"dependencies": {
"@sinclair/typebox": "0.25.24",
"@vercel/build-utils": "6.8.2",
"@vercel/node": "2.15.8",
"@vercel/build-utils": "6.8.3",
"@vercel/node": "2.15.9",
"@vercel/routing-utils": "2.2.1",
"esbuild": "0.14.47",
"etag": "1.8.1",

View File

@@ -27,7 +27,7 @@
"@types/node-fetch": "^2.3.0",
"@types/tar": "^4.0.0",
"@types/yauzl-promise": "2.1.0",
"@vercel/build-utils": "6.8.2",
"@vercel/build-utils": "6.8.3",
"@vercel/ncc": "0.24.0",
"async-retry": "1.3.1",
"execa": "^1.0.0",

View File

@@ -21,7 +21,7 @@
"devDependencies": {
"@types/jest": "27.5.1",
"@types/node": "14.18.33",
"@vercel/build-utils": "6.8.2",
"@vercel/build-utils": "6.8.3",
"@vercel/static-config": "2.0.17",
"execa": "3.2.0",
"fs-extra": "11.1.0",

View File

@@ -1,5 +1,11 @@
# @vercel/next
## 3.9.4
### Patch Changes
- Preserve sourceMappingURL comments in template literals ([#10275](https://github.com/vercel/vercel/pull/10275))
## 3.9.3
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@vercel/next",
"version": "3.9.3",
"version": "3.9.4",
"license": "Apache-2.0",
"main": "./dist/index",
"homepage": "https://vercel.com/docs/runtimes#official-runtimes/next-js",
@@ -35,7 +35,7 @@
"@types/semver": "6.0.0",
"@types/text-table": "0.2.1",
"@types/webpack-sources": "3.2.0",
"@vercel/build-utils": "6.8.2",
"@vercel/build-utils": "6.8.3",
"@vercel/nft": "0.22.5",
"@vercel/routing-utils": "2.2.1",
"async-sema": "3.0.1",

View File

@@ -58,7 +58,7 @@ export async function fileToSource(
fullFilePath?: string
): Promise<Source> {
const sourcemap = await getSourceMap(content, fullFilePath);
const cleanContent = convertSourceMap.removeComments(content);
const cleanContent = removeInlinedSourceMap(content);
return sourcemap
? new SourceMapSource(cleanContent, sourceName, sourcemap)
: new OriginalSource(cleanContent, sourceName);
@@ -99,3 +99,42 @@ export function stringifySourceMap(
delete obj.sourcesContent;
return JSON.stringify(obj);
}
// Based on https://github.com/thlorenz/convert-source-map/blob/f1ed815b4edacfa9c3c5552dd342e71a3cffbb0a/index.js#L4 (MIT license)
// Groups: 1: media type, 2: MIME type, 3: charset, 4: encoding, 5: data.
const SOURCE_MAP_COMMENT_REGEX =
/^\s*?\/[/*][@#]\s+?sourceMappingURL=data:(((?:application|text)\/json)(?:;charset=([^;,]+?)?)?)?(?:;(base64))?,(.*?)$/gm;
function isValidSourceMapData(encoding: string, data: string): boolean {
if (encoding !== 'base64') {
// Unknown encoding. I think the comment is short (e.g. URL) if it's not
// base64 encoded, so let's keep it to be safe.
return false;
}
// Remove any spaces and "*/" of the source map comment. They should not be
// considered as a part of the source map data.
data = data.replace(/\s/g, '').replace(/\*\//g, '');
// If it's an invalid base64 string, it must be a sourceMappingURL
// inside a template literal like the follwoing.
// https://github.com/webpack-contrib/style-loader/blob/16e401b17a39544d5c8ca47c9032f02e2b60d8f5/src/runtime/styleDomAPI.js#L35C1-L40C1
return /^[a-zA-Z0-9+=/]+$/.test(data);
}
/*
* Removes sourceMappingURL comments from a string.
*/
export function removeInlinedSourceMap(source: string): string {
for (const m of source.matchAll(SOURCE_MAP_COMMENT_REGEX)) {
// Check if it's certainly a sourceMappingURL in a comment, not a part
// of JavaScript code (e.g. template literal).
if (!isValidSourceMapData(m[4], m[5])) {
continue;
}
source = source.replace(m[0], '');
}
return source;
}

View File

@@ -0,0 +1,104 @@
import { removeInlinedSourceMap } from '../../src/sourcemapped';
describe('removeInlinedSourceMap', () => {
it('removes inlined source map', () => {
expect(
removeInlinedSourceMap(`
function foo() {
return 1;
}
/*# sourceMappingURL=data:application/json;base64,abcdabcd12341234 */
`)
).toEqual(`
function foo() {
return 1;
}
`);
});
it('removes multiple inlined source maps', () => {
expect(
removeInlinedSourceMap(`
function foo() {
return 1;
}
/*# sourceMappingURL=data:application/json;base64,abcdabcd12341234 */
/*# sourceMappingURL=data:application/json;base64,cdefAB+/== */
`)
).toEqual(`
function foo() {
return 1;
}
`);
});
it('preserves non-base64 source map comments', () => {
expect(
removeInlinedSourceMap(`
function foo() {
return 1;
}
//# sourceMappingURL=script.min.js.map
`)
).toEqual(`
function foo() {
return 1;
}
//# sourceMappingURL=script.min.js.map
`);
});
it('preserves source map comments in the middle', () => {
expect(
removeInlinedSourceMap(`
function foo() {
console.log('/*# sourceMappingURL=data:application/json;base64,abcdabcd12341234 */')
}
`)
).toEqual(`
function foo() {
console.log('/*# sourceMappingURL=data:application/json;base64,abcdabcd12341234 */')
}
`);
});
it(`doesn't remove sourceMappingURL inside string literal`, () => {
expect(
removeInlinedSourceMap(`
css += \`
/*# sourceMappingURL=data:application/json;base64,\${btoa(
unescape(encodeURIComponent(JSON.stringify(sourceMap)))
)} */\`
`)
).toEqual(`
css += \`
/*# sourceMappingURL=data:application/json;base64,\${btoa(
unescape(encodeURIComponent(JSON.stringify(sourceMap)))
)} */\`
`);
});
// Assuming that our bundler doesn't generate source maps like this, multiple comments
// may indicate that it's part of the original source code, not generated by the bundler.
it(`doesn't remove if there's another comment in the same line`, () => {
expect(
removeInlinedSourceMap(`
function foo() {
return 1;
}
/*# sourceMappingURL=data:application/json;base64,abcdabcd12341234 */ /* second comment */
`)
).toEqual(`
function foo() {
return 1;
}
/*# sourceMappingURL=data:application/json;base64,abcdabcd12341234 */ /* second comment */
`);
});
});

View File

@@ -1,5 +1,12 @@
# @vercel/node
## 2.15.9
### Patch Changes
- Updated dependencies [[`a8ecf40d6`](https://github.com/vercel/vercel/commit/a8ecf40d6f50e2fc8b13b02c8ef50b3dcafad3a6)]:
- @vercel/build-utils@6.8.3
## 2.15.8
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@vercel/node",
"version": "2.15.8",
"version": "2.15.9",
"license": "Apache-2.0",
"main": "./dist/index",
"homepage": "https://vercel.com/docs/runtimes#official-runtimes/node-js",
@@ -24,7 +24,7 @@
"@edge-runtime/vm": "3.0.1",
"@types/node": "14.18.33",
"@types/node-fetch": "2.6.3",
"@vercel/build-utils": "6.8.2",
"@vercel/build-utils": "6.8.3",
"@vercel/error-utils": "1.0.10",
"@vercel/static-config": "2.0.17",
"async-listen": "3.0.0",

View File

@@ -23,7 +23,7 @@
"@types/execa": "^0.9.0",
"@types/jest": "27.4.1",
"@types/node": "14.18.33",
"@vercel/build-utils": "6.8.2",
"@vercel/build-utils": "6.8.3",
"@vercel/ncc": "0.24.0",
"execa": "^1.0.0"
}

View File

@@ -27,7 +27,7 @@
"@types/aws-lambda": "8.10.19",
"@types/node": "14.18.33",
"@types/semver": "6.0.0",
"@vercel/build-utils": "6.8.2",
"@vercel/build-utils": "6.8.3",
"execa": "3.2.0",
"fs-extra": "11.1.0"
}

View File

@@ -1,5 +1,18 @@
# @vercel/remix-builder
## 1.10.0
### Minor Changes
- Add initial support for Hydrogen v2 ([#10305](https://github.com/vercel/vercel/pull/10305))
### Patch Changes
- Update `@remix-run/dev` fork to v1.19.2 ([#10299](https://github.com/vercel/vercel/pull/10299))
- Updated dependencies [[`a8ecf40d6`](https://github.com/vercel/vercel/commit/a8ecf40d6f50e2fc8b13b02c8ef50b3dcafad3a6)]:
- @vercel/build-utils@6.8.3
## 1.9.1
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@vercel/remix-builder",
"version": "1.9.1",
"version": "1.10.0",
"license": "Apache-2.0",
"main": "./dist/index.js",
"homepage": "https://vercel.com/docs",
@@ -20,7 +20,7 @@
"defaults"
],
"dependencies": {
"@vercel/build-utils": "6.8.2",
"@vercel/build-utils": "6.8.3",
"@vercel/nft": "0.22.5",
"@vercel/static-config": "2.0.17",
"path-to-regexp": "6.2.1",
@@ -28,7 +28,7 @@
"ts-morph": "12.0.0"
},
"devDependencies": {
"@remix-run/dev": "npm:@vercel/remix-run-dev@1.19.1",
"@remix-run/dev": "npm:@vercel/remix-run-dev@1.19.2",
"@types/jest": "27.5.1",
"@types/node": "14.18.33",
"@types/semver": "7.3.13"

View File

@@ -45,6 +45,7 @@ import {
ensureResolvable,
isESM,
} from './utils';
import { patchHydrogenServer } from './hydrogen';
interface ServerBundle {
serverBuildPath: string;
@@ -140,6 +141,10 @@ export const build: BuildV2 = async ({
await runNpmInstall(entrypointFsDirname, [], spawnOpts, meta, nodeVersion);
}
const isHydrogen2 =
pkg.dependencies?.['@shopify/remix-oxygen'] ||
pkg.devDependencies?.['@shopify/remix-oxygen'];
// Determine the version of Remix based on the `@remix-run/dev`
// package version.
const remixRunDevPath = await ensureResolvable(
@@ -162,7 +167,9 @@ export const build: BuildV2 = async ({
const depsToAdd: string[] = [];
if (remixRunDevPkg.name !== '@vercel/remix-run-dev') {
// Override the official `@remix-run/dev` package with the
// Vercel fork, which supports the `serverBundles` config
if (!isHydrogen2 && remixRunDevPkg.name !== '@vercel/remix-run-dev') {
const remixDevForkVersion = resolveSemverMinMax(
REMIX_RUN_DEV_MIN_VERSION,
REMIX_RUN_DEV_MAX_VERSION,
@@ -216,6 +223,8 @@ export const build: BuildV2 = async ({
}
let remixConfigWrapped = false;
let serverEntryPointAbs: string | undefined;
let originalServerEntryPoint: string | undefined;
const remixConfigPath = findConfig(entrypointFsDirname, 'remix.config');
const renamedRemixConfigPath = remixConfigPath
? `${remixConfigPath}.original${extname(remixConfigPath)}`
@@ -232,7 +241,13 @@ export const build: BuildV2 = async ({
const staticConfigsMap = new Map<ConfigRoute, BaseFunctionConfig | null>();
for (const route of remixRoutes) {
const routePath = join(remixConfig.appDirectory, route.file);
const staticConfig = getConfig(project, routePath);
let staticConfig = getConfig(project, routePath);
if (staticConfig && isHydrogen2) {
console.log(
'WARN: `export const config` is currently not supported for Hydrogen v2 apps'
);
staticConfig = null;
}
staticConfigsMap.set(route, staticConfig);
}
@@ -240,7 +255,8 @@ export const build: BuildV2 = async ({
const config = getResolvedRouteConfig(
route,
remixConfig.routes,
staticConfigsMap
staticConfigsMap,
isHydrogen2
);
resolvedConfigsMap.set(route, config);
}
@@ -269,7 +285,12 @@ export const build: BuildV2 = async ({
([hash, routes]) => {
const runtime = resolvedConfigsMap.get(routes[0])?.runtime ?? 'nodejs';
return {
serverBuildPath: `build/build-${runtime}-${hash}.js`,
serverBuildPath: isHydrogen2
? relative(entrypointFsDirname, remixConfig.serverBuildPath)
: `${relative(
entrypointFsDirname,
dirname(remixConfig.serverBuildPath)
)}/build-${runtime}-${hash}.js`,
routes: routes.map(r => r.id),
};
}
@@ -277,7 +298,7 @@ export const build: BuildV2 = async ({
// We need to patch the `remix.config.js` file to force some values necessary
// for a build that works on either Node.js or the Edge runtime
if (remixConfigPath && renamedRemixConfigPath) {
if (!isHydrogen2 && remixConfigPath && renamedRemixConfigPath) {
await fs.rename(remixConfigPath, renamedRemixConfigPath);
let patchedConfig: string;
@@ -307,6 +328,32 @@ module.exports = config;`;
remixConfigWrapped = true;
}
// For Hydrogen v2, patch the `server.ts` file to be Vercel-compatible
if (isHydrogen2) {
if (remixConfig.serverEntryPoint) {
serverEntryPointAbs = join(
entrypointFsDirname,
remixConfig.serverEntryPoint
);
originalServerEntryPoint = await fs.readFile(
serverEntryPointAbs,
'utf8'
);
const patchedServerEntryPoint = patchHydrogenServer(
project,
serverEntryPointAbs
);
if (patchedServerEntryPoint) {
debug(
`Patched Hydrogen server file: ${remixConfig.serverEntryPoint}`
);
await fs.writeFile(serverEntryPointAbs, patchedServerEntryPoint);
}
} else {
console.log('WARN: No "server" field found in Remix config');
}
}
// Make `remix build` output production mode
spawnOpts.env.NODE_ENV = 'production';
@@ -336,10 +383,28 @@ module.exports = config;`;
}
}
} finally {
const cleanupOps: Promise<void>[] = [];
// Clean up our patched `remix.config.js` to be polite
if (remixConfigWrapped && remixConfigPath && renamedRemixConfigPath) {
await fs.rename(renamedRemixConfigPath, remixConfigPath);
cleanupOps.push(
fs
.rename(renamedRemixConfigPath, remixConfigPath)
.then(() =>
debug(`Restored original "${basename(remixConfigPath)}" file`)
)
);
}
// Restore original server entrypoint if it was modified (for Hydrogen v2)
if (serverEntryPointAbs && originalServerEntryPoint) {
cleanupOps.push(
fs
.writeFile(serverEntryPointAbs, originalServerEntryPoint)
.then(() =>
debug(`Restored original "${basename(serverEntryPointAbs!)}" file`)
)
);
}
await Promise.all(cleanupOps);
}
// This needs to happen before we run NFT to create the Node/Edge functions
@@ -349,11 +414,21 @@ module.exports = config;`;
repoRootPath,
'@remix-run/server-runtime'
),
ensureResolvable(entrypointFsDirname, repoRootPath, '@remix-run/node'),
!isHydrogen2
? ensureResolvable(entrypointFsDirname, repoRootPath, '@remix-run/node')
: null,
]);
const staticDir = join(
remixConfig.assetsBuildDirectory,
...remixConfig.publicPath
.replace(/^\/|\/$/g, '')
.split('/')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.map(_ => '..')
);
const [staticFiles, ...functions] = await Promise.all([
glob('**', join(entrypointFsDirname, 'public')),
glob('**', staticDir),
...serverBundles.map(bundle => {
const firstRoute = remixConfig.routes[bundle.routes[0]];
const config = resolvedConfigsMap.get(firstRoute) ?? {

View File

@@ -0,0 +1,106 @@
import { basename } from 'path';
import { Node, Project, SyntaxKind } from 'ts-morph';
/**
* For Hydrogen v2, the `server.ts` file exports a signature like:
*
* ```
* export default {
* async fetch(
* request: Request,
* env: Env,
* executionContext: ExecutionContext,
* ): Promise<Response>;
* }
* ```
*
* Here we parse the AST of that file so that we can:
*
* 1. Convert the signature to be compatible with Vercel Edge functions
* (i.e. `export default (res: Response): Promise<Response>`).
*
* 2. Track usages of the `env` parameter which (which gets removed),
* so that we can create that object based on `process.env`.
*/
export function patchHydrogenServer(
project: Project,
serverEntryPoint: string
) {
const sourceFile = project.addSourceFileAtPath(serverEntryPoint);
const defaultExportSymbol = sourceFile.getDescendantsOfKind(
SyntaxKind.ExportAssignment
)[0];
const envProperties: string[] = [];
if (!defaultExportSymbol) {
console.log(
`WARN: No default export found in "${basename(serverEntryPoint)}"`
);
return;
}
const objectLiteral = defaultExportSymbol.getFirstChildByKind(
SyntaxKind.ObjectLiteralExpression
);
if (!Node.isObjectLiteralExpression(objectLiteral)) {
console.log(
`WARN: Default export in "${basename(
serverEntryPoint
)}" does not conform to Oxygen syntax`
);
return;
}
const fetchMethod = objectLiteral.getProperty('fetch');
if (!fetchMethod || !Node.isMethodDeclaration(fetchMethod)) {
console.log(
`WARN: Default export in "${basename(
serverEntryPoint
)}" does not conform to Oxygen syntax`
);
return;
}
const parameters = fetchMethod.getParameters();
// Find usages of the env object within the fetch method
const envParam = parameters[1];
const envParamName = envParam.getName();
if (envParam) {
fetchMethod.forEachDescendant(node => {
if (
Node.isPropertyAccessExpression(node) &&
node.getExpression().getText() === envParamName
) {
envProperties.push(node.getName());
}
});
}
// Vercel does not support the Web Cache API, so find
// and replace `caches.open()` calls with `undefined`
fetchMethod.forEachDescendant(node => {
if (
Node.isCallExpression(node) &&
node.getExpression().getText() === 'caches.open'
) {
node.replaceWithText(`undefined /* ${node.getText()} */`);
}
});
// Remove the 'env' parameter to match Vercel's Edge signature
parameters.splice(1, 1);
// Construct the new function with the parameters and body of the original fetch method
const newFunction = `export default async function(${parameters
.map(p => p.getText())
.join(', ')}) ${fetchMethod.getBody()!.getText()}`;
defaultExportSymbol.replaceWithText(newFunction);
const envCode = `const env = { ${envProperties
.map(name => `${name}: process.env.${name}`)
.join(', ')} };`;
const updatedCodeString = sourceFile.getFullText();
return `${envCode}\n${updatedCodeString}`;
}

View File

@@ -78,7 +78,8 @@ function isEdgeRuntime(runtime: string): boolean {
export function getResolvedRouteConfig(
route: ConfigRoute,
routes: RouteManifest,
configs: Map<ConfigRoute, BaseFunctionConfig | null>
configs: Map<ConfigRoute, BaseFunctionConfig | null>,
isHydrogen2: boolean
): ResolvedRouteConfig {
let runtime: ResolvedRouteConfig['runtime'] | undefined;
let regions: ResolvedRouteConfig['regions'];
@@ -107,8 +108,8 @@ export function getResolvedRouteConfig(
regions = Array.from(new Set(regions)).sort();
}
if (runtime === 'edge') {
return { runtime, regions };
if (isHydrogen2 || runtime === 'edge') {
return { runtime: 'edge', regions };
}
if (regions && !Array.isArray(regions)) {

View File

@@ -0,0 +1,9 @@
node_modules
/.cache
/build
/dist
/public/build
/.mf
.env
.shopify
.vercel

View File

@@ -0,0 +1 @@
schema: node_modules/@shopify/hydrogen-react/storefront.schema.json

View File

@@ -0,0 +1,40 @@
# Hydrogen template: Skeleton
Hydrogen is Shopifys stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopifys 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
```

View 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)}>
&times;
</a>
);
}

View 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 &rarr;</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} &nbsp;&nbsp;</small>
<CartLineUpdateButton lines={[{id: lineId, quantity: prevQuantity}]}>
<button
aria-label="Decrease quantity"
disabled={quantity <= 1}
name="decrease-quantity"
value={prevQuantity}
>
<span>&#8722; </span>
</button>
</CartLineUpdateButton>
&nbsp;
<CartLineUpdateButton lines={[{id: lineId, quantity: nextQuantity}]}>
<button
aria-label="Increase quantity"
name="increase-quantity"
value={nextQuantity}
>
<span>&#43;</span>
</button>
</CartLineUpdateButton>
&nbsp;
<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&rsquo;t added anything yet, let&rsquo;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>
&nbsp;
<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" />
&nbsp;
<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>
);
}

View 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',
};
}

View 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',
};
}

View 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"
/>
&nbsp;
<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>
);
}

View 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"
/>
&nbsp;
<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>
&nbsp;
</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(',');
}

View 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>,
);
});

View 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,
});
}

View 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;

View 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,
});
}

View 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;

View 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;

View 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;

View 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');
}

View 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;

View 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;

View 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&apos;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;

View 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">
&nbsp; 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;

View 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 &nbsp;
</NavLink>
&nbsp;|&nbsp;
<NavLink to="/account/profile" style={isActiveStyle}>
&nbsp; Profile &nbsp;
</NavLink>
&nbsp;|&nbsp;
<NavLink to="/account/addresses" style={isActiveStyle}>
&nbsp; Addresses &nbsp;
</NavLink>
&nbsp;|&nbsp;
<Logout />
</nav>
);
}
function Logout() {
return (
<Form className="account-logout" method="POST" action="/account/logout">
&nbsp;<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;

View File

@@ -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;

View 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;

View 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;
}

View 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;

View 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;

View File

@@ -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;

View 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;

View File

@@ -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} &middot; {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;

View 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;

View 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;

View 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>
);
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;
}

View 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;
}

View 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() : '');
}

View File

@@ -0,0 +1,39 @@
{
"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",
"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",
"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/react": "^18.0.20",
"@types/react-dom": "^18.0.6",
"prettier": "^2.8.4",
"typescript": "^4.9.5"
},
"engines": {
"node": ">=16.13"
}
}

9521
packages/remix/test/fixtures/10-hydrogen-2/pnpm-lock.yaml generated vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
{
"probes": [
{ "path": "/", "mustContain": "Mock.shop" },
{ "path": "/bad", "mustContain": "/bad not found" }
]
}

View 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

View 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,
},
};

View 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;
}
}

View 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;

File diff suppressed because it is too large Load Diff

View 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
}
}

View File

@@ -0,0 +1,12 @@
{
"build": {
"env":{
"SESSION_SECRET":"foobar",
"PUBLIC_STORE_DOMAIN":"mock.shop"
}
},
"env":{
"SESSION_SECRET":"foobar",
"PUBLIC_STORE_DOMAIN":"mock.shop"
}
}

View File

@@ -0,0 +1,9 @@
node_modules
/.cache
/build
/dist
/public/build
/.mf
.env
.shopify
.vercel

View File

@@ -0,0 +1 @@
schema: node_modules/@shopify/hydrogen-react/storefront.schema.json

View File

@@ -0,0 +1,40 @@
# Hydrogen template: Skeleton
Hydrogen is Shopifys stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopifys 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
```

View File

@@ -0,0 +1,39 @@
/**
* 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'}) {
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)}>
&times;
</a>
);
}

View File

@@ -0,0 +1,281 @@
import {CartForm, Image, Money} from '@shopify/hydrogen';
import {Link} from '@remix-run/react';
import {useVariantUrl} from '~/utils';
export function CartMain({layout, cart}) {
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}) {
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}) {
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}) {
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}) {
if (!checkoutUrl) return null;
return (
<div>
<a href={checkoutUrl} target="_self">
<p>Continue to Checkout &rarr;</p>
</a>
<br />
</div>
);
}
export function CartSummary({cost, layout, children = null}) {
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}) {
return (
<CartForm
route="/cart"
action={CartForm.ACTIONS.LinesRemove}
inputs={{lineIds}}
>
<button type="submit">Remove</button>
</CartForm>
);
}
function CartLineQuantity({line}) {
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} &nbsp;&nbsp;</small>
<CartLineUpdateButton lines={[{id: lineId, quantity: prevQuantity}]}>
<button
aria-label="Decrease quantity"
disabled={quantity <= 1}
name="decrease-quantity"
value={prevQuantity}
>
<span>&#8722; </span>
</button>
</CartLineUpdateButton>
&nbsp;
<CartLineUpdateButton lines={[{id: lineId, quantity: nextQuantity}]}>
<button
aria-label="Increase quantity"
name="increase-quantity"
value={nextQuantity}
>
<span>&#43;</span>
</button>
</CartLineUpdateButton>
&nbsp;
<CartLineRemoveButton lineIds={[lineId]} />
</div>
);
}
function CartLinePrice({line, priceType = 'regular', ...passthroughProps}) {
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'}) {
return (
<div hidden={hidden}>
<br />
<p>
Looks like you haven&rsquo;t added anything yet, let&rsquo;s get you
started!
</p>
<br />
<Link
to="/collections"
onClick={() => {
if (layout === 'aside') {
window.location.href = '/collections';
}
}}
>
Continue shopping
</Link>
</div>
);
}
function CartDiscounts({discountCodes}) {
const codes =
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>
&nbsp;
<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" />
&nbsp;
<button type="submit">Apply</button>
</div>
</UpdateDiscountForm>
</div>
);
}
function UpdateDiscountForm({discountCodes, children}) {
return (
<CartForm
route="/cart"
action={CartForm.ACTIONS.DiscountCodesUpdate}
inputs={{
discountCodes: discountCodes || [],
}}
>
{children}
</CartForm>
);
}
function CartLineUpdateButton({children, lines}) {
return (
<CartForm
route="/cart"
action={CartForm.ACTIONS.LinesUpdate}
inputs={{lines}}
>
{children}
</CartForm>
);
}

Some files were not shown because too many files have changed in this diff Show More