Compare commits

...

183 Commits

Author SHA1 Message Date
Sean Massa
4773ff5efd Publish Stable
- @vercel/build-utils@5.0.5
 - vercel@27.3.0
 - @vercel/client@12.1.4
 - @vercel/go@2.0.9
 - @vercel/hydrogen@0.0.6
 - @vercel/next@3.1.9
 - @vercel/node@2.5.0
 - @vercel/python@3.1.1
 - @vercel/redwood@1.0.10
 - @vercel/remix@1.0.11
 - @vercel/ruby@1.3.17
 - @vercel/static-build@1.0.10
2022-07-27 13:33:34 -05:00
Gal Schlezinger
d8c7308eb6 [node] Add WebAssembly importing support for the @vercel/node builder (#8204)
* [node] Add WebAssembly importing support for the @vercel/node builder

* add comment about the original source

* trim down values to only what is required for esbuild wrapping logic

* Revert "trim down values to only what is required for esbuild wrapping logic"

This reverts commit c04dacad99f25156938dccdf2f29aac6e8282564.

Co-authored-by: Sean Massa <EndangeredMassa@gmail.com>
2022-07-27 13:17:32 -05:00
Steven
5df1c89138 [tests] Fix next runtime test (#8258)
This test fails with `vc build`
2022-07-27 02:51:43 +00:00
Ethan Arrowood
f5d879143c [static-build] Add .vercel to static-build ignore list regardless of config (#8255)
### Related Issues

Adds `.vercel` path to the ignore list regardless if `zeroConfig` is enabled. This fixes a bug where the `.vercel` folder was being copied into the resulting `.vercel/output/static` directory after running `vc build` with `distDir: "."` configured for static-build.

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [ ] The code changed/added as part of this PR has been covered with tests
- [ ] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-26 22:04:25 +00:00
Matthew Stanciu
9a55809515 [cli] Minor vc inspect visual updates (#8160)
Featuring:

- Aliases are now shown
- `readyState` –> `status` in "General" section
- `createdAt` –> `created` in "General" section
- Update `stateString` to support more states & be more visually appealing

### Before

<img width="754" alt="Screen Shot 2022-07-14 at 3 10 40 PM" src="https://user-images.githubusercontent.com/14811170/179097164-df7acd8a-d677-4e75-b7d5-b8ec6cf9bb12.png">

### After

<img width="748" alt="Screen Shot 2022-07-14 at 3 10 29 PM" src="https://user-images.githubusercontent.com/14811170/179097170-137d8977-60d6-402d-825e-8b0fb3025969.png">

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [x] The code changed/added as part of this PR has been covered with tests
- [x] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-26 18:15:38 +00:00
Nathan Rajlich
56adf15823 [all] Update @vercel/ncc to v0.34.0 (#8248)
The version of `@vercel/ncc` that was being used in this repo is rather old. Let's update to the latest version. Specifically, this is a baby step towards allowing CLI to become ESM format.
2022-07-26 08:24:01 +00:00
Steven
1acab3d06c [tests] Update node tests to run concurrently (#8247)
This applies the same chunking algorithm from `@vercel/static-build` tests to `@vercel/node` tests that we can run up to 5 integration tests concurrently.
2022-07-26 00:06:59 +00:00
Nathan Rajlich
081b38466b [cli] Update to TypeScript v4.7.4 (#8232) 2022-07-25 16:08:14 -07:00
Nathan Rajlich
c397fd1856 [cli] Apply functions configuration to Serverless Functions in "vc build" (#8243) 2022-07-25 23:04:49 +00:00
Steven
afd303b94a [tests] Fix turbo cache for test changes (#8246)
When we make changes in `test/lib/**`, it should run all the E2E tests again for every package.

https://turborepo.org/docs/reference/configuration
2022-07-25 17:14:33 -04:00
Ethan Arrowood
b12387034a [cli] fix zero config resolution in vc build (#8244)
### Related Issues

Adds a `isZeroConfig` check to static-build so that when `"zeroConfig": true` is enabled the correct settings are resolved.

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [ ] The code changed/added as part of this PR has been covered with tests
- [ ] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-25 19:27:12 +00:00
Steven
5af65d5a24 Revert "[python] support Sanic >=21 and python >= 3.10" (#8241)
- Reverts vercel/vercel#8045
- Fixes vercel/vercel#8231
2022-07-25 17:40:21 +00:00
Steven
1ee9a96a62 [build-utils] Fix package.json and lockfile detection (#8230)
This PR fixes a couple issues where `vercel build` was not correctly detecting the package.json files

```
Error: @vercel/node:test: ERROR: frontend/index.ts(1,12): error TS2580: Cannot find name 'require'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node`.
```

It also fixes an issue where all deployments were incorrectly detecting the lock file because the lock file doesn't always live in the same directory as the package.json file. So we need to do 2 passes: one to find the nearest package.json and one to find the nearest lock file.
2022-07-25 16:29:24 +00:00
Nathan Rajlich
76130faf26 [cli] Do not load .env in "vercel build" command (#8228)
Only consider the `.vercel/.env.${target}.local` file when loading env vars at the beginning of `vercel build`.

If a project-level `.env` (or `.env.production` or whatever) file needs to be loaded then it is the responsibility of the frontend framework's build command to consider those files (i.e. CRA build, `next build`, etc.)
2022-07-24 05:08:13 +00:00
Nathan Rajlich
fb3601d178 [cli] Add "dev" script, remove "build-dev" script (#8222)
I don't think anyone is using this command anymore in the world of M1.

Anyways, we should be using `ts-node` to execute the CLI from source code instead of skipping steps at build-time to iterate quickly.
2022-07-22 22:39:10 +00:00
Steven
aebfb6812d [cli][build-utils] Fix dependencies for boxen (#8210)
The `boxen` package was incorrectly listed as a dependency of build-utils so I moved it to cli, which is only used by the [`vercel bisect`](3d3774ee7e/packages/cli/src/commands/bisect/index.ts (L2)) command.
2022-07-22 22:08:51 +00:00
Matthew Stanciu
73999e7253 [cli] Log delta in vc env pull (#8170)
This PR adds a new message to the end of `vc env pull` which shows delta of what was added, changed, and removed.

Example: you have a local `.env` file in a directory linked to a Vercel project. You change `TEST_1`, remove `TEST_2` via `vc env rm TEST_2`, and add `TEST_3` via `vc env add`. Then you run `vc env pull`. Here's what you will see:

```
> Updating existing .env file
> Downloading `development` Environment Variables for Project name
  Updated .env file

+ TEST_3
~ TEST_1
- TEST_2
```

<img width="562" alt="Screen Shot 2022-07-20 at 11 09 56 AM" src="https://user-images.githubusercontent.com/14811170/180064534-2ff5facb-95cb-4712-aaad-cbb47490cebe.png">

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [x] The code changed/added as part of this PR has been covered with tests
- [x] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-22 19:16:56 +00:00
Nathan Rajlich
989dad5570 [cli] Use ts-node with SWC instead of ts-eager (#8224)
`ts-eager` isn't really maintained anymore and doesn't support ESM packages.

`ts-node` with SWC is more or less just as fast, so let's use that instead since it's well maintained any we can dogfood SWC more.
2022-07-22 17:53:15 +00:00
Nathan Rajlich
68c2dea601 [cli] Add support for Vercel Analytics in "vercel build" command (#8220)
Pull the Vercel Analytics ID in the `vercel pull` command, so that `vercel build` can set the `VERCEL_ANALYTICS_ID` environment variable at build-time for the frontend framework to utilize.
2022-07-22 17:23:29 +00:00
JJ Kasper
63f2da2f68 [next] Update size limit test (#8226)
This test is failing due to a decrease in the server layer so it is no longer over the limit as expected so this adds some more data to the specific route to ensure it does hit the limit. 

Fixes: https://github.com/vercel/vercel/runs/7469021922?check_suite_focus=true#step:9:6800
2022-07-22 11:05:53 -04:00
57e5f81361 [docs] Fix CONTRIBUTING file local development guideline, change dir after git clone (#8225)
Update CONTRIBUTING.md
2022-07-22 09:07:29 -04:00
Mosaad
fd5e440533 [next] Fix error message typo (#8218)
User the correct indefinite articles.

Co-authored-by: Steven <steven@ceriously.com>
2022-07-22 08:44:48 -04:00
Sean Massa
2a45805b26 [tests] increase timeout for test that's failing on windows (#8221)
This test sometimes times out on Windows. When it succeeds, it takes ~6 seconds. When it fails, we don't know how long it would have taken, but the current timeout is 7 seconds. Let's try 10 seconds and see if that's better.
2022-07-22 08:52:28 +00:00
Nathan Rajlich
5523383e50 [cli] Prevent vc deploy --prebuilt when vc build failed (#8194)
If `vc build` failed then don't allow a deployment to be created with `vc deploy --prebuilt`.
2022-07-22 07:31:52 +00:00
Sean Massa
0ecbb24cab [utils] allow a github PAT token to get past rate limit (#8209)
If you run `yarn changelog` when your IP is already rate limited by github, you'll get an error. This allows you to set a [Github PAT](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) to get past the generic rate limit.

Given a PAT with value `MY_PAT_TOKEN`, you can run:

```
$ GITHUB_TOKEN=MY_PAT_TOKEN yarn changelog
```

If you do get an error, it now actually shows up in the output:

```
$  yarn changelog
yarn run v1.22.18
$ node utils/changelog.js
Error: Failed to fetch releases from github: API rate limit exceeded for 98.139.180.149. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.)
```
2022-07-22 06:14:59 +00:00
Nathan Rajlich
922223bd19 [cli] Lazy-load "metrics" object to fix jest reporting open handle (#7866)
Jest was saying that there's an open handle in the unit tests due to the `crypto.pbkdf2Sync()` call in this metrics file, so lazy-load that object in the case when metrics should not be collected.
2022-07-22 04:02:26 +00:00
JJ Kasper
0ad7fd34f4 [next][node][redwood][remix] Update @vercel/nft to 0.21.0 (#8208)
### Related Issues

Updates to the latest version of `@vercel/nft` which adds fs concurrency limits to help alleviate memory usage. 

x-ref: https://github.com/vercel/nft/pull/301

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [ ] The code changed/added as part of this PR has been covered with tests
- [ ] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-22 00:45:53 +00:00
Sean Massa
3d3774ee7e Publish Stable
- @vercel/build-utils@5.0.4
 - vercel@27.2.0
 - @vercel/client@12.1.3
 - @vercel/go@2.0.8
 - @vercel/hydrogen@0.0.5
 - @vercel/next@3.1.8
 - @vercel/node@2.4.5
 - @vercel/python@3.1.0
 - @vercel/redwood@1.0.9
 - @vercel/remix@1.0.10
 - @vercel/ruby@1.3.16
 - @vercel/static-build@1.0.9
2022-07-21 15:03:11 -05:00
Sean Massa
50f8eec7cb [cli][dev] support environment variables in edge functions during vc dev (#8207)
Edge Function support in `vc dev` was not passing through the environment variables, which is supported by [production Edge Functions](https://vercel.com/docs/concepts/functions/edge-functions/edge-functions-api#environment-variables).

This PR passes those through. I updated a test for it and manually tested on a separate project.
2022-07-21 17:53:52 +00:00
Sean Massa
45374e2f90 [cli] improve isBundledBuilder logic (#8086)
The logic that determines if a builder needs to be installed has a check to see if the builder should already be bundled: `isBundledBuilder`. This was looking for specific conditions that made it (1) a bit hard to follow and (2) very sensitive to `canary` tags (and having "canary" in the version specifier).

This causes general development problems because local changes weren't always used by local CLI builds. Depedendant packages (like `@vercel/node`) would be installed from the latest `canary` release instead.

This caused problems in CI and released CLI versions where the latest `canary` of dependant packages might be rather old, causing that old code to be used instead of the latest non-canary releases.

The issue was mitigated for now by publishing canary releases for all packages.

---

Paired with @styfle @MatthewStanciu.

@TooTallNate: Is this change too broad? Are there cases where we wouldn't want to do this?
2022-07-21 15:45:29 +00:00
Steven
fd9142b6f3 [cli] Bump @vercel/fun to 1.0.4 (#8198)
Bump `@vercel/fun` to [1.0.4](https://github.com/vercel/fun/releases/tag/1.0.4)
2022-07-21 00:00:03 +00:00
JJ Kasper
8cf67b549b [next] Ensure manifests are specific to the included pages (#8172)
### Related Issues

This updates to filter the `routes-manifest` and `pages-manifest` to only include entries for the pages that are being included in the specific serverless function. This fixes the case where multiple dynamic routes could match a path but one is configured with `fallback: false` so shouldn't match when executing for a different dynamic route. 

A regression test for this specific scenario has been added in the `00-mixed-dynamic-routes` fixture. 

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [ ] The code changed/added as part of this PR has been covered with tests
- [ ] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-20 23:06:44 +00:00
Sean Massa
5dc6f48e44 [cli][dev] handle no response from edge functions (#8095)
When an edge function has no response during `vc dev`, we were seeing an unhelpful error message:

> The event listener did not respond.

Now, we'll see a much more specific error message:

> Unhandled rejection: Edge Function "api/edge-no-response.ts" did not return a response.
> Error! Failed to complete request to /api/edge-no-response: Error: socket hang up
2022-07-20 20:09:29 +00:00
Kevin Tan
66c8544e8f [python] support Sanic >=21 and python >= 3.10 (#8045)
### Related Issues

1. exception from python 3.10:
```
TypeError: As of 3.10, the *loop* parameter was removed from Queue() since it is no longer necessary`
```

Remove the deprecated argument `loop` from `Queue`, which can also be omitted in python version < 3.10

2. exception from Sanic > 21.3:
```
File "C:\Users\Kevin\AppData\Local\Temp\zeit-fun-03f18b2d2c7d7\sanic\signals.py", line 93, in get
    group, param_basket = self.find_route(
TypeError: 'NoneType' object is not callable
```
As of Sanic > 21.3, it cannot serve requests immediately after initializing, instead, we need implement the [ASGI lifespan protocol](https://asgi.readthedocs.io/en/latest/specs/lifespan.html) and wait for the startup event completed.  

here I complemented the protocol copied from (same source of the previous HTTP procotol): <https://github.com/jordaneremieff/mangum/blob/main/mangum/protocols/lifespan.py>


### Related link:
https://github.com/encode/uvicorn/pull/498

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [x] The code changed/added as part of this PR has been covered with tests
- [x] All tests pass locally with `yarn test-unit`

#### Code Review

- [x] This PR has a concise title and thorough description useful to a reviewer
- [x] Issue from task tracker has a link to this PR


Co-authored-by: Steven <steven@ceriously.com>
2022-07-20 09:50:53 -04:00
Matthew Stanciu
0140db38fa [cli] Support multiple remote URLs in Git config (#8145)
Two features that handle a user's local Git config have been shipped:

- #8100 
- #7910 

Both of these features currently pull only from the user's remote origin URL. This covers 90% of cases, but there are cases in which the user has more than one remote URL, and may want to use one other than the origin URL, especially in `vc git connect`. This PR:

- Adds support for multiple remote URLs in a user's Git config
- Updates `vc git connect` to prompt the user to select a URL if they have multiple remote URLs
- Updates `createGitMeta` to send the connected Git repository url by default, origin url otherwise

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [x] The code changed/added as part of this PR has been covered with tests
- [x] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-20 07:17:53 +00:00
Chris Barber
e5421c27e8 [cli][client][build-utils][node][static-build] updated node-fetch to fix high severity security vulnerability (#8180)
Update `node-fetch 2.6.1 -> 2.6.7` to fix high severity security vulnerability: Exposure of Sensitive Information to an Unauthorized Actor (https://github.com/advisories/GHSA-r683-j2x4-v87g).

`node-fetch` was updated in the root, `api`, `build-utils`, `cli`, `client`, `node`, and `static-build`.

### Related Issues

> https://linear.app/vercel/issue/VCCLI-196/update-vercelnode-dep-node-fetch-261-267

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [x] The code changed/added as part of this PR has been covered with tests
- [x] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-19 22:15:09 +00:00
Matthew Stanciu
5afc527233 [cli] Add --environment flag to vc env pull (#8162)
Right now, `vc env pull` only pulls development environment variables. This PR adds a new flag, `--environment,` which allows users to specify which environment to pull from.

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [x] The code changed/added as part of this PR has been covered with tests
- [x] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-19 20:02:13 +00:00
Nathan Rajlich
de9518b010 [cli] Write top-level error to builds.json file in "vc build" (#8193)
If an error happens outside of a Builder (i.e. `detectBuilders()` function fails), then write the serialized error into the `builds.json` file at the top-level of the file (there is no `builds[]` when an error happens at the top-level).
2022-07-19 19:30:17 +00:00
Nathan Rajlich
c322d1dbba [cli] Set ignoreBuildScript: true option in "vc build" command (#8184)
This matches the behavior in production and prevents the error:

```
> Error! Your `package.json` file is missing a `build` property inside the `scripts` property.
```
2022-07-19 18:52:33 +00:00
Steven
18c19ead76 [tests] Upgrade python tests to 3.9 (#8181)
New python 3.6 deployments will fail today per the previous announcement https://vercel.com/changelog/python-3-6-is-being-deprecated

This PR updates the tests to use python 3.9 instead.
2022-07-19 17:43:20 +00:00
Steven
9d80c27382 [cli] Print full error when unknown/unexpected (#8059)
If we don’t know the error, we should not assume it has a stack prop and instead print the whole thing to avoid accidentally printing `undefined`. 

- related to https://github.com/vercel/vercel/discussions/8043
2022-07-18 10:40:09 -04:00
Steven
bef1aec766 Publish Stable
- @vercel/build-utils@5.0.3
 - vercel@27.1.5
 - @vercel/client@12.1.2
 - @vercel/frameworks@1.1.1
 - @vercel/fs-detectors@2.0.1
 - @vercel/go@2.0.7
 - @vercel/hydrogen@0.0.4
 - @vercel/next@3.1.7
 - @vercel/node@2.4.4
 - @vercel/python@3.0.7
 - @vercel/redwood@1.0.8
 - @vercel/remix@1.0.9
 - @vercel/routing-utils@2.0.0
 - @vercel/ruby@1.3.15
 - @vercel/static-build@1.0.8
2022-07-15 15:40:37 -04:00
Steven
4f4a42813f [build-utils][node][python][ruby] Update error message for EOL runtimes (#8167)
This PR updates the error message when the runtime version detected is EOL
2022-07-15 15:38:38 -04:00
Steven
181a492d91 [routing-utils] MAJOR refactor getTransformedRoutes and types (#8155)
This is a semver major change to the public API for `@vercel/routing-utils` which includes the following breaking changes.

1. `getTransformedRoutes({ nowConfig })` props changed to `getTransformedRoutes(nowConfig)`
2. `type Source` renamed `type RouteWithSrc`
3. `type Handler` renamed `type RouteWithHandle`
4. `interface VercelConfig` removed
5. `type NowConfig` removed
6. `type NowRewrite` removed
7. `type NowRedirect` removed
8. `type NowHeader` removed
9. `type NowHeaderKeyValue` removed
2022-07-15 14:05:08 -04:00
Sean Massa
1be7a80bb8 Publish Stable
- vercel@27.1.4
 - @vercel/next@3.1.6
2022-07-15 11:20:56 -05:00
Sean Massa
0428d4744e [cli] write config.json when exiting because of error in builder (#8163)
Co-authored-by: Steven <steven@ceriously.com>
2022-07-15 11:16:31 -05:00
JJ Kasper
2a929a4bb9 [next] Update allowQuery for prerendered paths (#8158)
This updates our `allowQuery` generating to ignore all query values for build-time prerender paths as these will match before dynamic routes since they are filesystem routes and the query values will not be overridden properly like they are for fallback prerender paths. This also adds testing for both prerender path types with on-demand ISR to ensure the cache is updated as expected regardless of the query.  

Deployment with patch can be seen here https://nextjs-issue-odr-simple-hrjt2dagm-ijjk-testing.vercel.app/

### Related Issues

x-ref: https://github.com/vercel/next.js/issues/38306
x-ref: https://github.com/vercel/next.js/issues/38653
2022-07-15 11:37:05 -04:00
JJ Kasper
accd308dc5 [tests] Update log for update-canary-tag script (#8156)
### Related Issues

Noticed this log was not being converted to a string so we're losing some context so this corrects in case we have a failure on this step in the future. 

Fixes: https://github.com/vercel/vercel/runs/7344626478?check_suite_focus=true#step:8:258

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [ ] The code changed/added as part of this PR has been covered with tests
- [ ] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-15 02:16:51 +00:00
Sean Massa
e2d4efab08 Publish Stable
- vercel@27.1.3
2022-07-14 12:04:05 -05:00
JJ Kasper
7e0dd6f808 [tests] Update to latest version of turbo (#8152)
### Related Issues

Updates to latest turbo which includes patches for cached files. 

x-ref: [slack thread](https://vercel.slack.com/archives/C02CDC2ALJH/p1657767763630359?thread_ts=1657757803.039099&cid=C02CDC2ALJH)

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [ ] The code changed/added as part of this PR has been covered with tests
- [ ] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-14 12:41:28 +00:00
Nathan Rajlich
8971e02e49 [cli] Normalize output file paths in vercel build (#8149)
When a Builder returns an output asset that contains irregular slashes (multiple slashes or leading/trailing slashes), then have them be removed from the file path before creating the output asset.

This fixes an edge case where `@vercel/next` could end up outputting a Serverless Function with a trailing slash (i.e. `en-US/`). Before this PR, that would be serialized to the filesystem at `en-US/.func`, but after this fix it's saved in the correct `en-US.func` directory.
2022-07-14 07:58:20 +00:00
Nathan Rajlich
10c91c8579 [cli] Store build error in "builds.json" file in vc build (#8148)
When a build fails, store the serialized Error in the "builds.json" file under the "build" object of the Builder that failed.

Example:

```json
{
  "//": "This file was generated by the `vercel build` command. It is not part of the Build Output API.",
  "target": "preview",
  "argv": [
    "/usr/local/bin/node",
    "/Users/nrajlich/Code/vercel/vercel/packages/cli/src/index.ts",
    "build",
    "--cwd",
    "/Users/nrajlich/Downloads/vc-build-next-repro/"
  ],
  "builds": [
    {
      "require": "@vercel/next",
      "requirePath": "/Users/nrajlich/Code/vercel/vercel/packages/next/dist/index",
      "apiVersion": 2,
      "src": "package.json",
      "use": "@vercel/next",
      "config": {
        "zeroConfig": true,
        "framework": "nextjs"
      },
      "error": {
        "name": "Error",
        "message": "Command \"pnpm run build\" exited with 1",
        "stack": "Error: Command \"pnpm run build\" exited with 1\n    at ChildProcess.<anonymous> (/Users/nrajlich/Code/vercel/vercel/packages/build-utils/dist/index.js:20591:20)\n    at ChildProcess.emit (node:events:527:28)\n    at ChildProcess.emit (node:domain:475:12)\n    at maybeClose (node:internal/child_process:1092:16)\n    at Process.ChildProcess._handle.onexit (node:internal/child_process:302:5)",
        "hideStackTrace": true,
        "code": "BUILD_UTILS_SPAWN_1"
      }
    }
  ]
}
```
2022-07-14 01:05:00 +00:00
Nathan Rajlich
bfdbe58675 Publish Stable
- vercel@27.1.2
 - @vercel/static-build@1.0.7
2022-07-13 13:51:02 -07:00
Matthew Stanciu
7bdaf107b7 [cli] Consume --no-clipboard (#8147)
#8085 removed the clipboard copy feature in `vc deploy`, along with the `--no-clipboard` flag. Right now, the CLI exits and returns an error when someone includes the `--no-clipboard` flag. This PR instead consumes the flag and warns the user that the flag is deprecated.

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [ ] The code changed/added as part of this PR has been covered with tests
- [ ] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-13 20:32:21 +00:00
Nathan Rajlich
8de100f0e1 [static-build] Return BOA v3 result without vercel build (#8146)
This will allow Build Output API v3 to be produced by a build script without the `ENABLE_VC_BUILD=1` env var being necessary.
2022-07-13 18:17:53 +00:00
Steven
38a6785859 Publish Stable
- vercel@27.1.1
2022-07-13 12:00:03 -04:00
Steven
c67d1a8525 [cli] Fix vercel build sort order (#8144)
- Fixes #8063
2022-07-13 11:58:56 -04:00
Matthew Stanciu
c5a7c574a2 [tests] Add missing function import in Project mock (#8143)
A function `formatProvider` wasn't imported in the `Project` mock endpoint. This wasn't caught before #8100 was merged, and dodged CI.

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [ ] The code changed/added as part of this PR has been covered with tests
- [ ] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-12 21:35:27 +00:00
Sean Massa
d2f8d178f7 Publish Stable
- @vercel/build-utils@5.0.2
 - vercel@27.1.0
 - @vercel/client@12.1.1
 - @vercel/fs-detectors@2.0.0
 - @vercel/go@2.0.6
 - @vercel/hydrogen@0.0.3
 - @vercel/next@3.1.5
 - @vercel/node@2.4.3
 - @vercel/python@3.0.6
 - @vercel/redwood@1.0.7
 - @vercel/remix@1.0.8
 - @vercel/ruby@1.3.14
 - @vercel/static-build@1.0.6
2022-07-12 15:34:28 -05:00
Nathan Rajlich
f9a747764c [remix] Fix isMonorepo check (#8142)
`config.projectSettings` is not available when running `@vercel/remix` directly (i.e. when not using `vc build`), so calculate the correct root directory value based on the `workPath` relative to the `repoRootPath` value.
2022-07-12 20:10:51 +00:00
Matthew Stanciu
27d80f13cd [cli] Add ability to connect Git provider repo (#8100)
This PR adds a new subcommand `vc git`, which allows you to create a Git provider repository from your local `.git` config to your Vercel project.

Usage:

- `vc git connect` – connects a Git provider repository
- `vc git disconnect` – disconnects a Git provider repository

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [x] The code changed/added as part of this PR has been covered with tests
- [x] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-12 17:55:40 +00:00
Nathan Rajlich
8c668c925d [fs-detectors] Remove "solidstart" framework from FS API v2 detection (#8134)
"solidstart" framework doesn't need to use the FS API v2 Builder anymore since:

  1) Newer versions output Build Output API v3
  2) Older versions that output FS API v2 will be handled by the compatbility shim that was added in https://github.com/vercel/vercel/pull/7690
2022-07-12 17:03:00 +00:00
Nathan Rajlich
4b1b33c143 [cli] Add createdAt to project settings in vc pull (#8138)
The `createdAt` property is checked in the `detectBuilders()` function, so it needs to be present in the local copy of the project settings.
2022-07-12 08:12:32 +00:00
Gal Schlezinger
a8d4147554 [next] Add EdgeRuntime provider environment variable to the builder (#8130)
This will enable Next.js to compile and set `vercel` as the edge runtime provider (`EdgeRuntime` global),
which can allow different libraries/customers to have different code running depends on the runtime environment (`edge-runtime` vs `vercel`).

### Related Issues

- https://github.com/vercel/next.js/pull/38331

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

The Next.js feature was not merged yet, so it still can't be tested.

- [ ] The code changed/added as part of this PR has been covered with tests
- [ ] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-12 06:33:14 +00:00
Gal Schlezinger
09339f494d [next][build-utils] allow to declare assets in EdgeFunction constructor (#8127)
There's a [Next.js PR](https://github.com/vercel/next.js/pull/38492) to include binary assets in Edge Functions. This PR enables to declare assets in the `EdgeFunction` constructor.

This is a configuration level setting, compared to WebAssembly modules which the `middleware-manifest.json` converts into `import` statements. This is because the asset generation is done in Webpack and it is not dynamic, it's a static string. We can use AST to replace it but we're risking in:

* worse performance
* accidentally replacing a string that isn't an asset
2022-07-11 23:13:21 +00:00
Steven
ee4d772ae9 [tests] Add retry to npm tests (#8133)
These tests fail occasionally on GH Actions due to flakey network IO, so this PR adds retry logic.

https://github.com/vercel/vercel/runs/7289659634?check_suite_focus=true#step:13:328
2022-07-11 21:39:10 +00:00
Matthew Stanciu
61e8103404 [tests] Fix flaky Git meta test (#8132)
A test I wrote for a previous PR (https://github.com/vercel/vercel/pull/8112) was failing once in ~every 5 runs. This PR makes the test reliable.

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [x] The code changed/added as part of this PR has been covered with tests
- [x] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-11 21:01:16 +00:00
Steven
fb4f477325 Publish Stable
- vercel@27.0.2
 - @vercel/fs-detectors@1.0.2
 - @vercel/node@2.4.2
2022-07-11 16:21:30 -04:00
P.B. To
016bff848e [fs-detectors] process detectFramework in parallel (#8128)
The Vercel API's `detect-framework` API contacts GitHub's API using its own file adapter. 

However, currently the `detectFramework` function in the Vercel CLI (on which the API depends) calls the file system adapter in series using a `for` loop. This results in large amounts of lag when in a networked situation such as calling GitHub's API as each API call takes a couple hundred milliseconds.

I propose changing `detectFramework` to process the directories it scans in parallel.

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [x] The code changed/added as part of this PR has been covered with tests
- [x] All tests pass locally with `yarn test-unit`

#### Code Review

- [x] This PR has a concise title and thorough description useful to a reviewer
- [x] Issue from task tracker has a link to this PR
2022-07-11 20:16:04 +00:00
Steven
183e411f7c [cli] Remove DEBUG=corepack env var (#8131)
This debug log was originally added in #7871 because corepack has no output by default. In particular, it was nice to see the first deployment was not stalled when the package manager is being installed.

That being said, this gets noisy really fast because cache detections also print a log line.
For example, here's a deployment that prints 3 times:

```
Detected ENABLE_EXPERIMENTAL_COREPACK=1 and "npm@8.10.0" in package.json
Running "install" command: `npm install --prefix=.. --no-audit --engine-strict=false`...
2022-07-11T18:27:00.696Z corepack Reusing npm@8.10.0
356 packages are looking for funding
Running "npm run vercel-build"
2022-07-11T18:27:06.664Z corepack Reusing npm@8.10.0
> front@0.0.0 vercel-build
> npm run buildonly && npm run build:rss
2022-07-11T18:27:07.088Z corepack Reusing npm@8.10.0
> front@0.0.0 buildonly
> next build
```

I think its best to let users add this env var themselves if they want to debug what corepack is doing, so this PR removes that environment variable.
2022-07-11 19:31:04 +00:00
Sean Massa
070e300148 [node] add links to edge error messages (#8048)
Add links to some Edge errors.

---

Follow up to: https://github.com/vercel/vercel/pull/8007
2022-07-11 18:50:15 +00:00
Steven
cbdf9b4a88 [cli] Fix beta label (#8129)
This PR is a follow up to #7991 which incorrectly used a the empty string instead of empty array

- Maybe related to #8125
2022-07-11 18:05:36 +00:00
Steven
ec9b55dc81 Publish Stable
- vercel@27.0.1
 - @vercel/remix@1.0.7
2022-07-10 15:04:09 -04:00
Leon Salsiccia
06829bc21a [remix] fix monorepo support (#8077)
Co-authored-by: Steven <steven@ceriously.com>
2022-07-10 13:28:39 -04:00
Matthew Stanciu
628071f659 [cli] Debug log error messages in create-git-meta (#8112)
This is a follow-up to #8094 which debug logs errors.

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [ ] The code changed/added as part of this PR has been covered with tests
- [ ] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-08 19:30:28 +00:00
Matthew Stanciu
5a7461dfe3 [cli] Explicitly use vc project vs. vc projects (#8113)
This is a follow-up to #8091 which:

- Makes `vc project` the default command, with `vc projects` aliased to `vc project` (previously it was not clear in the code which one was the "real" command)
- Makes some helper names for `ls` more specific

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [ ] The code changed/added as part of this PR has been covered with tests
- [ ] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-08 18:56:35 +00:00
Sean Massa
599f8f675c [tests] remove commented code (#8109)
Removes a leftover commented line.
2022-07-08 16:52:55 +00:00
Steven
0a8bc494fc [tests] Try building with ENABLE_VC_BUILD (#8110)
This will dogfood `vc build` which happens to improve this deployment time from 4 min down to 2 min.
2022-07-08 12:30:03 -04:00
Lee Robinson
34e008f42e [cli] Remove beta warning for vercel build (#7991)
This removes the `(beta)` output from `vercel build` as we move towards stability.

![image](https://user-images.githubusercontent.com/9113740/174356399-65f3d6bb-a241-49c8-9edb-167b25d6fa44.png)
2022-07-08 12:12:22 +00:00
Matthew Stanciu
037633b3f1 [cli] Refactor vc project (#8091)
Since the `vc project` command is about to be expanded with 2 new subcommands, I think it makes sense to do a little bit of refactoring. The current `vc project` command is all in one file, with the logic for subcommands nested within if statements. I think the structure of `vc project` should look more like `vc env`, which is consistent with how commands with subcommands look throughout the rest of the codebase.

This PR moves the logic for the `project` subcommands into their own files.

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [x] The code changed/added as part of this PR has been covered with tests
- [x] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-08 01:34:13 +00:00
Matthew Stanciu
1a6f3c0270 [cli] Fix vc deploy erroring when .git is corrupt (#8094)
### Related Issues

- Fixes https://github.com/vercel/customer-issues/issues/597

`vc deploy` currently fails with a confusing error if the user has a corrupt `.git` directory. This is caused by `create-git-meta` improperly handling the scenario where it detects a `.git` directory but cannot find git information. This PR handles this error.

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [x] The code changed/added as part of this PR has been covered with tests
- [x] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-08 01:07:43 +00:00
Nathan Rajlich
0a2af4fb94 Publish Stable
- @vercel/build-utils@5.0.1
 - vercel@27.0.0
 - @vercel/client@12.1.0
 - @vercel/frameworks@1.1.0
 - @vercel/fs-detectors@1.0.1
 - @vercel/go@2.0.5
 - @vercel/hydrogen@0.0.2
 - @vercel/next@3.1.4
 - @vercel/node@2.4.1
 - @vercel/python@3.0.5
 - @vercel/redwood@1.0.6
 - @vercel/remix@1.0.6
 - @vercel/ruby@1.3.13
 - @vercel/static-build@1.0.5
2022-07-07 16:31:30 -07:00
Nathan Rajlich
3fb1c50142 [node] Provide correct middlewarePath when using "Root Directory" setting (#8097)
The `middlewarePath` property was not correctly stripping
the root directory setting from the value, causing Middleware
to not work correctly with Root Directory.
2022-07-07 15:25:24 -07:00
Nathan Rajlich
de033c43fd [cli] Support Builders that don't define version property (#8099)
`vercel-sapper` is a case of a 3rd party Builder that does not export a `version` property. In this case, we should treat it the same as a `version: 2` Builder since its return value is compatible.
2022-07-07 21:30:53 +00:00
Nathan Rajlich
f8e5df749c [frameworks] Add "hydrogen" framework preset (#8073)
Adds "hydrogen" framework preset for Shopify Hydrogen framework to allow for zero-config usage of the `@vercel/hydrogen` Builder.
2022-07-07 20:52:03 +00:00
Steven
5670acc2cc Revert "[cli] Add vc project connect command" (#8096)
Revert "[cli] Add `vc project connect` command (#8014)"

This reverts commit 32ebcd83a7.
2022-07-07 16:03:52 -04:00
Nathan Rajlich
5205047851 [tests] Fix invoke path of update-canary-tag.js script (#8092)
See related CI failure: https://github.com/vercel/vercel/runs/7228051741?check_suite_focus=true
2022-07-07 13:01:22 +00:00
Nathan Rajlich
1edc2d06c9 Publish Stable
- @vercel/hydrogen@0.0.1
2022-07-06 13:51:43 -07:00
Nathan Rajlich
fdb15b2539 [hydrogen] Add @vercel/hydrogen Builder (#8071)
Adds a new `@vercel/hydrogen` Builder package so that Vercel can support Shopify Hydrogen projects with zero config. It outputs an Edge Function for the server-side render code and includes a catch-all route to invoke that function after a `handle: "filesystem"` to serve static files that were generated by the build command.

**Examples:**

 * [`hello-world-ts` template](https://hydrogen-hello-world-otm2vmw6w-tootallnate.vercel.app/)
 * [`demo-store-ts` template](https://hydrogen-demo-store-1gko2fst3-tootallnate.vercel.app/)
2022-07-06 20:06:45 +00:00
Matthew Stanciu
32ebcd83a7 [cli] Add vc project connect command (#8014)
This PR adds a new subcommand, `vc project connect`, which connects a Git provider repository to the current project. Previously, this could only be done via the Dashboard.

This is the first part of a larger project—the goal is to include this functionality within `vc link`, so that you never have to leave the CLI if you want to set up a new Vercel project that's connected to Git.

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [x] The code changed/added as part of this PR has been covered with tests
- [x] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-06 19:49:08 +00:00
Matthew Stanciu
2e43b2b88a [cli] Remove redundant mock project endpoint (#8089)
- https://github.com/vercel/vercel/pull/8053

While writing tests for this PR, I added a mock project endpoint that always returned a default project. This was probably incorrect and no longer needed (tests pass without it). I should have removed it before merging #8053, but I didn't catch this before merging.

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [ ] The code changed/added as part of this PR has been covered with tests
- [ ] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-06 19:17:33 +00:00
Matthew Stanciu
f83d432fcd [cli] MAJOR: Scope vc ls to linked project (#8053)
Currently, `vc ls` is scoped to your team, and you have to type out a project name if you want to see deployments for a project. This PR instead scopes it to the linked project.

Under these changes, `vc ls` still works similarly to how it currently works: users can still specify a project name to get the deployments for a project. The difference is that:

1. The selected team is the one that the linked project belongs to, instead of the one that the user has selected.
2. `vc ls` with no arguments displays the latest deployments for the linked project.

This is the first part of a larger effort to change the behavior of `vc ls`, plucked from https://github.com/vercel/vercel/pull/7993. More PRs to follow.

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [x] The code changed/added as part of this PR has been covered with tests
- [x] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-06 18:42:51 +00:00
Matthew Stanciu
87fc38e860 [cli] MAJOR: remove vc deploy clipboard copy feature (#8085)
https://vercel.slack.com/archives/C03F2CMNGKG/p1656971502881949

Right now, `vc deploy` automatically copies the deploy url to your clipboard after the deployment has finished. You can opt out via the `--no-clipboard` flag, but the feature is enabled by default.

This is strange behavior—there's no indication that the CLI will hijack your clipboard, and you don't know it's been hijacked until after it happens.

This PR removes the clipboard copying feature as well as the `--no-clipboard` flag.

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [ ] The code changed/added as part of this PR has been covered with tests
- [ ] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-06 18:04:13 +00:00
JJ Kasper
afc4388fc0 [tests] Update canary dist-tag on publish (#8084)
This ensures we update the canary dist-tag when publishing stable releases as we no longer need to do separate canary publishes.  

### Related Issues

x-ref: [slack thread](https://vercel.slack.com/archives/C65QW9PN1/p1657049930618119?thread_ts=1656362480.574099&cid=C65QW9PN1)

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [ ] The code changed/added as part of this PR has been covered with tests
- [ ] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-05 23:45:45 +00:00
Steven
3c48b40b43 Publish Canary
- @vercel/build-utils@5.0.1-canary.0
 - vercel@26.0.1-canary.1
 - @vercel/client@12.0.5-canary.0
 - @vercel/go@2.0.5-canary.0
 - @vercel/next@3.1.4-canary.1
 - @vercel/node@2.4.1-canary.0
 - @vercel/python@3.0.5-canary.0
 - @vercel/redwood@1.0.6-canary.0
 - @vercel/remix@1.0.6-canary.0
 - @vercel/ruby@1.3.13-canary.0
 - @vercel/static-build@1.0.5-canary.0
2022-07-05 17:44:26 -04:00
JJ Kasper
ce89f00328 Publish Canary
- vercel@26.0.1-canary.0
 - @vercel/next@3.1.4-canary.0
2022-07-05 14:17:51 -05:00
Steven
621b53bc49 Publish Stable
- @vercel/build-utils@5.0.0
 - vercel@26.0.0
 - @vercel/client@12.0.4
 - @vercel/fs-detectors@1.0.0
 - @vercel/go@2.0.4
 - @vercel/next@3.1.3
 - @vercel/node@2.4.0
 - @vercel/python@3.0.4
 - @vercel/redwood@1.0.5
 - @vercel/remix@1.0.5
 - @vercel/ruby@1.3.12
 - @vercel/static-build@1.0.4
2022-07-05 08:47:49 -04:00
JJ Kasper
728b620355 Update trailing slash handling for _next/data resolving (#8080) 2022-07-04 09:52:08 -05:00
Luc Leray
7d16395038 [build-utils] Stricter getNodeBinPath return type (#8082)
In https://github.com/vercel/vercel/pull/8058, I made `getNodeBinPath` return type too broad. The function can never return `undefined` so we can make it more strict.
2022-07-04 13:22:35 +00:00
Lee Robinson
59e1259688 [examples] Remove stray console.log. (#8070) 2022-07-01 22:47:36 +00:00
Nathan Rajlich
169242157e [cli] Fix Middleware "matchers" with query string in vc dev (#8069)
`matchers` config in Middleware was not being properly… matched… in `vc dev` when a query string was present.
2022-07-01 20:38:02 +00:00
Steven
db10ffd679 [build-utils][next][redwood][remix][static-build] Fix corepack path prepend (#8065)
This PR fixes a bug where corepack (#7871) was not correctly setup because the lockfile autodetection and node version autodetection was overriding the PATH.

It also fixes a bug where the log output was printed twice because we incorrectly prepended the PATH twice.
2022-07-01 19:33:21 +00:00
Steven
c0d0744c4e [tests] Add yarn.lock to saber test fixture (#8068)
The Saber example had a lock file but the test did not.

This PR copies the lock file since the latest version of Saber doesn't work with the latest Vue.

```
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './package' is not defined by "exports" in /vercel/path0/node_modules/vue/package.json
  at new NodeError (node:internal/errors:372:5)
  at throwExportsNotFound (node:internal/modules/esm/resolve:472:9)
  at packageExportsResolve (node:internal/modules/esm/resolve:753:3)
  at resolveExports (node:internal/modules/cjs/loader:482:36)
  at Function.Module._findPath (node:internal/modules/cjs/loader:522:31)
  at Function.Module._resolveFilename (node:internal/modules/cjs/loader:919:27)
  at Function.resolve (node:internal/modules/cjs/helpers:108:19)
  at module.exports (/vercel/path0/node_modules/saber/dist/webpack/webpack.config.js:30:58)
  at Saber.getWebpackConfig (/vercel/path0/node_modules/saber/dist/index.js:195:58)
  at VueRenderer.build (/vercel/path0/node_modules/saber/dist/vue-renderer/index.js:232:39)
```
2022-07-01 19:04:44 +00:00
Steven
9da67423a5 [node] Fix public TypeScript types (#8064)
There was a regression some time between [1.12.2-canary.6](https://unpkg.com/browse/@vercel/node@1.12.2-canary.6/dist/index.d.ts) and [1.12.2-canary.7](https://unpkg.com/browse/@vercel/node@1.12.2-canary.7/dist/index.d.ts) where the `index.d.ts` types changed.

This PR reverts back to the old behavior so users can properly use

```ts
import { VercelRequest, VercelResponse } from '@vercel/node';
```

The existing test was also changed to use the result of the build rather than copy at the beginning

cfae7ec3c2/packages/node/test/fixtures/15-helpers/ts/index.ts (L1)

- Fixes https://github.com/vercel/vercel/issues/7951
2022-06-30 22:45:49 +00:00
Steven
51fe09d5e9 [build-utils][fs-detectors][cli] MAJOR: move some of build-utils into new fs-detectors (#8054)
The `@vercel/build-utils` package was meant be shared functions necessary for writing a Vercel Builder (aka Runtime).

This package has since bloated into the catch-all package for anything that wasn't a Builder.

This PR removes the bloat in favor of a new package, `@vercel/fs-detectors`. It also removes the need for `@vercel/build-utils` to have a dependency on `@vercel/frameworks`.

- Related to #7951
2022-06-30 21:14:07 +00:00
Nathan Rajlich
695bfbdd60 [cli] Add full stdio mockability for unit tests (#8052)
This PR is a follow-up to #8039, which provides an intuitive syntax for writing unit tests for interactive CLI commands.

The heart of this is the new `await expect(stream).toOutput(test)` custom Jest matcher, which is intended for use with the mock Client `stdout` and `stderr` stream properties. The `test` is a string that will wait for the stream to output via "data" events until a match is found, or it will timeout (after 3 seconds by default). The timeout error has nice Jest-style formatting so that you can easily identify what was output:

<img width="553" alt="Screen Shot 2022-06-29 at 10 33 06 PM" src="https://user-images.githubusercontent.com/71256/176600324-cb1ebecb-e891-42d9-bdc9-4864d3594a8c.png">

Below is an example of a unit test that was added for an interactive `vc login` session:

```typescript
it('should allow login via email', async () => {
  const user = useUser();

  const exitCodePromise = login(client);

  // Wait for login interactive prompt
  await expect(client.stderr).toOutput(`> Log in to Vercel`);

  // Move down to "Email" option
  client.stdin.write('\x1B[B'); // Down arrow
  client.stdin.write('\x1B[B'); // Down arrow
  client.stdin.write('\x1B[B'); // Down arrow
  client.stdin.write('\r'); // Return key

  // Wait for email input prompt
  await expect(client.stderr).toOutput('> Enter your email address:');

  // Write user email address into prompt
  client.stdin.write(`${user.email}\n`);

  // Wait for login success message
  await expect(client.stderr).toOutput(
    `Success! Email authentication complete for ${user.email}`
  );

  // Assert that the `login()` command returned 0 exit code
  await expect(exitCodePromise).resolves.toEqual(0);
});
```

**Note:**  as a consequence of this PR, prompts are now written to stderr instead of stdout, so this change _may_ be considered a breaking change, in which case we should tag it for major release.
2022-06-30 20:17:22 +00:00
Nathan Rajlich
547e88228e Publish Stable
- @vercel/build-utils@4.2.1
 - vercel@25.2.3
 - @vercel/client@12.0.3
 - @vercel/go@2.0.3
 - @vercel/next@3.1.2
 - @vercel/node@2.3.3
 - @vercel/python@3.0.3
 - @vercel/redwood@1.0.4
 - @vercel/remix@1.0.4
 - @vercel/ruby@1.3.11
 - @vercel/static-build@1.0.3
2022-06-30 12:24:13 -07:00
Luc Leray
9bfb5dd535 [build-utils] Handle npm bin exit code 7 (#8058)
In some rare cases, `npm bin` exits with code 7, but still outputs the right bin path.

To reproduce, try:
```
npm init -y
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc
vc
# enter "echo build" for the build command, leave the other configuration as default
```

The build will fail with `Error: Command exited with 7` because `npm bin` fails with code 7, for some reason.

In this PR, we do 2 things:
(1) Ignore exit codes from `npm bin`. It still outputs the right path when it exits with code 7 so we just read the output and check if it's a valid path.
(2) Throw a more specific error message when `npm bin` fails to give us the bin path. The current error was hard to debug because it looked like it was coming from the install commmand. We can do better by emitting a custom error.

Alternative considered for (2): Do not throw errors. If `npm bin` fails, emit a warning and let the build continue.

Related Issues:
- https://github.com/vercel/customer-issues/issues/585 (internal)
2022-06-30 17:27:52 +00:00
Nathan Rajlich
81ea84fae8 [cli] Fix vc build lambda serialization when there's a broken symlink (#8050)
There's some cleanup directory walking logic that was choking when
a Lambda outputs a file with a broken symlink. We shouldn't need to
traverse into those directories in the case of a symlink anyways, so use
`lstat()` instead of `stat()` to prevent that filesystem call from
throwing an error.
2022-06-29 16:04:31 -07:00
Nathan Rajlich
fa8bf07be4 [cli] Add Client#stdin / Client#stdout (#8039)
This will allow for mockability of the input streams (i.e. prompts)
for CLI commands in unit tests.

**Example:**

```typescript
import confirm from '../../src/util/input/confirm';
import { client } from '../mocks/client';

describe('MockClient', () => {
  it('should mock `confirm()`', async () => {
    const confirmedPromise = confirm(client, 'Do the thing?', false);

    client.stdin.write('yes\n');

    const confirmed = await confirmedPromise;
    expect(confirmed).toEqual(true);
  });
});
```
2022-06-29 16:03:56 -07:00
JJ Kasper
cc9dce73ad [next] Ensure uncompressed limit is correct (#8049)
* Revert "Revert "[next] Update max size warning to handle initial layer better" (#8047)"

This reverts commit 8c62de16ce.

* Ensure uncompressed limit is correct

* apply suggestion
2022-06-29 15:13:15 -05:00
Sean Massa
bba7cbd411 [cli][dev] fix: creating "api/some-func.js" after "vc dev" now works (#8041)
If there is no `api` directory, then you run `vc dev`, then you create a new function `api/some-func.js`, then this file would not be served as a new function.

This was being caused by incomplete "new file" handling logic. This PR ensures that the proper detection is done in each new file (`getVercelConfig`) that populates key properties (`apiDir`, `apiExtensions`, and extensionless `files`) for determining when a file is used to serve a request.
2022-06-29 18:37:48 +00:00
Steven
9a3739bebd Publish Stable
- vercel@25.2.2
 - @vercel/next@3.1.1
 - @vercel/node@2.3.2
 - @vercel/redwood@1.0.3
 - @vercel/remix@1.0.3
2022-06-29 09:27:34 -04:00
Gal Schlezinger
8c62de16ce Revert "[next] Update max size warning to handle initial layer better" (#8047)
Revert "[next] Update max size warning to handle initial layer better (#8013)"

This reverts commit f20703b15d.
2022-06-29 09:26:29 -04:00
Steven
e9333988d7 [next][node][redwood][remix] Bump @vercel/nft to 0.20.1 (#8042)
- https://github.com/vercel/nft/releases/tag/0.20.0
- https://github.com/vercel/nft/releases/tag/0.20.1
2022-06-29 00:21:14 +00:00
Nathan Rajlich
fb001ce7eb [tests] Remove TODO comments from Middleware matchers vc dev test (#8037) 2022-06-28 18:26:56 +00:00
Sean Massa
b399fe7037 Publish Stable
- vercel@25.2.1
 - @vercel/node@2.3.1
2022-06-28 12:04:57 -05:00
Sean Massa
88385b3c84 [cli][node] switch to esbuild for compiling edge functions (#8032) 2022-06-28 11:27:56 -05:00
Steven
eed39913e1 Publish Stable
- @vercel/build-utils@4.2.0
 - vercel@25.2.0
 - @vercel/client@12.0.2
 - @vercel/edge@0.0.1
 - @vercel/frameworks@1.0.2
 - @vercel/go@2.0.2
 - @vercel/next@3.1.0
 - @vercel/node@2.3.0
 - @vercel/python@3.0.2
 - @vercel/redwood@1.0.2
 - @vercel/remix@1.0.2
 - @vercel/routing-utils@1.13.5
 - @vercel/ruby@1.3.10
 - @vercel/static-build@1.0.2
2022-06-28 10:15:33 -04:00
Gal Schlezinger
03e9047bc9 [@vercel/edge] add header helpers (#8036)
* [@vercel/edge] add header helpers

* rename getIp => ipAddress, getGeo => geolocation, as suggested by @kikobeats
2022-06-28 11:26:18 +02:00
Nathan Rajlich
0e35205bf1 [cli][dev][node] Support matchers config for Middleware in vc dev (#8033)
Adds support for `config.matchers` exported property in Middleware during `vc dev`.
2022-06-28 08:34:48 +00:00
Steven
e42fe34c4a [tests] Bump turbo to 1.3.1 (#8011)
https://turborepo.org/blog/turbo-1-3-0
2022-06-28 04:48:35 +00:00
Sean Massa
3ece7ac969 [cli][node] make error handling of edge functions consistent with serverless functions in vc dev (#8007)
When edge functions error, they were showing a basic text response instead of the error template. They were also sending a 502 status code instead of a 500.

This PR makes the error handling of Edge Functions consistent with Serverless Functions when executed through `vc dev`. If we want to update the error templates themselves, we can do that in a separate PR.

**Note:** Production currently treats Edge Function errors differently from Serverless Function errors, but that's a known issue that will be resolved.

---

*I deleted the original outputs (terminal and browser screenshots) because they are out of date and added a lot of content to this page. See my latest comment for updated examples.*
2022-06-28 04:12:21 +00:00
Nathan Rajlich
4f832acf90 [remix] Don't depend on @remix-run/vercel (#8029)
Instead, just add it to the project's `package.json` file before installing the dependencies.

Fixes warning about missing peer dependencies when installing CLI.

<img width="409" alt="CleanShot 2022-05-22 at 09 40 09@2x" src="https://user-images.githubusercontent.com/71256/176084428-79e964b3-8b20-416d-bf3f-c5bd36f4b0ff.png">

Now, a warning is shown in the Deployment build logs, saying that the dep was added, but that the user should commit the change:

<img width="931" alt="Screen Shot 2022-06-27 at 8 15 19 PM" src="https://user-images.githubusercontent.com/71256/176084377-dab5f7d3-4e9f-4bf6-baee-63708b65f218.png">
2022-06-28 03:29:50 +00:00
Matthew Stanciu
918726e01d [cli] Support "http:" scheme in vc bisect (#8023)
`vc bisect` currently prepends `https://` to a passed-in url if it doesn't begin with https—which means that if someone passes in a url that begins with `http://`, it'll turn the url into `https://http://url.com`. This PR fixes this.

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [ ] The code changed/added as part of this PR has been covered with tests
- [ ] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-06-28 02:15:59 +00:00
JJ Kasper
dc2ddf867b Publish Stable
- @vercel/next@3.0.5
2022-06-27 15:37:09 -05:00
Nathan Rajlich
ee1211416f [cli] Support root-level Middleware file in vc dev (#7973)
Adds initial support for a root-level `middleware.js` / `middleware.ts` file in the `vercel dev` CLI command. This leverages the existing Edge Function invoking logic in `@vercel/node`'s `startDevServer()` function and applies the necessary response / rewrites / mutations to the HTTP request based on the result of the middleware invocation.
2022-06-27 19:56:32 +00:00
JJ Kasper
570fd24e29 Publish Canary
- vercel@25.1.1-canary.11
 - @vercel/edge@0.0.1-canary.0
 - @vercel/next@3.0.5-canary.1
2022-06-27 12:35:09 -05:00
Gal Schlezinger
40681ad0f4 [next] allow to declare edge functions outside of /api/ in Next.js (#7997) 2022-06-27 11:55:39 -05:00
JJ Kasper
f20703b15d [next] Update max size warning to handle initial layer better (#8013)
* Update max size warning to handle initial layer better

* update test
2022-06-27 10:22:48 -05:00
Gal Schlezinger
68eb197112 Add @vercel/edge with helpers for Middleware (#8022) 2022-06-27 17:37:46 +03:00
JJ Kasper
b8b87b96da Publish Canary
- vercel@25.1.1-canary.10
 - @vercel/next@3.0.5-canary.0
2022-06-25 15:04:32 -05:00
JJ Kasper
967c24f1bb [next] Ensure trailing slash is handled while resolving _next/data rewrites (#8015)
* Ensure trailing slash is handled while resolving _next/data rewrites

* update regex
2022-06-25 12:39:30 -05:00
JJ Kasper
609f781234 Publish Stable
- @vercel/next@3.0.4
2022-06-24 23:16:40 -05:00
JJ Kasper
998f6bf6e6 Publish Canary
- vercel@25.1.1-canary.9
 - @vercel/next@3.0.4-canary.3
 - @vercel/node@2.2.1-canary.1
2022-06-23 15:45:31 -05:00
JJ Kasper
7511c2ef85 [next] Fix normalizing for _next/data/index.json route with middleware (#8005)
* Fix normalizing for _next/data/index.json route with middleware

* ensure / -> /index.json denormalizes as well

* add continue/override
2022-06-23 14:57:07 -05:00
Michaël De Boey
71425fac1f [examples] Update remix template (#7917) 2022-06-21 12:20:01 -07:00
Lee Robinson
6973cd5989 [examples] Address follow up from SvelteKit example update (#8002) 2022-06-21 12:10:55 -07:00
Nathan Rajlich
24785ff50a [node] Implement matcher config support for Middleware (#8001)
Allows to specify a string or array of paths/globs for when a root-level
Middleware should be invoked.

```javascript
export const config = {
  matcher: ['/about/:path*', '/dashboard/:path*'],
}
```
2022-06-21 12:10:13 -07:00
Nathan Rajlich
aa3ad4478c [cli] Only show "Removing .vercel/output" log when directory exists in vc dev (#8004)
Conditionally show the "Removing…" message, instead of always rendering it.
2022-06-21 19:04:24 +00:00
JJ Kasper
f0d73049ca Publish Canary
- vercel@25.1.1-canary.8
 - @vercel/next@3.0.4-canary.2
2022-06-21 10:09:24 -05:00
JJ Kasper
6cef07354a [next] Ensure basePath is matched correctly for _next/data resolving (#7999)
This ensures we correctly match `basePath` for the `_next/data` resolving routes. The tests in the below referenced PR already cover this change so no new fixtures have been added here as they would rely on those changes landing first. 

### Related Issues

x-ref: https://github.com/vercel/next.js/pull/37854

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [ ] The code changed/added as part of this PR has been covered with tests
- [ ] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-06-20 22:29:01 +00:00
Nathan Rajlich
50af9f5b75 Publish Canary
- vercel@25.1.1-canary.7
 - @vercel/next@3.0.4-canary.1
2022-06-20 00:19:54 -07:00
Seiya Nuta
af76b134d8 [next] Check the size of WASM files in the Edge Functions size validation (#7936)
This is the last missing piece in the size validation of edge functions. Since WASM binaries are not bundled in the user JavaScript file, we also need to count their sizes in the validation.

### Related Issues

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [x] The code changed/added as part of this PR has been covered with tests
- [x] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-06-20 04:58:36 +00:00
Nathan Rajlich
c7640005fd [cli] Implement vc build --output parameter (#7995)
Use `--output` parameter to output the Build Output API build artifacts to a different location than the default `.vercel/output` directory.
2022-06-18 23:10:33 +00:00
Nathan Rajlich
3deed977ba [cli] Add requirePath to "builds.json" in vc build (#7990)
Include a `requirePath` property to each "build" in the `builds.json` file which is the absolute path to the Builder entrypoint that was executed.

This gives context as to which Builder was invoked by `vc build` which helps with introspection / debugging.
2022-06-18 15:07:46 +00:00
Nathan Rajlich
b38c360e36 [cli] Include "argv" in builds.json produced by vc build (#7988)
It will be useful for debugging purposes to have access to the arguments that were passed to `vc build`.
2022-06-17 22:18:33 +00:00
JJ Kasper
1595e48414 Publish Canary
- vercel@25.1.1-canary.6
 - @vercel/next@3.0.4-canary.0
2022-06-17 16:32:59 -05:00
JJ Kasper
e6b0ee3e3c [next] Update data regex to be less specific (#7994)
Update data regex to be less specific
2022-06-17 16:32:33 -05:00
JJ Kasper
a247e31688 Publish Stable
- @vercel/next@3.0.3
2022-06-17 14:49:54 -05:00
JJ Kasper
dc02e763a4 Publish Canary
- vercel@25.1.1-canary.5
 - @vercel/next@3.0.3-canary.2
2022-06-17 14:24:39 -05:00
JJ Kasper
8567fc0de6 [next] Optimize _next/data route regex (#7992)
Optimize _next/data route regex
2022-06-17 14:19:37 -05:00
JJ Kasper
4f8f3d373f Publish Canary
- vercel@25.1.1-canary.4
 - @vercel/next@3.0.3-canary.1
 - @vercel/node@2.2.1-canary.0
2022-06-16 18:00:29 -05:00
JJ Kasper
debb85b690 [next] Update error for internal missing page (#7987)
Update error for internal missing page
2022-06-16 16:44:12 -05:00
Thai Pangsakulyanont
bfef989ada [examples] Delete pnpm-lock.yaml from examples/sveltekit because it already contains yarn.lock (#7983)
Delete pnpm-lock.yaml

Co-authored-by: Lee Robinson <lrobinson2011@gmail.com>
2022-06-16 10:24:18 -07:00
JJ Kasper
4e0b6c5eaf [next] Update to skip middleware for on-demand revalidate (#7978)
Update to skip middleware for on-demand revalidate
2022-06-16 11:23:30 -05:00
Nathan Rajlich
0ace69ef75 Publish Stable
- @vercel/node@2.2.0
2022-06-15 13:01:57 -07:00
Nathan Rajlich
b7b7923f92 Publish Canary
- vercel@25.1.1-canary.3
 - @vercel/next@3.0.3-canary.0
 - @vercel/node@2.1.1-canary.2
2022-06-15 11:56:32 -07:00
Nathan Rajlich
8167233c56 [tests] Create tarballs of all packages in Vercel deployment (#7967)
For the Vercel deployment, run `yarn pack` in each of the packages in the monorepo and place them in the "public/tarballs" directory so that we can have npm-installable URLs of each package for every commit. The `package.json` of each package is also updated to reference the tarball when a package depends on other packages within the monorepo.

Try it out like:

```
$ npm i -g https://vercel-biww73ffq.vercel.sh/tarballs/vercel.tgz

# Notice how the package.json has the monorepo dependencies
# updated to point to the related tarballs from the same deployment:

$ cat /usr/local/lib/node_modules/vercel/package.json | grep biww
    "@vercel/build-utils": "https://vercel-biww73ffq.vercel.sh/tarballs/@vercel/build-utils.tgz",
    "@vercel/go": "https://vercel-biww73ffq.vercel.sh/tarballs/@vercel/go.tgz",
    "@vercel/next": "https://vercel-biww73ffq.vercel.sh/tarballs/@vercel/next.tgz",
    "@vercel/node": "https://vercel-biww73ffq.vercel.sh/tarballs/@vercel/node.tgz",
    "@vercel/python": "https://vercel-biww73ffq.vercel.sh/tarballs/@vercel/python.tgz",
    "@vercel/redwood": "https://vercel-biww73ffq.vercel.sh/tarballs/@vercel/redwood.tgz",
    "@vercel/remix": "https://vercel-biww73ffq.vercel.sh/tarballs/@vercel/remix.tgz",
    "@vercel/ruby": "https://vercel-biww73ffq.vercel.sh/tarballs/@vercel/ruby.tgz",
    "@vercel/static-build": "https://vercel-biww73ffq.vercel.sh/tarballs/@vercel/static-build.tgz",
    "@vercel/client": "https://vercel-biww73ffq.vercel.sh/tarballs/@vercel/client.tgz",
    "@vercel/frameworks": "https://vercel-biww73ffq.vercel.sh/tarballs/@vercel/frameworks.tgz",

# Also notice that the "version" in `package.json` gets updated to include the commit
# SHA so that we can easily identify which commit a given tarball was generated from:

$ vercel --version
25.1.1-canary.1-727b290
```
2022-06-15 17:44:23 +00:00
Michaël De Boey
32ee6aba92 [node] Move @vercel/build-utils to "dependencies" (#7934)
fix(node): move `@vercel/build-utils` to `dependencies`

Co-authored-by: Steven <steven@ceriously.com>
2022-06-15 13:18:02 -04:00
Nathan Rajlich
b48f7a7e6e [node] Add missing dependencies for dev-server.ts (#7969)
Some new imports were introduced recently but are missing from the `"dependencies"` object, so they were missing when the package was installed from the npm registry.
2022-06-15 01:38:17 +00:00
JJ Kasper
a961c9b992 Publish Stable
- @vercel/next@3.0.2
2022-06-14 17:35:24 -05:00
JJ Kasper
cf7c50d691 Publish Canary
- vercel@25.1.1-canary.2
 - @vercel/next@3.0.2-canary.2
2022-06-14 14:57:54 -05:00
JJ Kasper
f4be388a1f [next] Fix _next/data resolving priority with afterFiles (#7968)
* Fix _next/data resolving priority with afterFiles

* ensure x-nextjs-matched-path header is set
2022-06-14 14:52:49 -05:00
Nathan Rajlich
c1bc53dea8 Publish Canary
- @vercel/build-utils@4.1.1-canary.1
 - vercel@25.1.1-canary.1
 - @vercel/client@12.0.2-canary.1
 - @vercel/frameworks@1.0.2-canary.0
 - @vercel/go@2.0.2-canary.1
 - @vercel/next@3.0.2-canary.1
 - @vercel/node@2.1.1-canary.1
 - @vercel/python@3.0.2-canary.1
 - @vercel/redwood@1.0.2-canary.1
 - @vercel/remix@1.0.2-canary.1
 - @vercel/routing-utils@1.13.5-canary.0
 - @vercel/ruby@1.3.10-canary.1
 - @vercel/static-build@1.0.2-canary.1
2022-06-14 09:15:20 -07:00
Nathan Rajlich
6855e3df54 [tests] Allow for multiple probes (#7957)
The `probes` in the vercel.json files in tests fixtures weren't allowing both `mustContain` _and_ `responseHeaders` probes due to an `else` block. This PR allows to combine those checks into the same probe test.
2022-06-14 06:54:16 +00:00
Nathan Rajlich
0d39dbd1d9 [routing-utils] Convert tests to TypeScript (#7959)
Allows for the tests to be run without rebuilding the source code.
2022-06-13 23:29:00 -07:00
Nathan Rajlich
509c85182a [tests] Update ts-jest to v28.0.5 (#7962)
Gets rid of the annoying ts-jest update warning:

```
ts-jest[ts-jest-transformer] (WARN) Use 'ts-jest' entry point in v28 will resolve in refactored transformer. If you wish to use legacy transformer, please adjust your Jest 'transform' config. For example:
     module.exports = {
        transform: {
           '^.+\\.tsx?$': 'ts-jest/legacy'
        }
     }
```
2022-06-13 23:18:23 -07:00
Sean Massa
ae801e563d [cli] add support for Edge Functions in vercel dev (#7925)
Add support for Edge Functions when using `vercel dev`. This uses the [`edge-runtime`](https://edge-runtime.vercel.app/) to emulate the proper environment, [`ncc`](https://github.com/vercel/ncc) to parse the entrypoint (supporting TypeScript and importing node modules), and some custom marshaling to pass the request/response values through.
2022-06-13 22:50:16 +00:00
Steven
0e8278f490 [cli] Fix vc dev when user has ipv6 configured (#7956)
Running tests from #7455 

- Fixes #7306
- Closes #7455
2022-06-13 21:04:00 +00:00
Steven
0d302a6f48 [cli] Add alias for vc develop => vc dev (#7955)
This will make it easier for users who are used to `gatsby develop` or other CLIs that abbreviate to [`develop`](https://twitter.com/DiverseCauses/status/1536358138676486146).

Fixes this:
![image](https://user-images.githubusercontent.com/229881/173387083-7afd01db-00ab-4670-a07d-fa6c8cecea5d.png)
2022-06-13 17:01:46 +00:00
Lee Robinson
4e4f5f28a2 [examples] Add Vercel Analytics for CRA/SvelteKit templates. (#7894)
Successfully tested with:

- [CRA](https://front-git-analytics-for-all-frameworks.vercel.sh/now-examples/create-react-app/analytics?device=desktop&timePeriod=1d&percentile=75&timeInterval=3h&sort=datapoints&search=)
- [SvelteKit](https://front-git-analytics-for-all-frameworks.vercel.sh/now-examples/sveltekit/analytics?device=desktop&timePeriod=1d&percentile=75&timeInterval=3h&sort=datapoints&search=)

Dependencies have also been updated 👍
2022-06-13 16:30:14 +00:00
Steven
5205a4ec4b [cli] Fix vc dev with dynamic paths and ESM (#7946)
In a previous PR, the entrypoint path extension was stripped in `vc dev` to match the behavior of production deployments (`/api/user.js` => `/api/user`). However, it was supposed to map back to the original file extension before invoking the matching builder. This PR fixes the mapping for dynamic paths with ESM, such as `/api/[id].mjs` => `/api/[id]`.
2022-06-12 17:54:21 +00:00
Nathan Rajlich
2c15e496ed [tests] Remove as any casting in unit tests (#7945)
Maybe this casting was necessary at some point, but it doesn't seem to be anymore.
2022-06-10 22:30:51 +00:00
Matthew Stanciu
1f0ca46626 [cli] Add git metadata to vc deploy (#7910)
Currently, git metadata is not included on the Vercel dashboard when a project is deployed via the CLI. This PR populates the git metadata.

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [x] The code changed/added as part of this PR has been covered with tests
- [x] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-06-10 20:53:47 +00:00
Steven
17cb5f1bc6 [cli] Remove boxen for warnings (#7944)
This never worked correctly and its too verbose. Lets remove boxen in favor of yellow text.

## Before

<img width="1432" alt="before" src="https://user-images.githubusercontent.com/229881/173138338-1cd1c81c-f294-4752-a08f-f80466fbd21c.png" />

## After

<img width="1432" alt="after" src="https://user-images.githubusercontent.com/229881/173138813-f9ff4de1-5bb7-4b60-8609-6abdd0e86fa0.png">
2022-06-10 20:28:00 +00:00
Nathan Rajlich
b095031292 [cli] Normalize "src" property in vc build (#7943)
This matches the production behavior.
2022-06-10 18:53:01 +00:00
Ethan Arrowood
f50bcbc0ba [cli] Implement vercel.json configuration overrides (#7811)
### Related Issues

This PR adds vercel.json configuration overrides support for both new and existing project flows through the CLI. It includes integration and unit tests.

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [x] The code changed/added as part of this PR has been covered with tests
- [x] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-06-10 16:46:46 +00:00
JJ Kasper
4bf6295d7a Publish Canary
- @vercel/build-utils@4.1.1-canary.0
 - vercel@25.1.1-canary.0
 - @vercel/client@12.0.2-canary.0
 - @vercel/go@2.0.2-canary.0
 - @vercel/next@3.0.2-canary.0
 - @vercel/node@2.1.1-canary.0
 - @vercel/python@3.0.2-canary.0
 - @vercel/redwood@1.0.2-canary.0
 - @vercel/remix@1.0.2-canary.0
 - @vercel/ruby@1.3.10-canary.0
 - @vercel/static-build@1.0.2-canary.0
2022-06-10 09:37:52 -05:00
JJ Kasper
a4001ce10b [next] Add handling for resolving _next/data URLs (#7935)
* Add handling for resolving _next/data URLs

* update check

* update routes

* remove extra routes

* Update resolving
2022-06-10 08:50:59 -05:00
Nathan Rajlich
2df3432d88 [node] Add zero-config support for root-level middleware.js/middleware.ts file (#7928)
Adds support for `config.middleware` property in `@vercel/node` to output an Edge Middleware (by including a catch-all route in the output).

Also updates the zero-config detection logic to schedule a build of root-level `middleware.js`/`middleware.ts` files with `@vercel/node` with this middleware option enabled.
2022-06-09 23:04:39 +00:00
chloetedder
bcfc19de12 [build-utils] Add getProjectPaths that will evaluate if there is a monorepo (#7826)
<img width="1174" alt="Screen Shot 2022-05-19 at 3 53 55 PM" src="https://user-images.githubusercontent.com/35947020/169402845-4eb70a24-9954-44ac-8726-a6b56161d58c.png">

If there is no workspace detected, we still want to check if there are potential projects in the monorepo. An example of this case would be:

<img width="202" alt="Screen Shot 2022-05-19 at 10 29 12 AM" src="https://user-images.githubusercontent.com/35947020/169336400-e69845e3-616e-4857-80fe-c2614a65352a.png">

The directories `backend/app-three`, `frontend/app-one`, `frontend/app-two` all have definitions within their `package.json` files that define a framework assosiated with the project. This is what distinguishes them from the `package.json` files within `backend` and `frontend`

`getProjectPaths` loops through a depth of 3 (this can be changed but in most cases of this it won't be more than 3). For each directory it recursively calls itself until it finds a `package.json` file. It then checks if the directory has a framework defined and if so it adds the path to the final return value of all projects.

If any `skipPaths` are sent into the function, no projects will be looked for within those path(s). This would include the case for a hybrid monorepo where a workspace has already been detected

### Related Issues

> Fixes #1
> Related to #2

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [ ] The code changed/added as part of this PR has been covered with tests
- [ ] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-06-09 15:15:25 +00:00
Matthew Stanciu
04381c669b [cli] Pull correct env variables in vc build command (#7933)
`vc build` runs `vc pull` if no project settings are found locally. By default, `vc pull` pulls development environment variables, but we want either preview or production environment variables in the context of `vc build`. This PR adds a flag to the internal `vc pull` call to pull the correct environment variables.

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [x] The code changed/added as part of this PR has been covered with tests
- [x] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-06-09 06:15:30 +00:00
1181 changed files with 126849 additions and 14916 deletions

View File

@@ -19,6 +19,9 @@ packages/cli/src/util/dev/templates/*.ts
packages/client/tests/fixtures packages/client/tests/fixtures
packages/client/lib packages/client/lib
# hydrogen
packages/hydrogen/edge-entry.js
# next # next
packages/next/test/integration/middleware packages/next/test/integration/middleware
packages/next/test/integration/middleware-eval packages/next/test/integration/middleware-eval

4
.gitattributes vendored
View File

@@ -8,3 +8,7 @@ packages/*/test/* linguist-vendored
# Go build fails with Windows line endings. # Go build fails with Windows line endings.
*.go text eol=lf *.go text eol=lf
go.mod text eol=lf go.mod text eol=lf
# Mark certain files as "binary" -- hide diffs
**/test/fixtures/**/git/**/* binary
**/test/fixtures/**/git/**/* linguist-generated

View File

@@ -12,6 +12,7 @@ To get started, execute the following:
``` ```
git clone https://github.com/vercel/vercel git clone https://github.com/vercel/vercel
cd vercel
yarn install yarn install
yarn bootstrap yarn bootstrap
yarn build yarn build

5
.prettierignore Normal file
View File

@@ -0,0 +1,5 @@
# https://prettier.io/docs/en/ignore.html
# ignore these files with an intentional syntax error
packages/cli/test/dev/fixtures/edge-function-error/api/edge-error-syntax.js
packages/cli/test/fixtures/unit/commands/build/node-error/api/typescript.ts

View File

@@ -1,18 +1 @@
* packages/*/test/**
# general
!utils/
!utils/run.js
!.yarnrc
!yarn.lock
!package.json
!turbo.json
# api
!api/
!api/**
# packages
!packages/
!packages/frameworks
!packages/frameworks/**

View File

@@ -1,5 +1,6 @@
import fs from 'fs/promises'; import fs from 'fs/promises';
import { join } from 'path'; import { join, dirname } from 'path';
import execa from 'execa';
import { getExampleList } from '../examples/example-list'; import { getExampleList } from '../examples/example-list';
import { mapOldToNew } from '../examples/map-old-to-new'; import { mapOldToNew } from '../examples/map-old-to-new';
@@ -40,7 +41,32 @@ async function main() {
JSON.stringify([...existingExamples, ...oldExamples]) JSON.stringify([...existingExamples, ...oldExamples])
); );
const { stdout: sha } = await execa('git', ['rev-parse', '--short', 'HEAD'], {
cwd: repoRoot,
});
const tarballsDir = join(pubDir, 'tarballs');
const packagesDir = join(repoRoot, 'packages');
const packages = await fs.readdir(packagesDir);
for (const pkg of packages) {
const fullDir = join(packagesDir, pkg);
const packageJsonRaw = await fs.readFile(
join(fullDir, 'package.json'),
'utf-8'
);
const packageJson = JSON.parse(packageJsonRaw);
const tarballName = `${packageJson.name
.replace('@', '')
.replace('/', '-')}-v${packageJson.version}-${sha.trim()}.tgz`;
const destTarballPath = join(tarballsDir, `${packageJson.name}.tgz`);
await fs.mkdir(dirname(destTarballPath), { recursive: true });
await fs.copyFile(join(fullDir, tarballName), destTarballPath);
}
console.log('Completed building static frontend.'); console.log('Completed building static frontend.');
} }
main().catch(console.error); main().catch(err => {
console.log('error running build:', err);
process.exit(1);
});

View File

@@ -5,12 +5,13 @@
"description": "API for the vercel/vercel repo", "description": "API for the vercel/vercel repo",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"vercel-build": "node ../utils/run.js build all" "//TODO": "We should add this pkg to yarn workspaces",
"vercel-build": "cd .. && yarn install && yarn vercel-build"
}, },
"dependencies": { "dependencies": {
"@sentry/node": "5.11.1", "@sentry/node": "5.11.1",
"got": "10.2.1", "got": "10.2.1",
"node-fetch": "2.6.1", "node-fetch": "2.6.7",
"parse-github-url": "1.0.2", "parse-github-url": "1.0.2",
"tar-fs": "2.0.0", "tar-fs": "2.0.0",
"unzip-stream": "0.3.0" "unzip-stream": "0.3.0"

View File

@@ -0,0 +1,2 @@
# `REACT_APP` prefix is required to expose to client-side
REACT_APP_VERCEL_ANALYTICS_ID=$VERCEL_ANALYTICS_ID

View File

@@ -1,13 +1,13 @@
{ {
"private": true, "private": true,
"dependencies": { "dependencies": {
"@testing-library/jest-dom": "^5.16.1", "@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^12.1.2", "@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^14.2.0",
"react": "^17.0.2", "react": "^18.1.0",
"react-dom": "^17.0.2", "react-dom": "^18.1.0",
"react-scripts": "5.0.0", "react-scripts": "5.0.1",
"web-vitals": "^2.1.3" "web-vitals": "^2.1.4"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",

View File

@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom';
import './index.css'; import './index.css';
import App from './App'; import App from './App';
import reportWebVitals from './reportWebVitals'; import reportWebVitals from './reportWebVitals';
import { sendToVercelAnalytics } from './vitals';
ReactDOM.render( ReactDOM.render(
<React.StrictMode> <React.StrictMode>
@@ -11,7 +12,4 @@ ReactDOM.render(
document.getElementById('root') document.getElementById('root')
); );
// If you want to start measuring performance in your app, pass a function reportWebVitals(sendToVercelAnalytics);
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

View File

@@ -0,0 +1,40 @@
const vitalsUrl = 'https://vitals.vercel-analytics.com/v1/vitals';
function getConnectionSpeed() {
return 'connection' in navigator &&
navigator['connection'] &&
'effectiveType' in navigator['connection']
? navigator['connection']['effectiveType']
: '';
}
export function sendToVercelAnalytics(metric) {
const analyticsId = process.env.REACT_APP_VERCEL_ANALYTICS_ID;
if (!analyticsId) {
return;
}
const body = {
dsn: analyticsId,
id: metric.id,
page: window.location.pathname,
href: window.location.href,
event_name: metric.name,
value: metric.value.toString(),
speed: getConnectionSpeed(),
};
const blob = new Blob([new URLSearchParams(body).toString()], {
// This content type is necessary for `sendBeacon`
type: 'application/x-www-form-urlencoded',
});
if (navigator.sendBeacon) {
navigator.sendBeacon(vitalsUrl, blob);
} else
fetch(vitalsUrl, {
body: blob,
method: 'POST',
credentials: 'omit',
keepalive: true,
});
}

View File

@@ -1484,10 +1484,10 @@
"@svgr/plugin-svgo" "^5.5.0" "@svgr/plugin-svgo" "^5.5.0"
loader-utils "^2.0.0" loader-utils "^2.0.0"
"@testing-library/dom@^8.0.0": "@testing-library/dom@^8.5.0":
version "8.11.2" version "8.13.0"
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.11.2.tgz#fc110c665a066c2287be765e4a35ba8dad737015" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.13.0.tgz#bc00bdd64c7d8b40841e27a70211399ad3af46f5"
integrity sha512-idsS/cqbYudXcVWngc1PuWNmXs416oBy2g/7Q8QAUREt5Z3MUkAL2XJD7xazLJ6esDfqRDi/ZBxk+OPPXitHRw== integrity sha512-9VHgfIatKNXQNaZTtLnalIy0jNZzY35a4S3oi08YAt9Hv1VsfZ/DfA45lM8D/UhtHBGJ4/lGwp0PZkVndRkoOQ==
dependencies: dependencies:
"@babel/code-frame" "^7.10.4" "@babel/code-frame" "^7.10.4"
"@babel/runtime" "^7.12.5" "@babel/runtime" "^7.12.5"
@@ -1498,10 +1498,10 @@
lz-string "^1.4.4" lz-string "^1.4.4"
pretty-format "^27.0.2" pretty-format "^27.0.2"
"@testing-library/jest-dom@^5.16.1": "@testing-library/jest-dom@^5.16.4":
version "5.16.1" version "5.16.4"
resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.16.1.tgz#3db7df5ae97596264a7da9696fe14695ba02e51f" resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.16.4.tgz#938302d7b8b483963a3ae821f1c0808f872245cd"
integrity sha512-ajUJdfDIuTCadB79ukO+0l8O+QwN0LiSxDaYUTI4LndbbUsGi6rWU1SCexXzBA2NSjlVB9/vbkasQIL3tmPBjw== integrity sha512-Gy+IoFutbMQcky0k+bqqumXZ1cTGswLsFqmNLzNdSKkU9KGV2u9oXhukCbbJ9/LRPKiqwxEE8VpV/+YZlfkPUA==
dependencies: dependencies:
"@babel/runtime" "^7.9.2" "@babel/runtime" "^7.9.2"
"@types/testing-library__jest-dom" "^5.9.1" "@types/testing-library__jest-dom" "^5.9.1"
@@ -1513,20 +1513,19 @@
lodash "^4.17.15" lodash "^4.17.15"
redent "^3.0.0" redent "^3.0.0"
"@testing-library/react@^12.1.2": "@testing-library/react@^13.3.0":
version "12.1.2" version "13.3.0"
resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.2.tgz#f1bc9a45943461fa2a598bb4597df1ae044cfc76" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-13.3.0.tgz#bf298bfbc5589326bbcc8052b211f3bb097a97c5"
integrity sha512-ihQiEOklNyHIpo2Y8FREkyD1QAea054U0MVbwH1m8N9TxeFz+KoJ9LkqoKqJlzx2JDm56DVwaJ1r36JYxZM05g== integrity sha512-DB79aA426+deFgGSjnf5grczDPiL4taK3hFaa+M5q7q20Kcve9eQottOG5kZ74KEr55v0tU2CQormSSDK87zYQ==
dependencies: dependencies:
"@babel/runtime" "^7.12.5" "@babel/runtime" "^7.12.5"
"@testing-library/dom" "^8.0.0" "@testing-library/dom" "^8.5.0"
"@types/react-dom" "^18.0.0"
"@testing-library/user-event@^13.5.0": "@testing-library/user-event@^14.2.0":
version "13.5.0" version "14.2.0"
resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-13.5.0.tgz#69d77007f1e124d55314a2b73fd204b333b13295" resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.2.0.tgz#8293560f8f80a00383d6c755ec3e0b918acb1683"
integrity sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg== integrity sha512-+hIlG4nJS6ivZrKnOP7OGsDu9Fxmryj9vCl8x0ZINtTJcCHs2zLsYif5GzuRiBF2ck5GZG2aQr7Msg+EHlnYVQ==
dependencies:
"@babel/runtime" "^7.12.5"
"@tootallnate/once@1": "@tootallnate/once@1":
version "1.1.2" version "1.1.2"
@@ -1735,6 +1734,11 @@
resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.4.3.tgz#a3c65525b91fca7da00ab1a3ac2b5a2a4afbffbf" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.4.3.tgz#a3c65525b91fca7da00ab1a3ac2b5a2a4afbffbf"
integrity sha512-QzSuZMBuG5u8HqYz01qtMdg/Jfctlnvj1z/lYnIDXs/golxw0fxtRAHd9KrzjR7Yxz1qVeI00o0kiO3PmVdJ9w== integrity sha512-QzSuZMBuG5u8HqYz01qtMdg/Jfctlnvj1z/lYnIDXs/golxw0fxtRAHd9KrzjR7Yxz1qVeI00o0kiO3PmVdJ9w==
"@types/prop-types@*":
version "15.7.5"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf"
integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==
"@types/q@^1.5.1": "@types/q@^1.5.1":
version "1.5.5" version "1.5.5"
resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.5.tgz#75a2a8e7d8ab4b230414505d92335d1dcb53a6df" resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.5.tgz#75a2a8e7d8ab4b230414505d92335d1dcb53a6df"
@@ -1750,6 +1754,22 @@
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
"@types/react-dom@^18.0.0":
version "18.0.5"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.0.5.tgz#330b2d472c22f796e5531446939eacef8378444a"
integrity sha512-OWPWTUrY/NIrjsAPkAk1wW9LZeIjSvkXRhclsFO8CZcZGCOg2G0YZy4ft+rOyYxy8B7ui5iZzi9OkDebZ7/QSA==
dependencies:
"@types/react" "*"
"@types/react@*":
version "18.0.9"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.9.tgz#d6712a38bd6cd83469603e7359511126f122e878"
integrity sha512-9bjbg1hJHUm4De19L1cHiW0Jvx3geel6Qczhjd0qY5VKVE2X5+x77YxAepuCwVh4vrgZJdgEJw48zrhRIeF4Nw==
dependencies:
"@types/prop-types" "*"
"@types/scheduler" "*"
csstype "^3.0.2"
"@types/resolve@1.17.1": "@types/resolve@1.17.1":
version "1.17.1" version "1.17.1"
resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6" resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6"
@@ -1762,6 +1782,11 @@
resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.1.tgz#d8f1c0d0dc23afad6dc16a9e993a0865774b4065" resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.1.tgz#d8f1c0d0dc23afad6dc16a9e993a0865774b4065"
integrity sha512-xoDlM2S4ortawSWORYqsdU+2rxdh4LRW9ytc3zmT37RIKQh6IHyKwwtKhKis9ah8ol07DCkZxPt8BBvPjC6v4g== integrity sha512-xoDlM2S4ortawSWORYqsdU+2rxdh4LRW9ytc3zmT37RIKQh6IHyKwwtKhKis9ah8ol07DCkZxPt8BBvPjC6v4g==
"@types/scheduler@*":
version "0.16.2"
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39"
integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==
"@types/serve-index@^1.9.1": "@types/serve-index@^1.9.1":
version "1.9.1" version "1.9.1"
resolved "https://registry.yarnpkg.com/@types/serve-index/-/serve-index-1.9.1.tgz#1b5e85370a192c01ec6cec4735cf2917337a6278" resolved "https://registry.yarnpkg.com/@types/serve-index/-/serve-index-1.9.1.tgz#1b5e85370a192c01ec6cec4735cf2917337a6278"
@@ -3175,6 +3200,11 @@ cssstyle@^2.3.0:
dependencies: dependencies:
cssom "~0.3.6" cssom "~0.3.6"
csstype@^3.0.2:
version "3.1.0"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.0.tgz#4ddcac3718d787cf9df0d1b7d15033925c8f29f2"
integrity sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==
damerau-levenshtein@^1.0.7: damerau-levenshtein@^1.0.7:
version "1.0.8" version "1.0.8"
resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7" resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7"
@@ -3622,10 +3652,10 @@ escodegen@^2.0.0:
optionalDependencies: optionalDependencies:
source-map "~0.6.1" source-map "~0.6.1"
eslint-config-react-app@^7.0.0: eslint-config-react-app@^7.0.1:
version "7.0.0" version "7.0.1"
resolved "https://registry.yarnpkg.com/eslint-config-react-app/-/eslint-config-react-app-7.0.0.tgz#0fa96d5ec1dfb99c029b1554362ab3fa1c3757df" resolved "https://registry.yarnpkg.com/eslint-config-react-app/-/eslint-config-react-app-7.0.1.tgz#73ba3929978001c5c86274c017ea57eb5fa644b4"
integrity sha512-xyymoxtIt1EOsSaGag+/jmcywRuieQoA2JbPCjnw9HukFj9/97aGPoZVFioaotzk1K5Qt9sHO5EutZbkrAXS0g== integrity sha512-K6rNzvkIeHaTd8m/QEh1Zko0KI7BACWkkneSs6s9cKZC/J27X3eZR6Upt1jkmZ/4FK+XUOPPxMEN7+lbUXfSlA==
dependencies: dependencies:
"@babel/core" "^7.16.0" "@babel/core" "^7.16.0"
"@babel/eslint-parser" "^7.16.3" "@babel/eslint-parser" "^7.16.3"
@@ -6841,10 +6871,10 @@ react-app-polyfill@^3.0.0:
regenerator-runtime "^0.13.9" regenerator-runtime "^0.13.9"
whatwg-fetch "^3.6.2" whatwg-fetch "^3.6.2"
react-dev-utils@^12.0.0: react-dev-utils@^12.0.1:
version "12.0.0" version "12.0.1"
resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-12.0.0.tgz#4eab12cdb95692a077616770b5988f0adf806526" resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-12.0.1.tgz#ba92edb4a1f379bd46ccd6bcd4e7bc398df33e73"
integrity sha512-xBQkitdxozPxt1YZ9O1097EJiVpwHr9FoAuEVURCKV0Av8NBERovJauzP7bo1ThvuhZ4shsQ1AJiu4vQpoT1AQ== integrity sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==
dependencies: dependencies:
"@babel/code-frame" "^7.16.0" "@babel/code-frame" "^7.16.0"
address "^1.1.2" address "^1.1.2"
@@ -6865,25 +6895,24 @@ react-dev-utils@^12.0.0:
open "^8.4.0" open "^8.4.0"
pkg-up "^3.1.0" pkg-up "^3.1.0"
prompts "^2.4.2" prompts "^2.4.2"
react-error-overlay "^6.0.10" react-error-overlay "^6.0.11"
recursive-readdir "^2.2.2" recursive-readdir "^2.2.2"
shell-quote "^1.7.3" shell-quote "^1.7.3"
strip-ansi "^6.0.1" strip-ansi "^6.0.1"
text-table "^0.2.0" text-table "^0.2.0"
react-dom@^17.0.2: react-dom@^18.1.0:
version "17.0.2" version "18.1.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.1.0.tgz#7f6dd84b706408adde05e1df575b3a024d7e8a2f"
integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA== integrity sha512-fU1Txz7Budmvamp7bshe4Zi32d0ll7ect+ccxNu9FlObT605GOEB8BfO4tmRJ39R5Zj831VCpvQ05QPBW5yb+w==
dependencies: dependencies:
loose-envify "^1.1.0" loose-envify "^1.1.0"
object-assign "^4.1.1" scheduler "^0.22.0"
scheduler "^0.20.2"
react-error-overlay@^6.0.10: react-error-overlay@^6.0.11:
version "6.0.10" version "6.0.11"
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.10.tgz#0fe26db4fa85d9dbb8624729580e90e7159a59a6" resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb"
integrity sha512-mKR90fX7Pm5seCOfz8q9F+66VCc1PGsWSBxKbITjfKVQHMNF2zudxHnMdJiB1fRCb+XsbQV9sO9DCkgsMQgBIA== integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==
react-is@^16.13.1: react-is@^16.13.1:
version "16.13.1" version "16.13.1"
@@ -6900,10 +6929,10 @@ react-refresh@^0.11.0:
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.11.0.tgz#77198b944733f0f1f1a90e791de4541f9f074046" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.11.0.tgz#77198b944733f0f1f1a90e791de4541f9f074046"
integrity sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A== integrity sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==
react-scripts@5.0.0: react-scripts@5.0.1:
version "5.0.0" version "5.0.1"
resolved "https://registry.yarnpkg.com/react-scripts/-/react-scripts-5.0.0.tgz#6547a6d7f8b64364ef95273767466cc577cb4b60" resolved "https://registry.yarnpkg.com/react-scripts/-/react-scripts-5.0.1.tgz#6285dbd65a8ba6e49ca8d651ce30645a6d980003"
integrity sha512-3i0L2CyIlROz7mxETEdfif6Sfhh9Lfpzi10CtcGs1emDQStmZfWjJbAIMtRD0opVUjQuFWqHZyRZ9PPzKCFxWg== integrity sha512-8VAmEm/ZAwQzJ+GOMLbBsTdDKOpuZh7RPs0UymvBR2vRk4iZWCskjbFnxqjrzoIvlNNRZ3QJFx6/qDSi6zSnaQ==
dependencies: dependencies:
"@babel/core" "^7.16.0" "@babel/core" "^7.16.0"
"@pmmmwh/react-refresh-webpack-plugin" "^0.5.3" "@pmmmwh/react-refresh-webpack-plugin" "^0.5.3"
@@ -6921,7 +6950,7 @@ react-scripts@5.0.0:
dotenv "^10.0.0" dotenv "^10.0.0"
dotenv-expand "^5.1.0" dotenv-expand "^5.1.0"
eslint "^8.3.0" eslint "^8.3.0"
eslint-config-react-app "^7.0.0" eslint-config-react-app "^7.0.1"
eslint-webpack-plugin "^3.1.1" eslint-webpack-plugin "^3.1.1"
file-loader "^6.2.0" file-loader "^6.2.0"
fs-extra "^10.0.0" fs-extra "^10.0.0"
@@ -6938,7 +6967,7 @@ react-scripts@5.0.0:
postcss-preset-env "^7.0.1" postcss-preset-env "^7.0.1"
prompts "^2.4.2" prompts "^2.4.2"
react-app-polyfill "^3.0.0" react-app-polyfill "^3.0.0"
react-dev-utils "^12.0.0" react-dev-utils "^12.0.1"
react-refresh "^0.11.0" react-refresh "^0.11.0"
resolve "^1.20.0" resolve "^1.20.0"
resolve-url-loader "^4.0.0" resolve-url-loader "^4.0.0"
@@ -6955,13 +6984,12 @@ react-scripts@5.0.0:
optionalDependencies: optionalDependencies:
fsevents "^2.3.2" fsevents "^2.3.2"
react@^17.0.2: react@^18.1.0:
version "17.0.2" version "18.1.0"
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" resolved "https://registry.yarnpkg.com/react/-/react-18.1.0.tgz#6f8620382decb17fdc5cc223a115e2adbf104890"
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== integrity sha512-4oL8ivCz5ZEPyclFQXaNksK3adutVS8l2xzZU0cqEFrE9Sb7fC0EFK5uEk74wIreL1DERyjvsU915j1pcT2uEQ==
dependencies: dependencies:
loose-envify "^1.1.0" loose-envify "^1.1.0"
object-assign "^4.1.1"
readable-stream@^2.0.1: readable-stream@^2.0.1:
version "2.3.7" version "2.3.7"
@@ -7235,13 +7263,12 @@ saxes@^5.0.1:
dependencies: dependencies:
xmlchars "^2.2.0" xmlchars "^2.2.0"
scheduler@^0.20.2: scheduler@^0.22.0:
version "0.20.2" version "0.22.0"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.22.0.tgz#83a5d63594edf074add9a7198b1bae76c3db01b8"
integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ== integrity sha512-6QAm1BgQI88NPYymgGQLCZgvep4FyePDWFpXVK+zNSUgHwlqpJy8VEh8Et0KxTACS4VWwMousBElAZOH9nkkoQ==
dependencies: dependencies:
loose-envify "^1.1.0" loose-envify "^1.1.0"
object-assign "^4.1.1"
schema-utils@2.7.0: schema-utils@2.7.0:
version "2.7.0" version "2.7.0"
@@ -8156,10 +8183,10 @@ wbuf@^1.1.0, wbuf@^1.7.3:
dependencies: dependencies:
minimalistic-assert "^1.0.0" minimalistic-assert "^1.0.0"
web-vitals@^2.1.3: web-vitals@^2.1.4:
version "2.1.3" version "2.1.4"
resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-2.1.3.tgz#6dca59f41dbc3fcccdb889da06191b437b18f534" resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-2.1.4.tgz#76563175a475a5e835264d373704f9dde718290c"
integrity sha512-+ijpniAzcnQicXaXIN0/eHQAiV/jMt1oHGHTmz7VdAJPPkzzDhmoYPSpLgJTuFtUh+jCjxCoeTZPg7Ic+g8o7w== integrity sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg==
webidl-conversions@^4.0.2: webidl-conversions@^4.0.2:
version "4.0.2" version "4.0.2"

View File

@@ -0,0 +1,18 @@
{
"name": "Shopify Hydrogen",
"image": "mcr.microsoft.com/vscode/devcontainers/javascript-node:0-16",
"settings": {},
"extensions": [
"graphql.vscode-graphql",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode"
],
"forwardPorts": [3000],
"postCreateCommand": "yarn install",
"postStartCommand": "yarn dev",
"remoteUser": "node",
"features": {
"git": "latest"
}
}

View File

@@ -0,0 +1,8 @@
module.exports = {
extends: ['plugin:hydrogen/recommended', 'plugin:hydrogen/typescript'],
rules: {
'node/no-missing-import': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/naming-convention': 'off',
},
};

79
examples/hydrogen/.gitignore vendored Normal file
View File

@@ -0,0 +1,79 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# Serverless directories
.serverless/
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Vite output
dist
.vercel

View File

@@ -0,0 +1,50 @@
# Hydrogen
[Hydrogen](https://shopify.dev/custom-storefronts/hydrogen) is a React framework and SDK that you can use to build fast and dynamic Shopify custom storefronts.
## Deploy Your Own
Deploy your own Hydrogen project with Vercel.
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/vercel/vercel/tree/main/examples/hydrogen&template=hydrogen)
_Live Example: https://hydrogen-template.vercel.app_
## Getting started
**Requirements:**
- Node.js version 16.5.0 or higher
- Yarn
To create a new Hydrogen app, run:
```bash
npm init @shopify/hydrogen
```
## Running the dev server
Then `cd` into the new directory and run:
```bash
npm install
npm run dev
```
Remember to update `hydrogen.config.js` with your shop's domain and Storefront API token!
## Building for production
```bash
npm run build
```
## Previewing a production build
To run a local preview of your Hydrogen app in an environment similar to Oxygen, build your Hydrogen app and then run `npm run preview`:
```bash
npm run build
npm run preview
```

View File

@@ -0,0 +1,18 @@
import {defineConfig, CookieSessionStorage} from '@shopify/hydrogen/config';
export default defineConfig({
shopify: {
defaultCountryCode: 'US',
defaultLanguageCode: 'EN',
storeDomain: 'hydrogen-preview.myshopify.com',
storefrontToken: '3b580e70970c4528da70c98e097c2fa0',
storefrontApiVersion: '2022-07',
},
session: CookieSessionStorage('__session', {
path: '/',
httpOnly: true,
secure: import.meta.env.PROD,
sameSite: 'Strict',
maxAge: 60 * 60 * 24 * 30,
}),
});

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/assets/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hydrogen</title>
<link rel="stylesheet" href="/src/styles/index.css" />
<link rel="preconnect" href="https://cdn.shopify.com" />
<link rel="preconnect" href="https://shop.app/" />
<link rel="preconnect" href="https://hydrogen-preview.myshopify.com/" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/@shopify/hydrogen/entry-client"></script>
</body>
</html>

View File

@@ -0,0 +1,49 @@
{
"name": "hydrogen",
"description": "Demo store template for @shopify/hydrogen",
"version": "0.0.0",
"license": "MIT",
"private": true,
"scripts": {
"dev": "shopify hydrogen dev",
"build": "shopify hydrogen build",
"preview": "shopify hydrogen preview",
"lint": "eslint --ext .js,.jsx,.ts,.tsx src",
"lint-ts": "tsc --noEmit",
"test": "WATCH=true vitest",
"test:ci": "yarn build -t node && vitest run"
},
"devDependencies": {
"@shopify/cli": "3.0.27",
"@shopify/cli-hydrogen": "3.0.27",
"@shopify/prettier-config": "^1.1.2",
"@tailwindcss/forms": "^0.5.2",
"@tailwindcss/typography": "^0.5.2",
"@types/react": "^18.0.14",
"eslint": "^8.18.0",
"eslint-plugin-hydrogen": "^0.12.2",
"playwright": "^1.22.2",
"postcss": "^8.4.14",
"postcss-import": "^14.1.0",
"postcss-preset-env": "^7.6.0",
"prettier": "^2.3.2",
"tailwindcss": "^3.0.24",
"typescript": "^4.7.2",
"vite": "^2.9.0",
"vitest": "^0.15.2"
},
"prettier": "@shopify/prettier-config",
"dependencies": {
"@headlessui/react": "^1.6.4",
"@heroicons/react": "^1.0.6",
"@shopify/hydrogen": "^1.0.2",
"clsx": "^1.1.1",
"graphql-tag": "^2.12.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-use": "^17.4.0",
"title": "^3.4.4",
"typographic-base": "^1.0.4"
},
"author": "nrajlich"
}

View File

@@ -0,0 +1,10 @@
module.exports = {
plugins: {
'postcss-import': {},
'tailwindcss/nesting': {},
tailwindcss: {},
'postcss-preset-env': {
features: {'nesting-rules': false},
},
},
};

Binary file not shown.

View File

@@ -0,0 +1,48 @@
import {Suspense} from 'react';
import renderHydrogen from '@shopify/hydrogen/entry-server';
import {
FileRoutes,
type HydrogenRouteProps,
PerformanceMetrics,
PerformanceMetricsDebug,
Route,
Router,
ShopifyAnalytics,
ShopifyProvider,
CartProvider,
} from '@shopify/hydrogen';
import {HeaderFallback} from '~/components';
import type {CountryCode} from '@shopify/hydrogen/storefront-api-types';
import {DefaultSeo, NotFound} from '~/components/index.server';
function App({request}: HydrogenRouteProps) {
const pathname = new URL(request.normalizedUrl).pathname;
const localeMatch = /^\/([a-z]{2})(\/|$)/i.exec(pathname);
const countryCode = localeMatch ? (localeMatch[1] as CountryCode) : undefined;
const isHome = pathname === `/${countryCode ? countryCode + '/' : ''}`;
return (
<Suspense fallback={<HeaderFallback isHome={isHome} />}>
<ShopifyProvider countryCode={countryCode}>
<CartProvider countryCode={countryCode}>
<Suspense>
<DefaultSeo />
</Suspense>
<Router>
<FileRoutes
basePath={countryCode ? `/${countryCode}/` : undefined}
/>
<Route path="*" page={<NotFound />} />
</Router>
</CartProvider>
<PerformanceMetrics />
{import.meta.env.DEV && <PerformanceMetricsDebug />}
<ShopifyAnalytics />
</ShopifyProvider>
</Suspense>
);
}
export default renderHydrogen(App);

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,135 @@
import {useCallback, useState, Suspense} from 'react';
import {useLocalization, fetchSync} from '@shopify/hydrogen';
// @ts-expect-error @headlessui/react incompatibility with node16 resolution
import {Listbox} from '@headlessui/react';
import {IconCheck, IconCaret} from '~/components';
import {useMemo} from 'react';
import type {
Country,
CountryCode,
} from '@shopify/hydrogen/storefront-api-types';
/**
* A client component that selects the appropriate country to display for products on a website
*/
export function CountrySelector() {
const [listboxOpen, setListboxOpen] = useState(false);
const {
country: {isoCode},
} = useLocalization();
const currentCountry = useMemo<{name: string; isoCode: CountryCode}>(() => {
const regionNamesInEnglish = new Intl.DisplayNames(['en'], {
type: 'region',
});
return {
name: regionNamesInEnglish.of(isoCode)!,
isoCode: isoCode as CountryCode,
};
}, [isoCode]);
const setCountry = useCallback<(country: Country) => void>(
({isoCode: newIsoCode}) => {
const currentPath = window.location.pathname;
let redirectPath;
if (newIsoCode !== 'US') {
if (currentCountry.isoCode === 'US') {
redirectPath = `/${newIsoCode.toLowerCase()}${currentPath}`;
} else {
redirectPath = `/${newIsoCode.toLowerCase()}${currentPath.substring(
currentPath.indexOf('/', 1),
)}`;
}
} else {
redirectPath = `${currentPath.substring(currentPath.indexOf('/', 1))}`;
}
window.location.href = redirectPath;
},
[currentCountry],
);
return (
<div className="relative">
<Listbox onChange={setCountry}>
{/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
{({open}) => {
setTimeout(() => setListboxOpen(open));
return (
<>
<Listbox.Button
className={`flex items-center justify-between w-full py-3 px-4 border ${
open ? 'rounded-b md:rounded-t md:rounded-b-none' : 'rounded'
} border-contrast/30 dark:border-white`}
>
<span className="">{currentCountry.name}</span>
<IconCaret direction={open ? 'up' : 'down'} />
</Listbox.Button>
<Listbox.Options
className={`border-t-contrast/30 border-contrast/30 bg-primary dark:bg-contrast absolute bottom-12 z-10 grid
h-48 w-full overflow-y-scroll rounded-t border dark:border-white px-2 py-2
transition-[max-height] duration-150 sm:bottom-auto md:rounded-b md:rounded-t-none
md:border-t-0 md:border-b ${
listboxOpen ? 'max-h-48' : 'max-h-0'
}`}
>
{listboxOpen && (
<Suspense fallback={<div className="p-2">Loading</div>}>
{/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
<Countries
selectedCountry={currentCountry}
getClassName={(active) => {
return `text-contrast dark:text-primary bg-primary
dark:bg-contrast w-full p-2 transition rounded
flex justify-start items-center text-left cursor-pointer ${
active ? 'bg-primary/10' : null
}`;
}}
/>
</Suspense>
)}
</Listbox.Options>
</>
);
}}
</Listbox>
</div>
);
}
export function Countries({
selectedCountry,
getClassName,
}: {
selectedCountry: Pick<Country, 'isoCode' | 'name'>;
getClassName: (active: boolean) => string;
}) {
const countries: Country[] = fetchSync('/api/countries').json();
return (countries || []).map((country) => {
const isSelected = country.isoCode === selectedCountry.isoCode;
return (
<Listbox.Option key={country.isoCode} value={country}>
{/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
{({active}) => (
<div
className={`text-contrast dark:text-primary ${getClassName(
active,
)}`}
>
{country.name}
{isSelected ? (
<span className="ml-2">
<IconCheck />
</span>
) : null}
</div>
)}
</Listbox.Option>
);
});
}

View File

@@ -0,0 +1,22 @@
// When making building your custom storefront, you will most likely want to
// use custom fonts as well. These are often implemented without critical
// performance optimizations.
// Below, you'll find the markup needed to optimally render a pair of web fonts
// that we will use on our journal articles. This typeface, IBM Plex,
// can be found at: https://www.ibm.com/plex/, as well as on
// Google Fonts: https://fonts.google.com/specimen/IBM+Plex+Serif. We included
// these locally since youll most likely be using commercially licensed fonts.
// When implementing a custom font, specifying the Unicode range you need,
// and using `font-display: swap` will help you improve your performance.
// For fonts that appear in the critical rendering path, you can speed up
// performance even more by including a <link> tag in your HTML.
// In a production environment, you will likely want to include the below
// markup right in your index.html and index.css files.
import '../styles/custom-font.css';
export function CustomFont() {}

View File

@@ -0,0 +1,37 @@
import {CacheLong, gql, Seo, useShopQuery} from '@shopify/hydrogen';
/**
* A server component that fetches a `shop.name` and sets default values and templates for every page on a website
*/
export function DefaultSeo() {
const {
data: {
shop: {name, description},
},
} = useShopQuery({
query: SHOP_QUERY,
cache: CacheLong(),
preload: '*',
});
return (
// @ts-ignore TODO: Fix types
<Seo
type="defaultSeo"
data={{
title: name,
description,
titleTemplate: `%s · ${name}`,
}}
/>
);
}
const SHOP_QUERY = gql`
query shopInfo {
shop {
name
description
}
}
`;

View File

@@ -0,0 +1,30 @@
export function HeaderFallback({isHome}: {isHome?: boolean}) {
const styles = isHome
? 'bg-primary/80 dark:bg-contrast/60 text-contrast dark:text-primary shadow-darkHeader'
: 'bg-contrast/80 text-primary';
return (
<header
role="banner"
className={`${styles} flex h-nav items-center backdrop-blur-lg z-40 top-0 justify-between w-full leading-none gap-8 px-12 py-8`}
>
<div className="flex space-x-4">
<Box isHome={isHome} />
<Box isHome={isHome} />
<Box isHome={isHome} />
<Box isHome={isHome} />
<Box isHome={isHome} />
</div>
<Box isHome={isHome} wide={true} />
</header>
);
}
function Box({wide, isHome}: {wide?: boolean; isHome?: boolean}) {
return (
<div
className={`h-6 rounded-sm ${wide ? 'w-32' : 'w-16'} ${
isHome ? 'bg-primary/60' : 'bg-primary/20'
}`}
/>
);
}

View File

@@ -0,0 +1,183 @@
import {useState} from 'react';
import {useNavigate} from '@shopify/hydrogen/client';
export function AccountActivateForm({
id,
activationToken,
}: {
id: string;
activationToken: string;
}) {
const navigate = useNavigate();
const [submitError, setSubmitError] = useState<null | string>(null);
const [password, setPassword] = useState('');
const [passwordError, setPasswordError] = useState<null | string>(null);
const [passwordConfirm, setPasswordConfirm] = useState('');
const [passwordConfirmError, setPasswordConfirmError] = useState<
null | string
>(null);
function passwordValidation(
form: HTMLFormElement & {password: HTMLInputElement},
) {
setPasswordError(null);
setPasswordConfirmError(null);
let hasError = false;
if (!form.password.validity.valid) {
hasError = true;
setPasswordError(
form.password.validity.valueMissing
? 'Please enter a password'
: 'Passwords must be at least 6 characters',
);
}
if (!form.passwordConfirm.validity.valid) {
hasError = true;
setPasswordConfirmError(
form.password.validity.valueMissing
? 'Please re-enter a password'
: 'Passwords must be at least 6 characters',
);
}
if (password !== passwordConfirm) {
hasError = true;
setPasswordConfirmError('The two passwords entered did not match.');
}
return hasError;
}
async function onSubmit(
event: React.FormEvent<HTMLFormElement & {password: HTMLInputElement}>,
) {
event.preventDefault();
if (passwordValidation(event.currentTarget)) {
return;
}
const response = await callActivateApi({
id,
activationToken,
password,
});
if (response.error) {
setSubmitError(response.error);
return;
}
navigate('/account');
}
return (
<div className="flex justify-center">
<div className="w-full max-w-md">
<h1 className="text-4xl">Activate Account.</h1>
<p className="mt-4">Create your password to activate your account.</p>
<form noValidate className="pt-6 pb-8 mt-4 mb-4" onSubmit={onSubmit}>
{submitError && (
<div className="flex items-center justify-center mb-6 bg-primary/30">
<p className="m-4 text-s text-contrast">{submitError}</p>
</div>
)}
<div className="mb-4">
<input
className={`mb-1 appearance-none border w-full py-2 px-3 text-primary placeholder:text-primary/30 leading-tight focus:shadow-outline ${
passwordError ? ' border-notice' : 'border-primary'
}`}
id="password"
name="password"
type="password"
autoComplete="current-password"
placeholder="Password"
aria-label="Password"
value={password}
minLength={8}
required
onChange={(event) => {
setPassword(event.target.value);
}}
/>
<p
className={`text-red-500 text-xs ${
!passwordError ? 'invisible' : ''
}`}
>
{passwordError} &nbsp;
</p>
</div>
<div className="mb-4">
<input
className={`mb-1 appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline ${
passwordConfirmError ? ' border-red-500' : 'border-gray-900'
}`}
id="passwordConfirm"
name="passwordConfirm"
type="password"
autoComplete="current-password"
placeholder="Re-enter password"
aria-label="Re-enter password"
value={passwordConfirm}
required
minLength={8}
onChange={(event) => {
setPasswordConfirm(event.target.value);
}}
/>
<p
className={`text-red-500 text-xs ${
!passwordConfirmError ? 'invisible' : ''
}`}
>
{passwordConfirmError} &nbsp;
</p>
</div>
<div className="flex items-center justify-between">
<button
className="block w-full px-4 py-2 text-contrast uppercase bg-gray-900 focus:shadow-outline"
type="submit"
>
Save
</button>
</div>
</form>
</div>
</div>
);
}
async function callActivateApi({
id,
activationToken,
password,
}: {
id: string;
activationToken: string;
password: string;
}) {
try {
const res = await fetch(`/account/activate`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({id, activationToken, password}),
});
if (res.ok) {
return {};
} else {
return res.json();
}
} catch (error: any) {
return {
error: error.toString(),
};
}
}

View File

@@ -0,0 +1,161 @@
import {useState, useMemo, MouseEventHandler} from 'react';
import {Text, Button} from '~/components/elements';
import {Modal} from '../index';
import {AccountAddressEdit, AccountDeleteAddress} from '../index';
export function AccountAddressBook({
addresses,
defaultAddress,
}: {
addresses: any[];
defaultAddress: any;
}) {
const [editingAddress, setEditingAddress] = useState(null);
const [deletingAddress, setDeletingAddress] = useState(null);
const {fullDefaultAddress, addressesWithoutDefault} = useMemo(() => {
const defaultAddressIndex = addresses.findIndex(
(address) => address.id === defaultAddress,
);
return {
addressesWithoutDefault: [
...addresses.slice(0, defaultAddressIndex),
...addresses.slice(defaultAddressIndex + 1, addresses.length),
],
fullDefaultAddress: addresses[defaultAddressIndex],
};
}, [addresses, defaultAddress]);
function close() {
setEditingAddress(null);
setDeletingAddress(null);
}
function editAddress(address: any) {
setEditingAddress(address);
}
return (
<>
{deletingAddress ? (
<Modal close={close}>
<AccountDeleteAddress addressId={deletingAddress} close={close} />
</Modal>
) : null}
{editingAddress ? (
<Modal close={close}>
<AccountAddressEdit
address={editingAddress}
defaultAddress={fullDefaultAddress === editingAddress}
close={close}
/>
</Modal>
) : null}
<div className="grid w-full gap-4 p-4 py-6 md:gap-8 md:p-8 lg:p-12">
<h3 className="font-bold text-lead">Address Book</h3>
<div>
{!addresses?.length ? (
<Text className="mb-1" width="narrow" as="p" size="copy">
You haven&apos;t saved any addresses yet.
</Text>
) : null}
<div className="w-48">
<Button
className="mt-2 text-sm w-full mb-6"
onClick={() => {
editAddress({
/** empty address */
});
}}
variant="secondary"
>
Add an Address
</Button>
</div>
{addresses?.length ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
{fullDefaultAddress ? (
<Address
address={fullDefaultAddress}
defaultAddress
setDeletingAddress={setDeletingAddress.bind(
null,
fullDefaultAddress.originalId,
)}
editAddress={editAddress}
/>
) : null}
{addressesWithoutDefault.map((address) => (
<Address
key={address.id}
address={address}
setDeletingAddress={setDeletingAddress.bind(
null,
address.originalId,
)}
editAddress={editAddress}
/>
))}
</div>
) : null}
</div>
</div>
</>
);
}
function Address({
address,
defaultAddress,
editAddress,
setDeletingAddress,
}: {
address: any;
defaultAddress?: boolean;
editAddress: (address: any) => void;
setDeletingAddress: MouseEventHandler<HTMLButtonElement>;
}) {
return (
<div className="lg:p-8 p-6 border border-gray-200 rounded flex flex-col">
{defaultAddress ? (
<div className="mb-3 flex flex-row">
<span className="px-3 py-1 text-xs font-medium rounded-full bg-primary/20 text-primary/50">
Default
</span>
</div>
) : null}
<ul className="flex-1 flex-row">
{address.firstName || address.lastName ? (
<li>
{(address.firstName && address.firstName + ' ') + address.lastName}
</li>
) : (
<></>
)}
{address.formatted ? (
address.formatted.map((line: string) => <li key={line}>{line}</li>)
) : (
<></>
)}
</ul>
<div className="flex flex-row font-medium mt-6">
<button
onClick={() => {
editAddress(address);
}}
className="text-left underline text-sm"
>
Edit
</button>
<button
onClick={setDeletingAddress}
className="text-left text-primary/50 ml-6 text-sm"
>
Remove
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,337 @@
import {useMemo, useState} from 'react';
import {useRenderServerComponents} from '~/lib/utils';
import {Button, Text} from '~/components';
export function AccountAddressEdit({
address,
defaultAddress,
close,
}: {
address: any;
defaultAddress: boolean;
close: () => void;
}) {
const isNewAddress = useMemo(() => !Object.keys(address).length, [address]);
const [saving, setSaving] = useState(false);
const [submitError, setSubmitError] = useState<null | string>(null);
const [address1, setAddress1] = useState(address?.address1 || '');
const [address2, setAddress2] = useState(address?.address2 || '');
const [firstName, setFirstName] = useState(address?.firstName || '');
const [lastName, setLastName] = useState(address?.lastName || '');
const [company, setCompany] = useState(address?.company || '');
const [country, setCountry] = useState(address?.country || '');
const [province, setProvince] = useState(address?.province || '');
const [city, setCity] = useState(address?.city || '');
const [zip, setZip] = useState(address?.zip || '');
const [phone, setPhone] = useState(address?.phone || '');
const [isDefaultAddress, setIsDefaultAddress] = useState(defaultAddress);
// Necessary for edits to show up on the main page
const renderServerComponents = useRenderServerComponents();
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setSaving(true);
const response = await callUpdateAddressApi({
id: address?.originalId,
firstName,
lastName,
company,
address1,
address2,
country,
province,
city,
zip,
phone,
isDefaultAddress,
});
setSaving(false);
if (response.error) {
setSubmitError(response.error);
return;
}
renderServerComponents();
close();
}
return (
<>
<Text className="mt-4 mb-6" as="h3" size="lead">
{isNewAddress ? 'Add address' : 'Edit address'}
</Text>
<div className="max-w-lg">
<form noValidate onSubmit={onSubmit}>
{submitError && (
<div className="flex items-center justify-center mb-6 bg-red-100 rounded">
<p className="m-4 text-sm text-red-900">{submitError}</p>
</div>
)}
<div className="mt-3">
<input
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
id="firstname"
name="firstname"
required
type="text"
autoComplete="given-name"
placeholder="First name"
aria-label="First name"
value={firstName}
onChange={(event) => {
setFirstName(event.target.value);
}}
/>
</div>
<div className="mt-3">
<input
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
id="lastname"
name="lastname"
required
type="text"
autoComplete="family-name"
placeholder="Last name"
aria-label="Last name"
value={lastName}
onChange={(event) => {
setLastName(event.target.value);
}}
/>
</div>
<div className="mt-3">
<input
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
id="company"
name="company"
type="text"
autoComplete="organization"
placeholder="Company"
aria-label="Company"
value={company}
onChange={(event) => {
setCompany(event.target.value);
}}
/>
</div>
<div className="mt-3">
<input
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
id="street1"
name="street1"
type="text"
autoComplete="address-line1"
placeholder="Address line 1*"
required
aria-label="Address line 1"
value={address1}
onChange={(event) => {
setAddress1(event.target.value);
}}
/>
</div>
<div className="mt-3">
<input
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
id="address2"
name="address2"
type="text"
autoComplete="address-line2"
placeholder="Addresss line 2"
aria-label="Address line 2"
value={address2}
onChange={(event) => {
setAddress2(event.target.value);
}}
/>
</div>
<div className="mt-3">
<input
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
id="city"
name="city"
type="text"
required
autoComplete="address-level2"
placeholder="City"
aria-label="City"
value={city}
onChange={(event) => {
setCity(event.target.value);
}}
/>
</div>
<div className="mt-3">
<input
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
id="state"
name="state"
type="text"
autoComplete="address-level1"
placeholder="State / Province"
required
aria-label="State"
value={province}
onChange={(event) => {
setProvince(event.target.value);
}}
/>
</div>
<div className="mt-3">
<input
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
id="zip"
name="zip"
type="text"
autoComplete="postal-code"
placeholder="Zip / Postal Code"
required
aria-label="Zip"
value={zip}
onChange={(event) => {
setZip(event.target.value);
}}
/>
</div>
<div className="mt-3">
<input
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
id="country"
name="country"
type="text"
autoComplete="country-name"
placeholder="Country"
required
aria-label="Country"
value={country}
onChange={(event) => {
setCountry(event.target.value);
}}
/>
</div>
<div className="mt-3">
<input
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
id="phone"
name="phone"
type="tel"
autoComplete="tel"
placeholder="Phone"
aria-label="Phone"
value={phone}
onChange={(event) => {
setPhone(event.target.value);
}}
/>
</div>
<div className="mt-4">
<input
type="checkbox"
value=""
name="defaultAddress"
id="defaultAddress"
checked={isDefaultAddress}
className="border-gray-500 rounded-sm cursor-pointer border-1"
onChange={() => setIsDefaultAddress(!isDefaultAddress)}
/>
<label
className="inline-block ml-2 text-sm cursor-pointer"
htmlFor="defaultAddress"
>
Set as default address
</label>
</div>
<div className="mt-8">
<Button
className="w-full rounded focus:shadow-outline"
type="submit"
variant="primary"
disabled={saving}
>
Save
</Button>
</div>
<div>
<Button
className="w-full mt-2 rounded focus:shadow-outline"
variant="secondary"
onClick={close}
>
Cancel
</Button>
</div>
</form>
</div>
</>
);
}
export async function callUpdateAddressApi({
id,
firstName,
lastName,
company,
address1,
address2,
country,
province,
city,
phone,
zip,
isDefaultAddress,
}: {
id: string;
firstName: string;
lastName: string;
company: string;
address1: string;
address2: string;
country: string;
province: string;
city: string;
phone: string;
zip: string;
isDefaultAddress: boolean;
}) {
try {
const res = await fetch(
id ? `/account/address/${encodeURIComponent(id)}` : '/account/address',
{
method: id ? 'PATCH' : 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
firstName,
lastName,
company,
address1,
address2,
country,
province,
city,
phone,
zip,
isDefaultAddress,
}),
},
);
if (res.ok) {
return {};
} else {
return res.json();
}
} catch (_e) {
return {
error: 'Error saving address. Please try again.',
};
}
}

View File

@@ -0,0 +1,175 @@
import {useState} from 'react';
import {useNavigate, Link} from '@shopify/hydrogen/client';
import {emailValidation, passwordValidation} from '~/lib/utils';
import {callLoginApi} from './AccountLoginForm.client';
interface FormElements {
email: HTMLInputElement;
password: HTMLInputElement;
}
export function AccountCreateForm() {
const navigate = useNavigate();
const [submitError, setSubmitError] = useState<null | string>(null);
const [email, setEmail] = useState('');
const [emailError, setEmailError] = useState<null | string>(null);
const [password, setPassword] = useState('');
const [passwordError, setPasswordError] = useState<null | string>(null);
async function onSubmit(
event: React.FormEvent<HTMLFormElement & FormElements>,
) {
event.preventDefault();
setEmailError(null);
setPasswordError(null);
setSubmitError(null);
const newEmailError = emailValidation(event.currentTarget.email);
if (newEmailError) {
setEmailError(newEmailError);
}
const newPasswordError = passwordValidation(event.currentTarget.password);
if (newPasswordError) {
setPasswordError(newPasswordError);
}
if (newEmailError || newPasswordError) {
return;
}
const accountCreateResponse = await callAccountCreateApi({
email,
password,
});
if (accountCreateResponse.error) {
setSubmitError(accountCreateResponse.error);
return;
}
// this can be avoided if customerCreate mutation returns customerAccessToken
await callLoginApi({
email,
password,
});
navigate('/account');
}
return (
<div className="flex justify-center my-24 px-4">
<div className="max-w-md w-full">
<h1 className="text-4xl">Create an Account.</h1>
<form noValidate className="pt-6 pb-8 mt-4 mb-4" onSubmit={onSubmit}>
{submitError && (
<div className="flex items-center justify-center mb-6 bg-zinc-500">
<p className="m-4 text-s text-contrast">{submitError}</p>
</div>
)}
<div className="mb-3">
<input
className={`mb-1 appearance-none rounded border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline ${
emailError ? ' border-red-500' : 'border-gray-900'
}`}
id="email"
name="email"
type="email"
autoComplete="email"
required
placeholder="Email address"
aria-label="Email address"
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
value={email}
onChange={(event) => {
setEmail(event.target.value);
}}
/>
{!emailError ? (
''
) : (
<p className={`text-red-500 text-xs`}>{emailError} &nbsp;</p>
)}
</div>
<div className="mb-3">
<input
className={`mb-1 appearance-none rounded border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline ${
passwordError ? ' border-red-500' : 'border-gray-900'
}`}
id="password"
name="password"
type="password"
autoComplete="current-password"
placeholder="Password"
aria-label="Password"
value={password}
minLength={8}
required
onChange={(event) => {
setPassword(event.target.value);
}}
/>
{!passwordError ? (
''
) : (
<p className={`text-red-500 text-xs`}>{passwordError} &nbsp;</p>
)}
</div>
<div className="flex items-center justify-between">
<button
className="bg-gray-900 text-contrast rounded py-2 px-4 focus:shadow-outline block w-full"
type="submit"
>
Create Account
</button>
</div>
<div className="flex items-center mt-4">
<p className="align-baseline text-sm">
Already have an account? &nbsp;
<Link className="inline underline" to="/account">
Sign in
</Link>
</p>
</div>
</form>
</div>
</div>
);
}
export async function callAccountCreateApi({
email,
password,
firstName,
lastName,
}: {
email: string;
password: string;
firstName?: string;
lastName?: string;
}) {
try {
const res = await fetch(`/account/register`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({email, password, firstName, lastName}),
});
if (res.status === 200) {
return {};
} else {
return res.json();
}
} catch (error: any) {
return {
error: error.toString(),
};
}
}

View File

@@ -0,0 +1,72 @@
import {Text, Button} from '~/components/elements';
import {useRenderServerComponents} from '~/lib/utils';
export function AccountDeleteAddress({
addressId,
close,
}: {
addressId: string;
close: () => void;
}) {
// Necessary for edits to show up on the main page
const renderServerComponents = useRenderServerComponents();
async function deleteAddress(id: string) {
const response = await callDeleteAddressApi(id);
if (response.error) {
alert(response.error);
return;
}
renderServerComponents();
close();
}
return (
<>
<Text className="mb-4" as="h3" size="lead">
Confirm removal
</Text>
<Text as="p">Are you sure you wish to remove this address?</Text>
<div className="mt-6">
<Button
className="text-sm"
onClick={() => {
deleteAddress(addressId);
}}
variant="primary"
width="full"
>
Confirm
</Button>
<Button
className="text-sm mt-2"
onClick={close}
variant="secondary"
width="full"
>
Cancel
</Button>
</div>
</>
);
}
export async function callDeleteAddressApi(id: string) {
try {
const res = await fetch(`/account/address/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
},
});
if (res.ok) {
return {};
} else {
return res.json();
}
} catch (_e) {
return {
error: 'Error removing address. Please try again.',
};
}
}

View File

@@ -0,0 +1,66 @@
import {Seo} from '@shopify/hydrogen';
import {useState} from 'react';
import {Modal} from '../index';
import {AccountDetailsEdit} from './AccountDetailsEdit.client';
export function AccountDetails({
firstName,
lastName,
phone,
email,
}: {
firstName?: string;
lastName?: string;
phone?: string;
email?: string;
}) {
const [isEditing, setIsEditing] = useState(false);
const close = () => setIsEditing(false);
return (
<>
{isEditing ? (
<Modal close={close}>
<Seo type="noindex" data={{title: 'Account details'}} />
<AccountDetailsEdit
firstName={firstName}
lastName={lastName}
phone={phone}
email={email}
close={close}
/>
</Modal>
) : null}
<div className="grid w-full gap-4 p-4 py-6 md:gap-8 md:p-8 lg:p-12">
<h3 className="font-bold text-lead">Account Details</h3>
<div className="lg:p-8 p-6 border border-gray-200 rounded">
<div className="flex">
<h3 className="font-bold text-base flex-1">Profile & Security</h3>
<button
className="underline text-sm font-normal"
onClick={() => setIsEditing(true)}
>
Edit
</button>
</div>
<div className="mt-4 text-sm text-primary/50">Name</div>
<p className="mt-1">
{firstName || lastName
? (firstName ? firstName + ' ' : '') + lastName
: 'Add name'}{' '}
</p>
<div className="mt-4 text-sm text-primary/50">Contact</div>
<p className="mt-1">{phone ?? 'Add mobile'}</p>
<div className="mt-4 text-sm text-primary/50">Email address</div>
<p className="mt-1">{email}</p>
<div className="mt-4 text-sm text-primary/50">Password</div>
<p className="mt-1">**************</p>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,342 @@
import {useState} from 'react';
import {Text, Button} from '~/components';
import {
emailValidation,
passwordValidation,
useRenderServerComponents,
} from '~/lib/utils';
interface FormElements {
firstName: HTMLInputElement;
lastName: HTMLInputElement;
phone: HTMLInputElement;
email: HTMLInputElement;
currentPassword: HTMLInputElement;
newPassword: HTMLInputElement;
newPassword2: HTMLInputElement;
}
export function AccountDetailsEdit({
firstName: _firstName = '',
lastName: _lastName = '',
phone: _phone = '',
email: _email = '',
close,
}: {
firstName?: string;
lastName?: string;
phone?: string;
email?: string;
close: () => void;
}) {
const [saving, setSaving] = useState(false);
const [firstName, setFirstName] = useState(_firstName);
const [lastName, setLastName] = useState(_lastName);
const [phone, setPhone] = useState(_phone);
const [email, setEmail] = useState(_email);
const [emailError, setEmailError] = useState<null | string>(null);
const [currentPasswordError, setCurrentPasswordError] = useState<
null | string
>(null);
const [newPasswordError, setNewPasswordError] = useState<null | string>(null);
const [newPassword2Error, setNewPassword2Error] = useState<null | string>(
null,
);
const [submitError, setSubmitError] = useState<null | string>(null);
// Necessary for edits to show up on the main page
const renderServerComponents = useRenderServerComponents();
async function onSubmit(
event: React.FormEvent<HTMLFormElement & FormElements>,
) {
event.preventDefault();
setEmailError(null);
setCurrentPasswordError(null);
setNewPasswordError(null);
setNewPassword2Error(null);
const emailError = emailValidation(event.currentTarget.email);
if (emailError) {
setEmailError(emailError);
}
let currentPasswordError, newPasswordError, newPassword2Error;
// Only validate the password fields if the current password has a value
if (event.currentTarget.currentPassword.value) {
currentPasswordError = passwordValidation(
event.currentTarget.currentPassword,
);
if (currentPasswordError) {
setCurrentPasswordError(currentPasswordError);
}
newPasswordError = passwordValidation(event.currentTarget.newPassword);
if (newPasswordError) {
setNewPasswordError(newPasswordError);
}
newPassword2Error =
event.currentTarget.newPassword.value !==
event.currentTarget.newPassword2.value
? 'The two passwords entered did not match'
: null;
if (newPassword2Error) {
setNewPassword2Error(newPassword2Error);
}
}
if (
emailError ||
currentPasswordError ||
newPasswordError ||
newPassword2Error
) {
return;
}
setSaving(true);
const accountUpdateResponse = await callAccountUpdateApi({
email,
newPassword: event.currentTarget.newPassword.value,
currentPassword: event.currentTarget.currentPassword.value,
phone,
firstName,
lastName,
});
setSaving(false);
if (accountUpdateResponse.error) {
setSubmitError(accountUpdateResponse.error);
return;
}
renderServerComponents();
close();
}
return (
<>
<Text className="mt-4 mb-6" as="h3" size="lead">
Update your profile
</Text>
<form noValidate onSubmit={onSubmit}>
{submitError && (
<div className="flex items-center justify-center mb-6 bg-red-100 rounded">
<p className="m-4 text-sm text-red-900">{submitError}</p>
</div>
)}
<div className="mt-3">
<input
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
id="firstname"
name="firstname"
type="text"
autoComplete="given-name"
placeholder="First name"
aria-label="First name"
value={firstName}
onChange={(event) => {
setFirstName(event.target.value);
}}
/>
</div>
<div className="mt-3">
<input
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
id="lastname"
name="lastname"
type="text"
autoComplete="family-name"
placeholder="Last name"
aria-label="Last name"
value={lastName}
onChange={(event) => {
setLastName(event.target.value);
}}
/>
</div>
<div className="mt-3">
<input
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline border-gray-500 rounded`}
id="phone"
name="phone"
type="tel"
autoComplete="tel"
placeholder="Mobile"
aria-label="Mobile"
value={phone}
onChange={(event) => {
setPhone(event.target.value);
}}
/>
</div>
<div className="mt-3">
<input
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline rounded ${
emailError ? ' border-red-500' : 'border-gray-500'
}`}
id="email"
name="email"
type="email"
autoComplete="email"
required
placeholder="Email address"
aria-label="Email address"
value={email}
onChange={(event) => {
setEmail(event.target.value);
}}
/>
<p
className={`text-red-500 text-xs ${!emailError ? 'invisible' : ''}`}
>
{emailError} &nbsp;
</p>
</div>
<Text className="mb-6 mt-6" as="h3" size="lead">
Change your password
</Text>
<Password
name="currentPassword"
label="Current password"
passwordError={currentPasswordError}
/>
<Password
name="newPassword"
label="New password"
passwordError={newPasswordError}
/>
<Password
name="newPassword2"
label="Re-enter new password"
passwordError={newPassword2Error}
/>
<Text
size="fine"
color="subtle"
className={`mt-1 ${
currentPasswordError || newPasswordError ? 'text-red-500' : ''
}`}
>
Passwords must be at least 6 characters.
</Text>
{newPassword2Error ? <br /> : null}
<Text
size="fine"
className={`mt-1 text-red-500 ${
newPassword2Error ? '' : 'invisible'
}`}
>
{newPassword2Error} &nbsp;
</Text>
<div className="mt-6">
<Button
className="text-sm mb-2"
variant="primary"
width="full"
type="submit"
disabled={saving}
>
Save
</Button>
</div>
<div className="mb-4">
<Button
type="button"
className="text-sm"
variant="secondary"
width="full"
onClick={close}
>
Cancel
</Button>
</div>
</form>
</>
);
}
function Password({
name,
passwordError,
label,
}: {
name: string;
passwordError: string | null;
label: string;
}) {
const [password, setPassword] = useState('');
return (
<div className="mt-3">
<input
className={`appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline rounded ${
passwordError ? ' border-red-500' : 'border-gray-500'
}`}
id={name}
name={name}
type="password"
autoComplete={
name === 'currentPassword' ? 'current-password' : undefined
}
placeholder={label}
aria-label={label}
value={password}
minLength={8}
required
onChange={(event) => {
setPassword(event.target.value);
}}
/>
</div>
);
}
export async function callAccountUpdateApi({
email,
phone,
firstName,
lastName,
currentPassword,
newPassword,
}: {
email: string;
phone: string;
firstName: string;
lastName: string;
currentPassword: string;
newPassword: string;
}) {
try {
const res = await fetch(`/account`, {
method: 'PATCH',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
phone,
firstName,
lastName,
currentPassword,
newPassword,
}),
});
if (res.ok) {
return {};
} else {
return res.json();
}
} catch (_e) {
return {
error: 'Error saving account. Please try again.',
};
}
}

View File

@@ -0,0 +1,285 @@
import {useState} from 'react';
import {useNavigate, Link} from '@shopify/hydrogen/client';
interface FormElements {
email: HTMLInputElement;
password: HTMLInputElement;
}
export function AccountLoginForm({shopName}: {shopName: string}) {
const navigate = useNavigate();
const [hasSubmitError, setHasSubmitError] = useState(false);
const [showEmailField, setShowEmailField] = useState(true);
const [email, setEmail] = useState('');
const [emailError, setEmailError] = useState<null | string>(null);
const [password, setPassword] = useState('');
const [passwordError, setPasswordError] = useState<null | string>(null);
function onSubmit(event: React.FormEvent<HTMLFormElement & FormElements>) {
event.preventDefault();
setEmailError(null);
setHasSubmitError(false);
setPasswordError(null);
if (showEmailField) {
checkEmail(event);
} else {
checkPassword(event);
}
}
function checkEmail(event: React.FormEvent<HTMLFormElement & FormElements>) {
if (event.currentTarget.email.validity.valid) {
setShowEmailField(false);
} else {
setEmailError('Please enter a valid email');
}
}
async function checkPassword(
event: React.FormEvent<HTMLFormElement & FormElements>,
) {
const validity = event.currentTarget.password.validity;
if (validity.valid) {
const response = await callLoginApi({
email,
password,
});
if (response.error) {
setHasSubmitError(true);
resetForm();
} else {
navigate('/account');
}
} else {
setPasswordError(
validity.valueMissing
? 'Please enter a password'
: 'Passwords must be at least 6 characters',
);
}
}
function resetForm() {
setShowEmailField(true);
setEmail('');
setEmailError(null);
setPassword('');
setPasswordError(null);
}
return (
<div className="flex justify-center my-24 px-4">
<div className="max-w-md w-full">
<h1 className="text-4xl">Sign in.</h1>
<form noValidate className="pt-6 pb-8 mt-4 mb-4" onSubmit={onSubmit}>
{hasSubmitError && (
<div className="flex items-center justify-center mb-6 bg-zinc-500">
<p className="m-4 text-s text-contrast">
Sorry we did not recognize either your email or password. Please
try to sign in again or create a new account.
</p>
</div>
)}
{showEmailField && (
<EmailField
shopName={shopName}
email={email}
setEmail={setEmail}
emailError={emailError}
/>
)}
{!showEmailField && (
<ValidEmail email={email} resetForm={resetForm} />
)}
{!showEmailField && (
<PasswordField
password={password}
setPassword={setPassword}
passwordError={passwordError}
/>
)}
</form>
</div>
</div>
);
}
export async function callLoginApi({
email,
password,
}: {
email: string;
password: string;
}) {
try {
const res = await fetch(`/account/login`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({email, password}),
});
if (res.ok) {
return {};
} else {
return res.json();
}
} catch (error: any) {
return {
error: error.toString(),
};
}
}
function EmailField({
email,
setEmail,
emailError,
shopName,
}: {
email: string;
setEmail: (email: string) => void;
emailError: null | string;
shopName: string;
}) {
return (
<>
<div className="mb-3">
<input
className={`mb-1 appearance-none rounded border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline ${
emailError ? ' border-red-500' : 'border-gray-900'
}`}
id="email"
name="email"
type="email"
autoComplete="email"
required
placeholder="Email address"
aria-label="Email address"
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
value={email}
onChange={(event) => {
setEmail(event.target.value);
}}
/>
{!emailError ? (
''
) : (
<p className={`text-red-500 text-xs`}>{emailError} &nbsp;</p>
)}
</div>
<div className="flex items-center justify-between">
<button
className="bg-gray-900 rounded text-contrast py-2 px-4 focus:shadow-outline block w-full"
type="submit"
>
Next
</button>
</div>
<div className="flex items-center mt-8 border-t border-gray-300">
<p className="align-baseline text-sm mt-6">
New to {shopName}? &nbsp;
<Link className="inline underline" to="/account/register">
Create an account
</Link>
</p>
</div>
</>
);
}
function ValidEmail({
email,
resetForm,
}: {
email: string;
resetForm: () => void;
}) {
return (
<div className="mb-3 flex items-center justify-between">
<div>
<p>{email}</p>
<input
className="hidden"
type="text"
autoComplete="username"
value={email}
readOnly
></input>
</div>
<div>
<button
className="inline-block align-baseline text-sm underline"
type="button"
onClick={resetForm}
>
Change email
</button>
</div>
</div>
);
}
function PasswordField({
password,
setPassword,
passwordError,
}: {
password: string;
setPassword: (password: string) => void;
passwordError: null | string;
}) {
return (
<>
<div className="mb-3">
<input
className={`mb-1 appearance-none rounded border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline ${
passwordError ? ' border-red-500' : 'border-gray-900'
}`}
id="password"
name="password"
type="password"
autoComplete="current-password"
placeholder="Password"
aria-label="Password"
value={password}
minLength={8}
required
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
onChange={(event) => {
setPassword(event.target.value);
}}
/>
{!passwordError ? (
''
) : (
<p className={`text-red-500 text-xs`}> {passwordError} &nbsp;</p>
)}
</div>
<div className="flex items-center justify-between">
<button
className="bg-gray-900 text-contrast rounded py-2 px-4 focus:shadow-outline block w-full"
type="submit"
>
Sign in
</button>
</div>
<div className="flex items-center justify-between mt-4">
<div className="flex-1"></div>
<Link
className="inline-block align-baseline text-sm text-primary/50"
to="/account/recover"
>
Forgot password
</Link>
</div>
</>
);
}

View File

@@ -0,0 +1,38 @@
import type {Order} from '@shopify/hydrogen/storefront-api-types';
import {Button, Text, OrderCard} from '~/components';
export function AccountOrderHistory({orders}: {orders: Order[]}) {
return (
<div className="mt-6">
<div className="grid w-full gap-4 p-4 py-6 md:gap-8 md:p-8 lg:p-12">
<h2 className="font-bold text-lead">Order History</h2>
{orders?.length ? <Orders orders={orders} /> : <EmptyOrders />}
</div>
</div>
);
}
function EmptyOrders() {
return (
<div>
<Text className="mb-1" size="fine" width="narrow" as="p">
You haven&apos;t placed any orders yet.
</Text>
<div className="w-48">
<Button className="text-sm mt-2 w-full" variant="secondary" to={'/'}>
Start Shopping
</Button>
</div>
</div>
);
}
function Orders({orders}: {orders: Order[]}) {
return (
<ul className="grid-flow-row grid gap-2 gap-y-6 md:gap-4 lg:gap-6 grid-cols-1 false sm:grid-cols-3">
{orders.map((order) => (
<OrderCard order={order} key={order.id} />
))}
</ul>
);
}

View File

@@ -0,0 +1,189 @@
import {useState} from 'react';
import {useNavigate} from '@shopify/hydrogen/client';
interface FormElements {
password: HTMLInputElement;
passwordConfirm: HTMLInputElement;
}
export function AccountPasswordResetForm({
id,
resetToken,
}: {
id: string;
resetToken: string;
}) {
const navigate = useNavigate();
const [submitError, setSubmitError] = useState<string | null>(null);
const [password, setPassword] = useState('');
const [passwordError, setPasswordError] = useState<string | null>(null);
const [passwordConfirm, setPasswordConfirm] = useState('');
const [passwordConfirmError, setPasswordConfirmError] = useState<
string | null
>(null);
function passwordValidation(form: HTMLFormElement & FormElements) {
setPasswordError(null);
setPasswordConfirmError(null);
let hasError = false;
if (!form.password.validity.valid) {
hasError = true;
setPasswordError(
form.password.validity.valueMissing
? 'Please enter a password'
: 'Passwords must be at least 6 characters',
);
}
if (!form.passwordConfirm.validity.valid) {
hasError = true;
setPasswordConfirmError(
form.password.validity.valueMissing
? 'Please re-enter a password'
: 'Passwords must be at least 6 characters',
);
}
if (password !== passwordConfirm) {
hasError = true;
setPasswordConfirmError('The two password entered did not match.');
}
return hasError;
}
async function onSubmit(
event: React.FormEvent<HTMLFormElement & FormElements>,
) {
event.preventDefault();
if (passwordValidation(event.currentTarget)) {
return;
}
const response = await callPasswordResetApi({
id,
resetToken,
password,
});
if (response.error) {
setSubmitError(response.error);
return;
}
navigate('/account');
}
return (
<div className="flex justify-center my-24 px-4">
<div className="max-w-md w-full">
<h1 className="text-4xl">Reset Password.</h1>
<p className="mt-4">Enter a new password for your account.</p>
<form noValidate className="pt-6 pb-8 mt-4 mb-4" onSubmit={onSubmit}>
{submitError && (
<div className="flex items-center justify-center mb-6 bg-zinc-500">
<p className="m-4 text-s text-contrast">{submitError}</p>
</div>
)}
<div className="mb-3">
<input
className={`mb-1 appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline ${
passwordError ? ' border-red-500' : 'border-gray-900'
}`}
id="password"
name="password"
type="password"
autoComplete="current-password"
placeholder="Password"
aria-label="Password"
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
value={password}
minLength={8}
required
onChange={(event) => {
setPassword(event.target.value);
}}
/>
<p
className={`text-red-500 text-xs ${
!passwordError ? 'invisible' : ''
}`}
>
{passwordError} &nbsp;
</p>
</div>
<div className="mb-3">
<input
className={`mb-1 appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline ${
passwordConfirmError ? ' border-red-500' : 'border-gray-900'
}`}
id="passwordConfirm"
name="passwordConfirm"
type="password"
autoComplete="current-password"
placeholder="Re-enter password"
aria-label="Re-enter password"
value={passwordConfirm}
required
minLength={8}
onChange={(event) => {
setPasswordConfirm(event.target.value);
}}
/>
<p
className={`text-red-500 text-xs ${
!passwordConfirmError ? 'invisible' : ''
}`}
>
{passwordConfirmError} &nbsp;
</p>
</div>
<div className="flex items-center justify-between">
<button
className="bg-gray-900 text-contrast rounded py-2 px-4 focus:shadow-outline block w-full"
type="submit"
>
Save
</button>
</div>
</form>
</div>
</div>
);
}
export async function callPasswordResetApi({
id,
resetToken,
password,
}: {
id: string;
resetToken: string;
password: string;
}) {
try {
const res = await fetch(`/account/reset`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({id, resetToken, password}),
});
if (res.ok) {
return {};
} else {
return res.json();
}
} catch (error: any) {
return {
error: error.toString(),
};
}
}

View File

@@ -0,0 +1,134 @@
import {useState} from 'react';
import {emailValidation} from '~/lib/utils';
interface FormElements {
email: HTMLInputElement;
}
export function AccountRecoverForm() {
const [submitSuccess, setSubmitSuccess] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [email, setEmail] = useState('');
const [emailError, setEmailError] = useState<string | null>(null);
async function onSubmit(
event: React.FormEvent<HTMLFormElement & FormElements>,
) {
event.preventDefault();
setEmailError(null);
setSubmitError(null);
const newEmailError = emailValidation(event.currentTarget.email);
if (newEmailError) {
setEmailError(newEmailError);
return;
}
await callAccountRecoverApi({
email,
});
setEmail('');
setSubmitSuccess(true);
}
return (
<div className="flex justify-center my-24 px-4">
<div className="max-w-md w-full">
{submitSuccess ? (
<>
<h1 className="text-4xl">Request Sent.</h1>
<p className="mt-4">
If that email address is in our system, you will receive an email
with instructions about how to reset your password in a few
minutes.
</p>
</>
) : (
<>
<h1 className="text-4xl">Forgot Password.</h1>
<p className="mt-4">
Enter the email address associated with your account to receive a
link to reset your password.
</p>
</>
)}
<form noValidate className="pt-6 pb-8 mt-4 mb-4" onSubmit={onSubmit}>
{submitError && (
<div className="flex items-center justify-center mb-6 bg-zinc-500">
<p className="m-4 text-s text-contrast">{submitError}</p>
</div>
)}
<div className="mb-3">
<input
className={`mb-1 rounded appearance-none border w-full py-2 px-3 text-primary/90 placeholder:text-primary/50 leading-tight focus:shadow-outline ${
emailError ? ' border-red-500' : 'border-gray-900'
}`}
id="email"
name="email"
type="email"
autoComplete="email"
required
placeholder="Email address"
aria-label="Email address"
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
value={email}
onChange={(event) => {
setEmail(event.target.value);
}}
/>
{!emailError ? (
''
) : (
<p className={`text-red-500 text-xs`}>{emailError} &nbsp;</p>
)}
</div>
<div className="flex items-center justify-between">
<button
className="bg-gray-900 text-contrast rounded py-2 px-4 focus:shadow-outline block w-full"
type="submit"
>
Request Reset Link
</button>
</div>
</form>
</div>
</div>
);
}
export async function callAccountRecoverApi({
email,
password,
firstName,
lastName,
}: {
email: string;
password?: string;
firstName?: string;
lastName?: string;
}) {
try {
const res = await fetch(`/account/recover`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({email, password, firstName, lastName}),
});
if (res.status === 200) {
return {};
} else {
return res.json();
}
} catch (error: any) {
return {
error: error.toString(),
};
}
}

View File

@@ -0,0 +1,11 @@
export {AccountActivateForm} from './AccountActivateForm.client';
export {AccountAddressBook} from './AccountAddressBook.client';
export {AccountAddressEdit} from './AccountAddressEdit.client';
export {AccountCreateForm} from './AccountCreateForm.client';
export {AccountDeleteAddress} from './AccountDeleteAddress.client';
export {AccountDetails} from './AccountDetails.client';
export {AccountDetailsEdit} from './AccountDetailsEdit.client';
export {AccountLoginForm} from './AccountLoginForm.client';
export {AccountOrderHistory} from './AccountOrderHistory.client';
export {AccountPasswordResetForm} from './AccountPasswordResetForm.client';
export {AccountRecoverForm} from './AccountRecoverForm.client';

View File

@@ -0,0 +1,38 @@
import {Image, Link} from '@shopify/hydrogen';
import type {Article} from '@shopify/hydrogen/storefront-api-types';
export function ArticleCard({
blogHandle,
article,
loading,
}: {
blogHandle: string;
article: Article;
loading?: HTMLImageElement['loading'];
}) {
return (
<li key={article.id}>
<Link to={`/${blogHandle}/${article.handle}`}>
{article.image && (
<div className="card-image aspect-[3/2]">
<Image
alt={article.image.altText || article.title}
className="object-cover w-full"
data={article.image}
height={400}
loading={loading}
sizes="(min-width: 768px) 50vw, 100vw"
width={600}
loaderOptions={{
scale: 2,
crop: 'center',
}}
/>
</div>
)}
<h2 className="mt-4 font-medium">{article.title}</h2>
<span className="block mt-1">{article.publishedAt}</span>
</Link>
</li>
);
}

View File

@@ -0,0 +1,36 @@
import {Image, Link} from '@shopify/hydrogen';
import type {Collection} from '@shopify/hydrogen/storefront-api-types';
import {Heading} from '~/components';
export function CollectionCard({
collection,
loading,
}: {
collection: Collection;
loading?: HTMLImageElement['loading'];
}) {
return (
<Link to={`/collections/${collection.handle}`} className="grid gap-4">
<div className="card-image bg-primary/5 aspect-[3/2]">
{collection?.image && (
<Image
alt={`Image of ${collection.title}`}
data={collection.image}
height={400}
sizes="(max-width: 32em) 100vw, 33vw"
width={600}
widths={[400, 500, 600, 700, 800, 900]}
loaderOptions={{
scale: 2,
crop: 'center',
}}
/>
)}
</div>
<Heading as="h3" size="copy">
{collection.title}
</Heading>
</Link>
);
}

View File

@@ -0,0 +1,87 @@
import {Image, Link, flattenConnection} from '@shopify/hydrogen';
import type {
Order,
OrderLineItem,
} from '@shopify/hydrogen/storefront-api-types';
import {Heading, Text} from '~/components';
import {statusMessage} from '~/lib/utils';
export function OrderCard({order}: {order: Order}) {
if (!order?.id) return null;
const legacyOrderId = order!.id!.split('/').pop()!.split('?')[0];
const lineItems = flattenConnection<OrderLineItem>(order?.lineItems);
return (
<li className="grid text-center border rounded">
<Link
className="grid items-center gap-4 p-4 md:gap-6 md:p-6 md:grid-cols-2"
to={`/account/orders/${legacyOrderId}`}
>
{lineItems[0].variant?.image && (
<div className="card-image aspect-square bg-primary/5">
<Image
width={168}
height={168}
widths={[168]}
className="w-full fadeIn cover"
alt={lineItems[0].variant?.image?.altText ?? 'Order image'}
// @ts-expect-error Stock line item variant image type has `url` as optional
data={lineItems[0].variant?.image}
loaderOptions={{scale: 2, crop: 'center'}}
/>
</div>
)}
<div
className={`flex-col justify-center text-left ${
!lineItems[0].variant?.image && 'md:col-span-2'
}`}
>
<Heading as="h3" format size="copy">
{lineItems.length > 1
? `${lineItems[0].title} +${lineItems.length - 1} more`
: lineItems[0].title}
</Heading>
<dl className="grid grid-gap-1">
<dt className="sr-only">Order ID</dt>
<dd>
<Text size="fine" color="subtle">
Order No. {order.orderNumber}
</Text>
</dd>
<dt className="sr-only">Order Date</dt>
<dd>
<Text size="fine" color="subtle">
{new Date(order.processedAt).toDateString()}
</Text>
</dd>
<dt className="sr-only">Fulfillment Status</dt>
<dd className="mt-2">
<span
className={`px-3 py-1 text-xs font-medium rounded-full ${
order.fulfillmentStatus === 'FULFILLED'
? 'bg-green-100 text-green-800'
: 'bg-primary/5 text-primary/50'
}`}
>
<Text size="fine">
{statusMessage(order.fulfillmentStatus)}
</Text>
</span>
</dd>
</dl>
</div>
</Link>
<div className="self-end border-t">
<Link
className="block w-full p-2 text-center"
to={`/account/orders/${legacyOrderId}`}
>
<Text color="subtle" className="ml-3">
View Details
</Text>
</Link>
</div>
</li>
);
}

View File

@@ -0,0 +1,126 @@
import clsx from 'clsx';
import {
flattenConnection,
Image,
Link,
Money,
useMoney,
} from '@shopify/hydrogen';
import {Text} from '~/components';
import {isDiscounted, isNewArrival} from '~/lib/utils';
import {getProductPlaceholder} from '~/lib/placeholders';
import type {
MoneyV2,
Product,
ProductVariant,
ProductVariantConnection,
} from '@shopify/hydrogen/storefront-api-types';
export function ProductCard({
product,
label,
className,
loading,
onClick,
}: {
product: Product;
label?: string;
className?: string;
loading?: HTMLImageElement['loading'];
onClick?: () => void;
}) {
let cardLabel;
const cardData = product?.variants ? product : getProductPlaceholder();
const {
image,
priceV2: price,
compareAtPriceV2: compareAtPrice,
} = flattenConnection<ProductVariant>(
cardData?.variants as ProductVariantConnection,
)[0] || {};
if (label) {
cardLabel = label;
} else if (isDiscounted(price as MoneyV2, compareAtPrice as MoneyV2)) {
cardLabel = 'Sale';
} else if (isNewArrival(product.publishedAt)) {
cardLabel = 'New';
}
const styles = clsx('grid gap-6', className);
return (
<Link onClick={onClick} to={`/products/${product.handle}`}>
<div className={styles}>
<div className="card-image aspect-[4/5] bg-primary/5">
<Text
as="label"
size="fine"
className="absolute top-0 right-0 m-4 text-right text-notice"
>
{cardLabel}
</Text>
{image && (
<Image
className="aspect-[4/5] w-full object-cover fadeIn"
widths={[320]}
sizes="320px"
loaderOptions={{
crop: 'center',
scale: 2,
width: 320,
height: 400,
}}
// @ts-ignore Stock type has `src` as optional
data={image}
alt={image.altText || `Picture of ${product.title}`}
loading={loading}
/>
)}
</div>
<div className="grid gap-1">
<Text
className="w-full overflow-hidden whitespace-nowrap text-ellipsis "
as="h3"
>
{product.title}
</Text>
<div className="flex gap-4">
<Text className="flex gap-4">
<Money withoutTrailingZeros data={price!} />
{isDiscounted(price as MoneyV2, compareAtPrice as MoneyV2) && (
<CompareAtPrice
className={'opacity-50'}
data={compareAtPrice as MoneyV2}
/>
)}
</Text>
</div>
</div>
</div>
</Link>
);
}
function CompareAtPrice({
data,
className,
}: {
data: MoneyV2;
className?: string;
}) {
const {currencyNarrowSymbol, withoutTrailingZerosAndCurrency} =
useMoney(data);
const styles = clsx('strike', className);
return (
<span className={styles}>
{currencyNarrowSymbol}
{withoutTrailingZerosAndCurrency}
</span>
);
}

View File

@@ -0,0 +1 @@
export {CollectionCard} from './CollectionCard.server';

View File

@@ -0,0 +1,3 @@
export {ArticleCard} from './ArticleCard';
export {OrderCard} from './OrderCard.client';
export {ProductCard} from './ProductCard.client';

View File

@@ -0,0 +1,100 @@
import {useRef} from 'react';
import {useScroll} from 'react-use';
import {
useCart,
CartLineProvider,
CartShopPayButton,
Money,
} from '@shopify/hydrogen';
import {Button, Text, CartLineItem, CartEmpty} from '~/components';
export function CartDetails({
layout,
onClose,
}: {
layout: 'drawer' | 'page';
onClose?: () => void;
}) {
const {lines} = useCart();
const scrollRef = useRef(null);
const {y} = useScroll(scrollRef);
if (lines.length === 0) {
return <CartEmpty onClose={onClose} layout={layout} />;
}
const container = {
drawer: 'grid grid-cols-1 h-screen-no-nav grid-rows-[1fr_auto]',
page: 'pb-12 grid md:grid-cols-2 md:items-start gap-8 md:gap-8 lg:gap-12',
};
const content = {
drawer: 'px-6 pb-6 sm-max:pt-2 overflow-auto transition md:px-12',
page: 'flex-grow md:translate-y-4',
};
const summary = {
drawer: 'grid gap-6 p-6 border-t md:px-12',
page: 'sticky top-nav grid gap-6 p-4 md:px-6 md:translate-y-4 bg-primary/5 rounded w-full',
};
return (
<form className={container[layout]}>
<section
ref={scrollRef}
aria-labelledby="cart-contents"
className={`${content[layout]} ${y > 0 ? 'border-t' : ''}`}
>
<ul className="grid gap-6 md:gap-10">
{lines.map((line) => {
return (
<CartLineProvider key={line.id} line={line}>
<CartLineItem />
</CartLineProvider>
);
})}
</ul>
</section>
<section aria-labelledby="summary-heading" className={summary[layout]}>
<h2 id="summary-heading" className="sr-only">
Order summary
</h2>
<OrderSummary />
<CartCheckoutActions />
</section>
</form>
);
}
function CartCheckoutActions() {
const {checkoutUrl} = useCart();
return (
<>
<div className="grid gap-4">
<Button to={checkoutUrl}>Continue to Checkout</Button>
<CartShopPayButton />
</div>
</>
);
}
function OrderSummary() {
const {cost} = useCart();
return (
<>
<dl className="grid">
<div className="flex items-center justify-between font-medium">
<Text as="dt">Subtotal</Text>
<Text as="dd">
{cost?.subtotalAmount?.amount ? (
<Money data={cost?.subtotalAmount} />
) : (
'-'
)}
</Text>
</div>
</dl>
</>
);
}

View File

@@ -0,0 +1,85 @@
import {useRef} from 'react';
import {useScroll} from 'react-use';
import {fetchSync} from '@shopify/hydrogen';
import {Button, Text, ProductCard, Heading, Skeleton} from '~/components';
import type {Product} from '@shopify/hydrogen/storefront-api-types';
import {Suspense} from 'react';
export function CartEmpty({
onClose,
layout = 'drawer',
}: {
onClose?: () => void;
layout?: 'page' | 'drawer';
}) {
const scrollRef = useRef(null);
const {y} = useScroll(scrollRef);
const container = {
drawer: `grid content-start gap-4 px-6 pb-8 transition overflow-y-scroll md:gap-12 md:px-12 h-screen-no-nav md:pb-12 ${
y > 0 ? 'border-t' : ''
}`,
page: `grid pb-12 w-full md:items-start gap-4 md:gap-8 lg:gap-12`,
};
const topProductsContainer = {
drawer: '',
page: 'md:grid-cols-4 sm:grid-col-4',
};
return (
<div ref={scrollRef} className={container[layout]}>
<section className="grid gap-6">
<Text format>
Looks like you haven&rsquo;t added anything yet, let&rsquo;s get you
started!
</Text>
<div>
<Button onClick={onClose}>Continue shopping</Button>
</div>
</section>
<section className="grid gap-8 pt-4">
<Heading format size="copy">
Shop Best Sellers
</Heading>
<div
className={`grid grid-cols-2 gap-x-6 gap-y-8 ${topProductsContainer[layout]}`}
>
<Suspense fallback={<Loading />}>
<TopProducts onClose={onClose} />
</Suspense>
</div>
</section>
</div>
);
}
function TopProducts({onClose}: {onClose?: () => void}) {
const products: Product[] = fetchSync('/api/bestSellers').json();
if (products.length === 0) {
return <Text format>No products found.</Text>;
}
return (
<>
{products.map((product) => (
<ProductCard product={product} key={product.id} onClick={onClose} />
))}
</>
);
}
function Loading() {
return (
<>
{[...new Array(4)].map((_, i) => (
// eslint-disable-next-line react/no-array-index-key
<div key={i} className="grid gap-2">
<Skeleton className="aspect-[3/4]" />
<Skeleton className="w-32 h-4" />
</div>
))}
</>
);
}

View File

@@ -0,0 +1,103 @@
import {
useCart,
useCartLine,
CartLineQuantityAdjustButton,
CartLinePrice,
CartLineQuantity,
Image,
Link,
} from '@shopify/hydrogen';
import type {Image as ImageType} from '@shopify/hydrogen/storefront-api-types';
import {Heading, IconRemove, Text} from '~/components';
export function CartLineItem() {
const {linesRemove} = useCart();
const {id: lineId, quantity, merchandise} = useCartLine();
return (
<li key={lineId} className="flex gap-4">
<div className="flex-shrink">
<Image
width={112}
height={112}
widths={[112]}
data={merchandise.image as ImageType}
loaderOptions={{
scale: 2,
crop: 'center',
}}
className="object-cover object-center w-24 h-24 border rounded md:w-28 md:h-28"
/>
</div>
<div className="flex justify-between flex-grow">
<div className="grid gap-2">
<Heading as="h3" size="copy">
<Link to={`/products/${merchandise.product.handle}`}>
{merchandise.product.title}
</Link>
</Heading>
<div className="grid pb-2">
{(merchandise?.selectedOptions || []).map((option) => (
<Text color="subtle" key={option.name}>
{option.name}: {option.value}
</Text>
))}
</div>
<div className="flex items-center gap-2">
<div className="flex justify-start text-copy">
<CartLineQuantityAdjust lineId={lineId} quantity={quantity} />
</div>
<button
type="button"
onClick={() => linesRemove([lineId])}
className="flex items-center justify-center w-10 h-10 border rounded"
>
<span className="sr-only">Remove</span>
<IconRemove aria-hidden="true" />
</button>
</div>
</div>
<Text>
<CartLinePrice as="span" />
</Text>
</div>
</li>
);
}
function CartLineQuantityAdjust({
lineId,
quantity,
}: {
lineId: string;
quantity: number;
}) {
return (
<>
<label htmlFor={`quantity-${lineId}`} className="sr-only">
Quantity, {quantity}
</label>
<div className="flex items-center border rounded">
<CartLineQuantityAdjustButton
adjust="decrease"
aria-label="Decrease quantity"
className="w-10 h-10 transition text-primary/50 hover:text-primary disabled:cursor-wait"
>
&#8722;
</CartLineQuantityAdjustButton>
<CartLineQuantity as="div" className="px-2 text-center" />
<CartLineQuantityAdjustButton
adjust="increase"
aria-label="Increase quantity"
className="w-10 h-10 transition text-primary/50 hover:text-primary disabled:cursor-wait"
>
&#43;
</CartLineQuantityAdjustButton>
</div>
</>
);
}

View File

@@ -0,0 +1,3 @@
export {CartDetails} from './CartDetails.client';
export {CartEmpty} from './CartEmpty.client';
export {CartLineItem} from './CartLineItem.client';

View File

@@ -0,0 +1,42 @@
import clsx from 'clsx';
import {Link} from '@shopify/hydrogen';
import {missingClass} from '~/lib/utils';
export function Button({
as = 'button',
className = '',
variant = 'primary',
width = 'auto',
...props
}: {
as?: React.ElementType;
className?: string;
variant?: 'primary' | 'secondary' | 'inline';
width?: 'auto' | 'full';
[key: string]: any;
}) {
const Component = props?.to ? Link : as;
const baseButtonClasses =
'inline-block rounded font-medium text-center py-3 px-6';
const variants = {
primary: `${baseButtonClasses} bg-primary text-contrast`,
secondary: `${baseButtonClasses} border border-primary/10 bg-contrast text-primary`,
inline: 'border-b border-primary/10 leading-none pb-1',
};
const widths = {
auto: 'w-auto',
full: 'w-full',
};
const styles = clsx(
missingClass(className, 'bg-') && variants[variant],
missingClass(className, 'w-') && widths[width],
className,
);
return <Component className={styles} {...props} />;
}

View File

@@ -0,0 +1,44 @@
import clsx from 'clsx';
export function Grid({
as: Component = 'div',
className,
flow = 'row',
gap = 'default',
items = 4,
layout = 'default',
...props
}: {
as?: React.ElementType;
className?: string;
flow?: 'row' | 'col';
gap?: 'default' | 'blog';
items?: number;
layout?: 'default' | 'products' | 'auto' | 'blog';
[key: string]: any;
}) {
const layouts = {
default: `grid-cols-1 ${items === 2 && 'md:grid-cols-2'} ${
items === 3 && 'sm:grid-cols-3'
} ${items > 3 && 'md:grid-cols-3'} ${items >= 4 && 'lg:grid-cols-4'}`,
products: `grid-cols-2 ${items >= 3 && 'md:grid-cols-3'} ${
items >= 4 && 'lg:grid-cols-4'
}`,
auto: 'auto-cols-auto',
blog: `grid-cols-2 pt-24`,
};
const gaps = {
default: 'grid gap-2 gap-y-6 md:gap-4 lg:gap-6',
blog: 'grid gap-6',
};
const flows = {
row: 'grid-flow-row',
col: 'grid-flow-col',
};
const styles = clsx(flows[flow], gaps[gap], layouts[layout], className);
return <Component {...props} className={styles} />;
}

View File

@@ -0,0 +1,45 @@
import clsx from 'clsx';
import {missingClass, formatText} from '~/lib/utils';
export function Heading({
as: Component = 'h2',
children,
className = '',
format,
size = 'heading',
width = 'default',
...props
}: {
as?: React.ElementType;
children: React.ReactNode;
format?: boolean;
size?: 'display' | 'heading' | 'lead' | 'copy';
width?: 'default' | 'narrow' | 'wide';
} & React.HTMLAttributes<HTMLHeadingElement>) {
const sizes = {
display: 'font-bold text-display',
heading: 'font-bold text-heading',
lead: 'font-bold text-lead',
copy: 'font-medium text-copy',
};
const widths = {
default: 'max-w-prose',
narrow: 'max-w-prose-narrow',
wide: 'max-w-prose-wide',
};
const styles = clsx(
missingClass(className, 'whitespace-') && 'whitespace-pre-wrap',
missingClass(className, 'max-w-') && widths[width],
missingClass(className, 'font-') && sizes[size],
className,
);
return (
<Component {...props} className={styles}>
{format ? formatText(children) : children}
</Component>
);
}

View File

@@ -0,0 +1,236 @@
import clsx from 'clsx';
type IconProps = JSX.IntrinsicElements['svg'] & {
direction?: 'up' | 'right' | 'down' | 'left';
};
function Icon({
children,
className,
fill = 'currentColor',
stroke,
...props
}: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
{...props}
fill={fill}
stroke={stroke}
className={clsx('w-5 h-5', className)}
>
{children}
</svg>
);
}
export function AccountIcon(props: IconProps) {
return (
<Icon {...props}>
<title>Accounts</title>
<circle cx="20" cy="10.5" r="4.5" strokeWidth="2" />
<path
d="M20 19C13.4375 19 9.5 20.2857 9.5 28H30.5C30.5 20.2857 26.5625 19 20 19Z"
strokeWidth="2"
/>
</Icon>
);
}
export function IconMenu(props: IconProps) {
return (
<Icon {...props} stroke={props.stroke || 'currentColor'}>
<title>Menu</title>
<line x1="3" y1="6.375" x2="17" y2="6.375" strokeWidth="1.25" />
<line x1="3" y1="10.375" x2="17" y2="10.375" strokeWidth="1.25" />
<line x1="3" y1="14.375" x2="17" y2="14.375" strokeWidth="1.25" />
</Icon>
);
}
export function IconClose(props: IconProps) {
return (
<Icon {...props} stroke={props.stroke || 'currentColor'}>
<title>Close</title>
<line
x1="4.44194"
y1="4.30806"
x2="15.7556"
y2="15.6218"
strokeWidth="1.25"
/>
<line
y1="-0.625"
x2="16"
y2="-0.625"
transform="matrix(-0.707107 0.707107 0.707107 0.707107 16 4.75)"
strokeWidth="1.25"
/>
</Icon>
);
}
export function IconArrow({direction = 'right'}: IconProps) {
let rotate;
switch (direction) {
case 'right':
rotate = 'rotate-0';
break;
case 'left':
rotate = 'rotate-180';
break;
case 'up':
rotate = '-rotate-90';
break;
case 'down':
rotate = 'rotate-90';
break;
default:
rotate = 'rotate-0';
}
return (
<Icon className={`w-5 h-5 ${rotate}`}>
<title>Arrow</title>
<path d="M7 3L14 10L7 17" strokeWidth="1.25" />
</Icon>
);
}
export function IconCaret({
direction = 'down',
stroke = 'currentColor',
...props
}: IconProps) {
let rotate;
switch (direction) {
case 'down':
rotate = 'rotate-0';
break;
case 'up':
rotate = 'rotate-180';
break;
case 'left':
rotate = '-rotate-90';
break;
case 'right':
rotate = 'rotate-90';
break;
default:
rotate = 'rotate-0';
}
return (
<Icon
{...props}
className={`w-5 h-5 transition ${rotate}`}
fill="transparent"
stroke={stroke}
>
<title>Caret</title>
<path d="M14 8L10 12L6 8" strokeWidth="1.25" />
</Icon>
);
}
export function IconSelect(props: IconProps) {
return (
<Icon {...props}>
<title>Select</title>
<path d="M7 8.5L10 6.5L13 8.5" strokeWidth="1.25" />
<path d="M13 11.5L10 13.5L7 11.5" strokeWidth="1.25" />
</Icon>
);
}
export function IconBag(props: IconProps) {
return (
<Icon {...props}>
<title>Bag</title>
<path
fillRule="evenodd"
d="M8.125 5a1.875 1.875 0 0 1 3.75 0v.375h-3.75V5Zm-1.25.375V5a3.125 3.125 0 1 1 6.25 0v.375h3.5V15A2.625 2.625 0 0 1 14 17.625H6A2.625 2.625 0 0 1 3.375 15V5.375h3.5ZM4.625 15V6.625h10.75V15c0 .76-.616 1.375-1.375 1.375H6c-.76 0-1.375-.616-1.375-1.375Z"
/>
</Icon>
);
}
export function IconAccount(props: IconProps) {
return (
<Icon {...props}>
<title>Account</title>
<path
fillRule="evenodd"
d="M9.9998 12.625c-1.9141 0-3.6628.698-5.0435 1.8611C3.895 13.2935 3.25 11.7221 3.25 10c0-3.728 3.022-6.75 6.75-6.75 3.7279 0 6.75 3.022 6.75 6.75 0 1.7222-.645 3.2937-1.7065 4.4863-1.3807-1.1632-3.1295-1.8613-5.0437-1.8613ZM10 18c-2.3556 0-4.4734-1.0181-5.9374-2.6382C2.7806 13.9431 2 12.0627 2 10c0-4.4183 3.5817-8 8-8s8 3.5817 8 8-3.5817 8-8 8Zm0-12.5c-1.567 0-2.75 1.394-2.75 3s1.183 3 2.75 3 2.75-1.394 2.75-3-1.183-3-2.75-3Z"
/>
</Icon>
);
}
export function IconHelp(props: IconProps) {
return (
<Icon {...props}>
<title>Help</title>
<path d="M3.375 10a6.625 6.625 0 1 1 13.25 0 6.625 6.625 0 0 1-13.25 0ZM10 2.125a7.875 7.875 0 1 0 0 15.75 7.875 7.875 0 0 0 0-15.75Zm.699 10.507H9.236V14h1.463v-1.368ZM7.675 7.576A3.256 3.256 0 0 0 7.5 8.67h1.245c0-.496.105-.89.316-1.182.218-.299.553-.448 1.005-.448a1 1 0 0 1 .327.065c.124.044.24.113.35.208.108.095.2.223.272.383.08.154.12.34.12.558a1.3 1.3 0 0 1-.076.471c-.044.131-.11.252-.197.361-.08.102-.174.197-.283.285-.102.087-.212.182-.328.284a3.157 3.157 0 0 0-.382.383c-.102.124-.19.27-.262.438a2.476 2.476 0 0 0-.164.591 6.333 6.333 0 0 0-.043.81h1.179c0-.263.021-.485.065-.668a1.65 1.65 0 0 1 .207-.47c.088-.139.19-.263.306-.372.117-.11.244-.223.382-.34l.35-.306c.116-.11.218-.23.305-.361.095-.139.168-.3.219-.482.058-.19.087-.412.087-.667 0-.35-.062-.664-.186-.942a1.881 1.881 0 0 0-.513-.689 2.07 2.07 0 0 0-.753-.427A2.721 2.721 0 0 0 10.12 6c-.4 0-.764.066-1.092.197a2.36 2.36 0 0 0-.83.536c-.225.234-.4.515-.523.843Z" />
</Icon>
);
}
export function IconSearch(props: IconProps) {
return (
<Icon {...props}>
<title>Search</title>
<path
fillRule="evenodd"
d="M13.3 8.52a4.77 4.77 0 1 1-9.55 0 4.77 4.77 0 0 1 9.55 0Zm-.98 4.68a6.02 6.02 0 1 1 .88-.88l4.3 4.3-.89.88-4.3-4.3Z"
/>
</Icon>
);
}
export function IconCheck({
stroke = 'currentColor',
...props
}: React.ComponentProps<typeof Icon>) {
return (
<Icon {...props} fill="transparent" stroke={stroke}>
<title>Check</title>
<circle cx="10" cy="10" r="7.25" strokeWidth="1.25" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="m7.04 10.37 2.42 2.41 3.5-5.56"
/>
</Icon>
);
}
export function IconRemove(props: IconProps) {
return (
<Icon {...props} fill="transparent" stroke={props.stroke || 'currentColor'}>
<title>Remove</title>
<path
d="M4 6H16"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M8.5 9V14" strokeLinecap="round" strokeLinejoin="round" />
<path d="M11.5 9V14" strokeLinecap="round" strokeLinejoin="round" />
<path
d="M5.5 6L6 17H14L14.5 6"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8 6L8 5C8 4 8.75 3 10 3C11.25 3 12 4 12 5V6"
strokeWidth="1.25"
/>
</Icon>
);
}

View File

@@ -0,0 +1,24 @@
import clsx from 'clsx';
export function Input({
className = '',
type,
variant,
...props
}: {
className?: string;
type?: string;
variant: 'search' | 'minisearch';
[key: string]: any;
}) {
const variants = {
search:
'bg-transparent px-0 py-2 text-heading w-full focus:ring-0 border-x-0 border-t-0 transition border-b-2 border-primary/10 focus:border-primary/90',
minisearch:
'bg-transparent hidden md:inline-block text-left lg:text-right border-b transition border-transparent -mb-px border-x-0 border-t-0 appearance-none px-0 py-1 focus:ring-transparent placeholder:opacity-20 placeholder:text-inherit',
};
const styles = clsx(variants[variant], className);
return <input type={type} {...props} className={styles} />;
}

View File

@@ -0,0 +1,20 @@
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
onClick?: () => void;
}
export function LogoutButton(props: ButtonProps) {
const logout = () => {
fetch('/account/logout', {method: 'POST'}).then(() => {
if (typeof props?.onClick === 'function') {
props.onClick();
}
window.location.href = '/';
});
};
return (
<button className="text-primary/50" {...props} onClick={logout}>
Logout
</button>
);
}

View File

@@ -0,0 +1,62 @@
import clsx from 'clsx';
import {Heading} from '~/components';
import {missingClass} from '~/lib/utils';
export function Section({
as: Component = 'section',
children,
className,
divider = 'none',
display = 'grid',
heading,
padding = 'all',
...props
}: {
as?: React.ElementType;
children?: React.ReactNode;
className?: string;
divider?: 'none' | 'top' | 'bottom' | 'both';
display?: 'grid' | 'flex';
heading?: string;
padding?: 'x' | 'y' | 'swimlane' | 'all';
[key: string]: any;
}) {
const paddings = {
x: 'px-6 md:px-8 lg:px-12',
y: 'py-6 md:py-8 lg:py-12',
swimlane: 'pt-4 md:pt-8 lg:pt-12 md:pb-4 lg:pb-8',
all: 'p-6 md:p-8 lg:p-12',
};
const dividers = {
none: 'border-none',
top: 'border-t border-primary/05',
bottom: 'border-b border-primary/05',
both: 'border-y border-primary/05',
};
const displays = {
flex: 'flex',
grid: 'grid',
};
const styles = clsx(
'w-full gap-4 md:gap-8',
displays[display],
missingClass(className, '\\mp[xy]?-') && paddings[padding],
dividers[divider],
className,
);
return (
<Component {...props} className={styles}>
{heading && (
<Heading size="lead" className={padding === 'y' ? paddings['x'] : ''}>
{heading}
</Heading>
)}
{children}
</Component>
);
}

View File

@@ -0,0 +1,24 @@
import clsx from 'clsx';
/**
* A shared component and Suspense call that's used in `App.server.jsx` to let your app wait for code to load while declaring a loading state
*/
export function Skeleton({
as: Component = 'div',
width,
height,
className,
...props
}: {
as?: React.ElementType;
width?: string;
height?: string;
className?: string;
[key: string]: any;
}) {
const styles = clsx('rounded bg-primary/10', className);
return (
<Component {...props} width={width} height={height} className={styles} />
);
}

View File

@@ -0,0 +1,57 @@
import clsx from 'clsx';
import {missingClass, formatText} from '~/lib/utils';
export function Text({
as: Component = 'span',
className,
color = 'default',
format,
size = 'copy',
width = 'default',
children,
...props
}: {
as?: React.ElementType;
className?: string;
color?: 'default' | 'primary' | 'subtle' | 'notice' | 'contrast';
format?: boolean;
size?: 'lead' | 'copy' | 'fine';
width?: 'default' | 'narrow' | 'wide';
children: React.ReactNode;
[key: string]: any;
}) {
const colors: Record<string, string> = {
default: 'inherit',
primary: 'text-primary/90',
subtle: 'text-primary/50',
notice: 'text-notice',
contrast: 'text-contrast/90',
};
const sizes: Record<string, string> = {
lead: 'text-lead font-medium',
copy: 'text-copy',
fine: 'text-fine subpixel-antialiased',
};
const widths: Record<string, string> = {
default: 'max-w-prose',
narrow: 'max-w-prose-narrow',
wide: 'max-w-prose-wide',
};
const styles = clsx(
missingClass(className, 'max-w-') && widths[width],
missingClass(className, 'whitespace-') && 'whitespace-pre-wrap',
missingClass(className, 'text-') && colors[color],
sizes[size],
className,
);
return (
<Component {...props} className={styles}>
{format ? formatText(children) : children}
</Component>
);
}

View File

@@ -0,0 +1,9 @@
export * from './Icon';
export {Button} from './Button';
export {Grid} from './Grid';
export {Heading} from './Heading';
export {Input} from './Input';
export {LogoutButton} from './LogoutButton.client';
export {Section} from './Section';
export {Skeleton} from './Skeleton';
export {Text} from './Text';

View File

@@ -0,0 +1,18 @@
import {CartDetails} from '~/components/cart';
import {Drawer} from './Drawer.client';
export function CartDrawer({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) {
return (
<Drawer open={isOpen} onClose={onClose} heading="Cart" openFrom="right">
<div className="grid">
<CartDetails layout="drawer" onClose={onClose} />
</div>
</Drawer>
);
}

View File

@@ -0,0 +1,117 @@
import {Fragment, useState} from 'react';
// @ts-expect-error @headlessui/react incompatibility with node16 resolution
import {Dialog, Transition} from '@headlessui/react';
import {Heading, IconClose} from '~/components';
/**
* Drawer component that opens on user click.
* @param heading - string. Shown at the top of the drawer.
* @param open - boolean state. if true opens the drawer.
* @param onClose - function should set the open state.
* @param openFrom - right, left
* @param children - react children node.
*/
function Drawer({
heading,
open,
onClose,
openFrom = 'right',
children,
}: {
heading?: string;
open: boolean;
onClose: () => void;
openFrom: 'right' | 'left';
children: React.ReactNode;
}) {
const offScreen = {
right: 'translate-x-full',
left: '-translate-x-full',
};
return (
<Transition appear show={open} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 left-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0">
<div className="absolute inset-0 overflow-hidden">
<div
className={`fixed inset-y-0 flex max-w-full ${
openFrom === 'right' ? 'right-0' : ''
}`}
>
<Transition.Child
as={Fragment}
enter="transform transition ease-in-out duration-300"
enterFrom={offScreen[openFrom]}
enterTo="translate-x-0"
leave="transform transition ease-in-out duration-300"
leaveFrom="translate-x-0"
leaveTo={offScreen[openFrom]}
>
<Dialog.Panel className="w-screen h-screen max-w-lg text-left align-middle transition-all transform shadow-xl bg-contrast">
<header
className={`sticky top-0 flex items-center px-6 h-nav sm:px-8 md:px-12 ${
heading ? 'justify-between' : 'justify-end'
}`}
>
{heading !== null && (
<Dialog.Title>
<Heading as="span" size="lead" id="cart-contents">
{heading}
</Heading>
</Dialog.Title>
)}
<button
type="button"
className="p-4 -m-4 transition text-primary hover:text-primary/50"
onClick={onClose}
>
<IconClose aria-label="Close panel" />
</button>
</header>
{children}
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</div>
</Dialog>
</Transition>
);
}
/* Use for associating arialabelledby with the title*/
Drawer.Title = Dialog.Title;
export {Drawer};
export function useDrawer(openDefault = false) {
const [isOpen, setIsOpen] = useState(openDefault);
function openDrawer() {
setIsOpen(true);
}
function closeDrawer() {
setIsOpen(false);
}
return {
isOpen,
openDrawer,
closeDrawer,
};
}

View File

@@ -0,0 +1,46 @@
import {useUrl} from '@shopify/hydrogen';
import {Section, Heading, FooterMenu, CountrySelector} from '~/components';
import type {EnhancedMenu} from '~/lib/utils';
/**
* A server component that specifies the content of the footer on the website
*/
export function Footer({menu}: {menu?: EnhancedMenu}) {
const {pathname} = useUrl();
const localeMatch = /^\/([a-z]{2})(\/|$)/i.exec(pathname);
const countryCode = localeMatch ? localeMatch[1] : null;
const isHome = pathname === `/${countryCode ? countryCode + '/' : ''}`;
const itemsCount = menu
? menu?.items?.length + 1 > 4
? 4
: menu?.items?.length + 1
: [];
return (
<Section
divider={isHome ? 'none' : 'top'}
as="footer"
role="contentinfo"
className={`grid min-h-[25rem] items-start grid-flow-row w-full gap-6 py-8 px-6 md:px-8 lg:px-12
border-b md:gap-8 lg:gap-12 grid-cols-1 md:grid-cols-2 lg:grid-cols-${itemsCount}
bg-primary dark:bg-contrast dark:text-primary text-contrast overflow-hidden`}
>
<FooterMenu menu={menu} />
<section className="grid gap-4 w-full md:max-w-[335px] md:ml-auto">
<Heading size="lead" className="cursor-default" as="h3">
Country
</Heading>
<CountrySelector />
</section>
<div
className={`self-end pt-8 opacity-50 md:col-span-2 lg:col-span-${itemsCount}`}
>
&copy; {new Date().getFullYear()} / Shopify, Inc. Hydrogen is an MIT
Licensed Open Source project. This website is carbon&nbsp;neutral.
</div>
</Section>
);
}

View File

@@ -0,0 +1,63 @@
// @ts-expect-error @headlessui/react incompatibility with node16 resolution
import {Disclosure} from '@headlessui/react';
import {Link} from '@shopify/hydrogen';
import {Heading, IconCaret} from '~/components';
import type {EnhancedMenu, EnhancedMenuItem} from '~/lib/utils';
/**
* A server component that specifies the content of the footer on the website
*/
export function FooterMenu({menu}: {menu?: EnhancedMenu}) {
const styles = {
section: 'grid gap-4',
nav: 'grid gap-2 pb-6',
};
return (
<>
{(menu?.items || []).map((item: EnhancedMenuItem) => (
<section key={item.id} className={styles.section}>
<Disclosure>
{/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
{({open}) => (
<>
<Disclosure.Button className="text-left md:cursor-default">
<Heading className="flex justify-between" size="lead" as="h3">
{item.title}
{item?.items?.length > 0 && (
<span className="md:hidden">
<IconCaret direction={open ? 'up' : 'down'} />
</span>
)}
</Heading>
</Disclosure.Button>
{item?.items?.length > 0 && (
<div
className={`${
open ? `max-h-48 h-fit` : `max-h-0 md:max-h-fit`
} overflow-hidden transition-all duration-300`}
>
<Disclosure.Panel static>
<nav className={styles.nav}>
{item.items.map((subItem) => (
<Link
key={subItem.id}
to={subItem.to}
target={subItem.target}
>
{subItem.title}
</Link>
))}
</nav>
</Disclosure.Panel>
</div>
)}
</>
)}
</Disclosure>
</section>
))}{' '}
</>
);
}

View File

@@ -0,0 +1,230 @@
import {Link, useUrl, useCart} from '@shopify/hydrogen';
import {useWindowScroll} from 'react-use';
import {
Heading,
IconAccount,
IconBag,
IconMenu,
IconSearch,
Input,
} from '~/components';
import {CartDrawer} from './CartDrawer.client';
import {MenuDrawer} from './MenuDrawer.client';
import {useDrawer} from './Drawer.client';
import type {EnhancedMenu} from '~/lib/utils';
/**
* A client component that specifies the content of the header on the website
*/
export function Header({title, menu}: {title: string; menu?: EnhancedMenu}) {
const {pathname} = useUrl();
const localeMatch = /^\/([a-z]{2})(\/|$)/i.exec(pathname);
const countryCode = localeMatch ? localeMatch[1] : undefined;
const isHome = pathname === `/${countryCode ? countryCode + '/' : ''}`;
const {
isOpen: isCartOpen,
openDrawer: openCart,
closeDrawer: closeCart,
} = useDrawer();
const {
isOpen: isMenuOpen,
openDrawer: openMenu,
closeDrawer: closeMenu,
} = useDrawer();
return (
<>
<CartDrawer isOpen={isCartOpen} onClose={closeCart} />
<MenuDrawer isOpen={isMenuOpen} onClose={closeMenu} menu={menu!} />
<DesktopHeader
countryCode={countryCode}
isHome={isHome}
title={title}
menu={menu}
openCart={openCart}
/>
<MobileHeader
countryCode={countryCode}
isHome={isHome}
title={title}
openCart={openCart}
openMenu={openMenu}
/>
</>
);
}
function MobileHeader({
countryCode,
title,
isHome,
openCart,
openMenu,
}: {
countryCode?: string | null;
title: string;
isHome: boolean;
openCart: () => void;
openMenu: () => void;
}) {
const {y} = useWindowScroll();
const styles = {
button: 'relative flex items-center justify-center w-8 h-8',
container: `${
isHome
? 'bg-primary/80 dark:bg-contrast/60 text-contrast dark:text-primary shadow-darkHeader'
: 'bg-contrast/80 text-primary'
} ${
y > 50 && !isHome ? 'shadow-lightHeader ' : ''
}flex lg:hidden items-center h-nav sticky backdrop-blur-lg z-40 top-0 justify-between w-full leading-none gap-4 px-4 md:px-8`,
};
return (
<header role="banner" className={styles.container}>
<div className="flex items-center justify-start w-full gap-4">
<button onClick={openMenu} className={styles.button}>
<IconMenu />
</button>
<form
action={`/${countryCode ? countryCode + '/' : ''}search`}
className="items-center gap-2 sm:flex"
>
<button type="submit" className={styles.button}>
<IconSearch />
</button>
<Input
className={
isHome
? 'focus:border-contrast/20 dark:focus:border-primary/20'
: 'focus:border-primary/20'
}
type="search"
variant="minisearch"
placeholder="Search"
name="q"
/>
</form>
</div>
<Link
className="flex items-center self-stretch leading-[3rem] md:leading-[4rem] justify-center flex-grow w-full h-full"
to="/"
>
<Heading className="font-bold text-center" as={isHome ? 'h1' : 'h2'}>
{title}
</Heading>
</Link>
<div className="flex items-center justify-end w-full gap-4">
<Link to={'/account'} className={styles.button}>
<IconAccount />
</Link>
<button onClick={openCart} className={styles.button}>
<IconBag />
<CartBadge dark={isHome} />
</button>
</div>
</header>
);
}
function DesktopHeader({
countryCode,
isHome,
menu,
openCart,
title,
}: {
countryCode?: string | null;
isHome: boolean;
openCart: () => void;
menu?: EnhancedMenu;
title: string;
}) {
const {y} = useWindowScroll();
const styles = {
button:
'relative flex items-center justify-center w-8 h-8 focus:ring-primary/5',
container: `${
isHome
? 'bg-primary/80 dark:bg-contrast/60 text-contrast dark:text-primary shadow-darkHeader'
: 'bg-contrast/80 text-primary'
} ${
y > 50 && !isHome ? 'shadow-lightHeader ' : ''
}hidden h-nav lg:flex items-center sticky transition duration-300 backdrop-blur-lg z-40 top-0 justify-between w-full leading-none gap-8 px-12 py-8`,
};
return (
<header role="banner" className={styles.container}>
<div className="flex gap-12">
<Link className={`font-bold`} to="/">
{title}
</Link>
<nav className="flex gap-8">
{/* Top level menu items */}
{(menu?.items || []).map((item) => (
<Link key={item.id} to={item.to} target={item.target}>
{item.title}
</Link>
))}
</nav>
</div>
<div className="flex items-center gap-1">
<form
action={`/${countryCode ? countryCode + '/' : ''}search`}
className="flex items-center gap-2"
>
<Input
className={
isHome
? 'focus:border-contrast/20 dark:focus:border-primary/20'
: 'focus:border-primary/20'
}
type="search"
variant="minisearch"
placeholder="Search"
name="q"
/>
<button type="submit" className={styles.button}>
<IconSearch />
</button>
</form>
<Link to={'/account'} className={styles.button}>
<IconAccount />
</Link>
<button onClick={openCart} className={styles.button}>
<IconBag />
<CartBadge dark={isHome} />
</button>
</div>
</header>
);
}
function CartBadge({dark}: {dark: boolean}) {
const {totalQuantity} = useCart();
if (totalQuantity < 1) {
return null;
}
return (
<div
className={`${
dark
? 'text-primary bg-contrast dark:text-contrast dark:bg-primary'
: 'text-contrast bg-primary'
} absolute bottom-1 right-1 text-[0.625rem] font-medium subpixel-antialiased h-3 min-w-[0.75rem] flex items-center justify-center leading-none text-center rounded-full w-auto px-[0.125rem] pb-px`}
>
<span>{totalQuantity}</span>
</div>
);
}

View File

@@ -0,0 +1,129 @@
import {Suspense} from 'react';
import {useLocalization, useShopQuery, CacheLong, gql} from '@shopify/hydrogen';
import type {Menu, Shop} from '@shopify/hydrogen/storefront-api-types';
import {Header} from '~/components';
import {Footer} from '~/components/index.server';
import {parseMenu} from '~/lib/utils';
const HEADER_MENU_HANDLE = 'main-menu';
const FOOTER_MENU_HANDLE = 'footer';
const SHOP_NAME_FALLBACK = 'Hydrogen';
/**
* A server component that defines a structure and organization of a page that can be used in different parts of the Hydrogen app
*/
export function Layout({children}: {children: React.ReactNode}) {
return (
<>
<div className="flex flex-col min-h-screen">
<div className="">
<a href="#mainContent" className="sr-only">
Skip to content
</a>
</div>
<Suspense fallback={<Header title={SHOP_NAME_FALLBACK} />}>
<HeaderWithMenu />
</Suspense>
<main role="main" id="mainContent" className="flex-grow">
{children}
</main>
</div>
<Suspense fallback={<Footer />}>
<FooterWithMenu />
</Suspense>
</>
);
}
function HeaderWithMenu() {
const {shopName, headerMenu} = useLayoutQuery();
return <Header title={shopName} menu={headerMenu} />;
}
function FooterWithMenu() {
const {footerMenu} = useLayoutQuery();
return <Footer menu={footerMenu} />;
}
function useLayoutQuery() {
const {
language: {isoCode: languageCode},
} = useLocalization();
const {data} = useShopQuery<{
shop: Shop;
headerMenu: Menu;
footerMenu: Menu;
}>({
query: SHOP_QUERY,
variables: {
language: languageCode,
headerMenuHandle: HEADER_MENU_HANDLE,
footerMenuHandle: FOOTER_MENU_HANDLE,
},
cache: CacheLong(),
preload: '*',
});
const shopName = data ? data.shop.name : SHOP_NAME_FALLBACK;
/*
Modify specific links/routes (optional)
@see: https://shopify.dev/api/storefront/unstable/enums/MenuItemType
e.g here we map:
- /blogs/news -> /news
- /blog/news/blog-post -> /news/blog-post
- /collections/all -> /products
*/
const customPrefixes = {BLOG: '', CATALOG: 'products'};
const headerMenu = data?.headerMenu
? parseMenu(data.headerMenu, customPrefixes)
: undefined;
const footerMenu = data?.footerMenu
? parseMenu(data.footerMenu, customPrefixes)
: undefined;
return {footerMenu, headerMenu, shopName};
}
const SHOP_QUERY = gql`
fragment MenuItem on MenuItem {
id
resourceId
tags
title
type
url
}
query layoutMenus(
$language: LanguageCode
$headerMenuHandle: String!
$footerMenuHandle: String!
) @inContext(language: $language) {
shop {
name
}
headerMenu: menu(handle: $headerMenuHandle) {
id
items {
...MenuItem
items {
...MenuItem
}
}
}
footerMenu: menu(handle: $footerMenuHandle) {
id
items {
...MenuItem
items {
...MenuItem
}
}
}
}
`;

View File

@@ -0,0 +1,43 @@
import {EnhancedMenu} from '~/lib/utils';
import {Text} from '~/components';
import {Drawer} from './Drawer.client';
import {Link} from '@shopify/hydrogen';
export function MenuDrawer({
isOpen,
onClose,
menu,
}: {
isOpen: boolean;
onClose: () => void;
menu: EnhancedMenu;
}) {
return (
<Drawer open={isOpen} onClose={onClose} openFrom="left" heading="Menu">
<div className="grid">
<MenuMobileNav menu={menu} onClose={onClose} />
</div>
</Drawer>
);
}
function MenuMobileNav({
menu,
onClose,
}: {
menu: EnhancedMenu;
onClose: () => void;
}) {
return (
<nav className="grid gap-4 p-6 sm:gap-6 sm:px-12 sm:py-8">
{/* Top level menu items */}
{(menu?.items || []).map((item) => (
<Link key={item.id} to={item.to} target={item.target} onClick={onClose}>
<Text as="span" size="copy">
{item.title}
</Text>
</Link>
))}
</nav>
);
}

View File

@@ -0,0 +1,43 @@
import {IconClose} from '~/components';
export function Modal({
children,
close,
}: {
children: React.ReactNode;
close: () => void;
}) {
return (
<div
className="relative z-50"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
id="modal-bg"
>
<div className="fixed inset-0 transition-opacity bg-opacity-75 bg-primary/40"></div>
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-full p-4 text-center sm:p-0">
<div
className="relative flex-1 px-4 pt-5 pb-4 overflow-hidden text-left transition-all transform rounded shadow-xl bg-contrast sm:my-12 sm:flex-none sm:w-full sm:max-w-sm sm:p-6"
role="button"
onClick={(e) => e.stopPropagation()}
onKeyPress={(e) => e.stopPropagation()}
tabIndex={0}
>
<div className="absolute top-0 right-0 hidden pt-4 pr-4 sm:block">
<button
type="button"
className="p-4 -m-4 transition text-primary hover:text-primary/50"
onClick={close}
>
<IconClose aria-label="Close panel" />
</button>
</div>
{children}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
import {
gql,
HydrogenResponse,
useLocalization,
useShopQuery,
} from '@shopify/hydrogen';
import {Suspense} from 'react';
import {PRODUCT_CARD_FRAGMENT} from '~/lib/fragments';
import {Button, FeaturedCollections, PageHeader, Text} from '~/components';
import {ProductSwimlane, Layout} from '~/components/index.server';
import type {
CollectionConnection,
ProductConnection,
} from '@shopify/hydrogen/storefront-api-types';
export function NotFound({
response,
type = 'page',
}: {
response?: HydrogenResponse;
type?: string;
}) {
if (response) {
response.status = 404;
response.statusText = 'Not found';
}
const heading = `Weve lost this ${type}`;
const description = `We couldnt find the ${type} youre looking for. Try checking the URL or heading back to the home page.`;
return (
<Layout>
<PageHeader heading={heading}>
<Text width="narrow" as="p">
{description}
</Text>
<Button width="auto" variant="secondary" to={'/'}>
Take me to the home page
</Button>
</PageHeader>
<Suspense>
<FeaturedSection />
</Suspense>
</Layout>
);
}
function FeaturedSection() {
const {
language: {isoCode: languageCode},
country: {isoCode: countryCode},
} = useLocalization();
const {data} = useShopQuery<{
featuredCollections: CollectionConnection;
featuredProducts: ProductConnection;
}>({
query: NOT_FOUND_QUERY,
variables: {
language: languageCode,
country: countryCode,
},
preload: true,
});
const {featuredCollections, featuredProducts} = data;
return (
<>
{featuredCollections.nodes.length < 2 && (
<FeaturedCollections
title="Popular Collections"
data={featuredCollections.nodes}
/>
)}
<ProductSwimlane data={featuredProducts.nodes} />
</>
);
}
const NOT_FOUND_QUERY = gql`
${PRODUCT_CARD_FRAGMENT}
query homepage($country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
featuredCollections: collections(first: 3, sortKey: UPDATED_AT) {
nodes {
id
title
handle
image {
altText
width
height
url
}
}
}
featuredProducts: products(first: 12) {
nodes {
...ProductCard
}
}
}
`;

View File

@@ -0,0 +1,38 @@
import clsx from 'clsx';
import {Heading} from '~/components';
export function PageHeader({
children,
className,
heading,
variant = 'default',
...props
}: {
children?: React.ReactNode;
className?: string;
heading?: string;
variant?: 'default' | 'blogPost' | 'allCollections';
[key: string]: any;
}) {
const variants: Record<string, string> = {
default: 'grid w-full gap-8 p-6 py-8 md:p-8 lg:p-12 justify-items-start',
blogPost:
'grid md:text-center w-full gap-4 p-6 py-8 md:p-8 lg:p-12 md:justify-items-center',
allCollections:
'flex justify-between items-baseline gap-8 p-6 md:p-8 lg:p-12',
};
const styles = clsx(variants[variant], className);
return (
<header {...props} className={styles}>
{heading && (
<Heading as="h1" width="narrow" size="heading" className="inline-block">
{heading}
</Heading>
)}
{children}
</header>
);
}

View File

@@ -0,0 +1,3 @@
export {Footer} from './Footer.server';
export {Layout} from './Layout.server';
export {NotFound} from './NotFound.server';

View File

@@ -0,0 +1,5 @@
export {Drawer, useDrawer} from './Drawer.client';
export {FooterMenu} from './FooterMenu.client';
export {Header} from './Header.client';
export {Modal} from './Modal.client';
export {PageHeader} from './PageHeader';

View File

@@ -0,0 +1,5 @@
export * from './cards/index.server';
export * from './global/index.server';
export * from './sections/index.server';
export * from './search/index.server';
export {DefaultSeo} from './DefaultSeo.server';

View File

@@ -0,0 +1,10 @@
export * from './account/index';
export * from './cards/index';
export * from './cart/index';
export * from './elements/index';
export * from './global/index';
export * from './product/index';
export * from './sections/index';
export {CountrySelector} from './CountrySelector.client';
export {CustomFont} from './CustomFont.client';
export {HeaderFallback} from './HeaderFallback';

View File

@@ -0,0 +1,54 @@
// @ts-expect-error @headlessui/react incompatibility with node16 resolution
import {Disclosure} from '@headlessui/react';
import {Link} from '@shopify/hydrogen';
import {Text, IconClose} from '~/components';
export function ProductDetail({
title,
content,
learnMore,
}: {
title: string;
content: string;
learnMore?: string;
}) {
return (
<Disclosure key={title} as="div" className="grid w-full gap-2">
{/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
{({open}) => (
<>
<Disclosure.Button className="text-left">
<div className="flex justify-between">
<Text size="lead" as="h4">
{title}
</Text>
<IconClose
className={`${
open ? '' : 'rotate-[45deg]'
} transition-transform transform-gpu duration-200`}
/>
</div>
</Disclosure.Button>
<Disclosure.Panel className={'pb-4 pt-2 grid gap-2'}>
<div
className="prose dark:prose-invert"
dangerouslySetInnerHTML={{__html: content}}
/>
{learnMore && (
<div className="">
<Link
className="pb-px border-b border-primary/30 text-primary/50"
to={learnMore}
>
Learn more
</Link>
</div>
)}
</Disclosure.Panel>
</>
)}
</Disclosure>
);
}

View File

@@ -0,0 +1,144 @@
import {useEffect, useCallback, useState} from 'react';
import {
useProductOptions,
isBrowser,
useUrl,
AddToCartButton,
Money,
OptionWithValues,
ShopPayButton,
} from '@shopify/hydrogen';
import {Heading, Text, Button, ProductOptions} from '~/components';
export function ProductForm() {
const {pathname, search} = useUrl();
const [params, setParams] = useState(new URLSearchParams(search));
const {options, setSelectedOption, selectedOptions, selectedVariant} =
useProductOptions();
const isOutOfStock = !selectedVariant?.availableForSale || false;
const isOnSale =
selectedVariant?.priceV2?.amount <
selectedVariant?.compareAtPriceV2?.amount || false;
useEffect(() => {
if (params || !search) return;
setParams(new URLSearchParams(search));
}, [params, search]);
useEffect(() => {
(options as OptionWithValues[]).map(({name, values}) => {
if (!params) return;
const currentValue = params.get(name.toLowerCase()) || null;
if (currentValue) {
const matchedValue = values.filter(
(value) => encodeURIComponent(value.toLowerCase()) === currentValue,
);
setSelectedOption(name, matchedValue[0]);
} else {
params.set(
encodeURIComponent(name.toLowerCase()),
encodeURIComponent(selectedOptions![name]!.toLowerCase()),
),
window.history.replaceState(
null,
'',
`${pathname}?${params.toString()}`,
);
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleChange = useCallback(
(name: string, value: string) => {
setSelectedOption(name, value);
if (!params) return;
params.set(
encodeURIComponent(name.toLowerCase()),
encodeURIComponent(value.toLowerCase()),
);
if (isBrowser()) {
window.history.replaceState(
null,
'',
`${pathname}?${params.toString()}`,
);
}
},
[setSelectedOption, params, pathname],
);
return (
<form className="grid gap-10">
{
<div className="grid gap-4">
{(options as OptionWithValues[]).map(({name, values}) => {
if (values.length === 1) {
return null;
}
return (
<div
key={name}
className="flex flex-col flex-wrap mb-4 gap-y-2 last:mb-0"
>
<Heading as="legend" size="lead" className="min-w-[4rem]">
{name}
</Heading>
<div className="flex flex-wrap items-baseline gap-4">
<ProductOptions
name={name}
handleChange={handleChange}
values={values}
/>
</div>
</div>
);
})}
</div>
}
<div className="grid items-stretch gap-4">
<AddToCartButton
variantId={selectedVariant?.id}
quantity={1}
accessibleAddingToCartLabel="Adding item to your cart"
disabled={isOutOfStock}
>
<Button
width="full"
variant={isOutOfStock ? 'secondary' : 'primary'}
as="span"
>
{isOutOfStock ? (
<Text>Sold out</Text>
) : (
<Text
as="span"
className="flex items-center justify-center gap-2"
>
<span>Add to bag</span> <span>·</span>{' '}
<Money
withoutTrailingZeros
data={selectedVariant.priceV2!}
as="span"
/>
{isOnSale && (
<Money
withoutTrailingZeros
data={selectedVariant.compareAtPriceV2!}
as="span"
className="opacity-50 strike"
/>
)}
</Text>
)}
</Button>
</AddToCartButton>
{!isOutOfStock && <ShopPayButton variantIds={[selectedVariant.id!]} />}
</div>
</form>
);
}

View File

@@ -0,0 +1,106 @@
import {MediaFile} from '@shopify/hydrogen/client';
import type {MediaEdge} from '@shopify/hydrogen/storefront-api-types';
import {ATTR_LOADING_EAGER} from '~/lib/const';
/**
* A client component that defines a media gallery for hosting images, 3D models, and videos of products
*/
export function ProductGallery({
media,
className,
}: {
media: MediaEdge['node'][];
className?: string;
}) {
if (!media.length) {
return null;
}
return (
<div
className={`swimlane md:grid-flow-row hiddenScroll md:p-0 md:overflow-x-auto md:grid-cols-2 ${className}`}
>
{media.map((med, i) => {
let mediaProps: Record<string, any> = {};
const isFirst = i === 0;
const isFourth = i === 3;
const isFullWidth = i % 3 === 0;
const data = {
...med,
image: {
// @ts-ignore
...med.image,
altText: med.alt || 'Product image',
},
};
switch (med.mediaContentType) {
case 'IMAGE':
mediaProps = {
width: 800,
widths: [400, 800, 1200, 1600, 2000, 2400],
};
break;
case 'VIDEO':
mediaProps = {
width: '100%',
autoPlay: true,
controls: false,
muted: true,
loop: true,
preload: 'auto',
};
break;
case 'EXTERNAL_VIDEO':
mediaProps = {width: '100%'};
break;
case 'MODEL_3D':
mediaProps = {
width: '100%',
interactionPromptThreshold: '0',
ar: true,
loading: ATTR_LOADING_EAGER,
disableZoom: true,
};
break;
}
if (i === 0 && med.mediaContentType === 'IMAGE') {
mediaProps.loading = ATTR_LOADING_EAGER;
}
const style = [
isFullWidth ? 'md:col-span-2' : 'md:col-span-1',
isFirst || isFourth ? '' : 'md:aspect-[4/5]',
'aspect-square snap-center card-image bg-white dark:bg-contrast/10 w-mobileGallery md:w-full',
].join(' ');
return (
<div
className={style}
// @ts-ignore
key={med.id || med.image.id}
>
<MediaFile
tabIndex="0"
className={`w-full h-full aspect-square fadeIn object-cover`}
data={data}
sizes={
isFullWidth
? '(min-width: 64em) 60vw, (min-width: 48em) 50vw, 90vw'
: '(min-width: 64em) 30vw, (min-width: 48em) 25vw, 90vw'
}
// @ts-ignore
options={{
crop: 'center',
scale: 2,
}}
{...mediaProps}
/>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,113 @@
import {useState, useRef, useEffect, useCallback} from 'react';
import {Link, flattenConnection} from '@shopify/hydrogen';
import {Button, Grid, ProductCard} from '~/components';
import {getImageLoadingPriority} from '~/lib/const';
import type {Collection, Product} from '@shopify/hydrogen/storefront-api-types';
export function ProductGrid({
url,
collection,
}: {
url: string;
collection: Collection;
}) {
const nextButtonRef = useRef(null);
const initialProducts = collection?.products?.nodes || [];
const {hasNextPage, endCursor} = collection?.products?.pageInfo ?? {};
const [products, setProducts] = useState<Product[]>(initialProducts);
const [cursor, setCursor] = useState(endCursor ?? '');
const [nextPage, setNextPage] = useState(hasNextPage);
const [pending, setPending] = useState(false);
const haveProducts = initialProducts.length > 0;
const fetchProducts = useCallback(async () => {
setPending(true);
const postUrl = new URL(window.location.origin + url);
postUrl.searchParams.set('cursor', cursor);
const response = await fetch(postUrl, {
method: 'POST',
});
const {data} = await response.json();
// ProductGrid can paginate collection, products and search routes
// @ts-ignore TODO: Fix types
const newProducts: Product[] = flattenConnection<Product>(
data?.collection?.products || data?.products || [],
);
const {endCursor, hasNextPage} = data?.collection?.products?.pageInfo ||
data?.products?.pageInfo || {endCursor: '', hasNextPage: false};
setProducts([...products, ...newProducts]);
setCursor(endCursor);
setNextPage(hasNextPage);
setPending(false);
}, [cursor, url, products]);
const handleIntersect = useCallback(
(entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
fetchProducts();
}
});
},
[fetchProducts],
);
useEffect(() => {
const observer = new IntersectionObserver(handleIntersect, {
rootMargin: '100%',
});
const nextButton = nextButtonRef.current;
if (nextButton) observer.observe(nextButton);
return () => {
if (nextButton) observer.unobserve(nextButton);
};
}, [nextButtonRef, cursor, handleIntersect]);
if (!haveProducts) {
return (
<>
<p>No products found on this collection</p>
<Link to="/products">
<p className="underline">Browse catalog</p>
</Link>
</>
);
}
return (
<>
<Grid layout="products">
{products.map((product, i) => (
<ProductCard
key={product.id}
product={product}
loading={getImageLoadingPriority(i)}
/>
))}
</Grid>
{nextPage && (
<div
className="flex items-center justify-center mt-6"
ref={nextButtonRef}
>
<Button
variant="secondary"
disabled={pending}
onClick={fetchProducts}
width="full"
>
{pending ? 'Loading...' : 'Load more products'}
</Button>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,141 @@
import {useCallback, useState} from 'react';
// @ts-expect-error @headlessui/react incompatibility with node16 resolution
import {Listbox} from '@headlessui/react';
import {useProductOptions} from '@shopify/hydrogen';
import {Text, IconCheck, IconCaret} from '~/components';
export function ProductOptions({
values,
...props
}: {
values: any[];
[key: string]: any;
} & React.ComponentProps<typeof OptionsGrid>) {
const asDropdown = values.length > 7;
return asDropdown ? (
<OptionsDropdown values={values} {...props} />
) : (
<OptionsGrid values={values} {...props} />
);
}
function OptionsGrid({
values,
name,
handleChange,
}: {
values: string[];
name: string;
handleChange: (name: string, value: string) => void;
}) {
const {selectedOptions} = useProductOptions();
return (
<>
{values.map((value) => {
const checked = selectedOptions![name] === value;
const id = `option-${name}-${value}`;
return (
<Text as="label" key={id} htmlFor={id}>
<input
className="sr-only"
type="radio"
id={id}
name={`option[${name}]`}
value={value}
checked={checked}
onChange={() => handleChange(name, value)}
/>
<div
className={`leading-none py-1 border-b-[1.5px] cursor-pointer transition-all duration-200 ${
checked ? 'border-primary/50' : 'border-primary/0'
}`}
>
{value}
</div>
</Text>
);
})}
</>
);
}
// TODO: De-dupe UI with CountrySelector
function OptionsDropdown({
values,
name,
handleChange,
}: {
values: string[];
name: string;
handleChange: (name: string, value: string) => void;
}) {
const [listboxOpen, setListboxOpen] = useState(false);
const {selectedOptions} = useProductOptions();
const updateSelectedOption = useCallback(
(value: string) => {
handleChange(name, value);
},
[name, handleChange],
);
return (
<div className="relative w-full">
<Listbox onChange={updateSelectedOption} value="">
{/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
{({open}) => {
setTimeout(() => setListboxOpen(open));
return (
<>
<Listbox.Button
className={`flex items-center justify-between w-full py-3 px-4 border border-primary ${
open ? 'rounded-b md:rounded-t md:rounded-b-none' : 'rounded'
}`}
>
<span>{selectedOptions![name]}</span>
<IconCaret direction={open ? 'up' : 'down'} />
</Listbox.Button>
<Listbox.Options
className={`border-primary bg-contrast absolute bottom-12 z-30 grid
h-48 w-full overflow-y-scroll rounded-t border px-2 py-2 transition-[max-height]
duration-150 sm:bottom-auto md:rounded-b md:rounded-t-none md:border-t-0 md:border-b ${
listboxOpen ? 'max-h-48' : 'max-h-0'
}`}
>
{values.map((value) => {
const isSelected = selectedOptions![name] === value;
const id = `option-${name}-${value}`;
return (
<Listbox.Option key={id} value={value}>
{/* @ts-expect-error @headlessui/react incompatibility with node16 resolution */}
{({active}) => (
<div
className={`text-primary w-full p-2 transition rounded flex justify-start items-center text-left cursor-pointer ${
active ? 'bg-primary/10' : null
}`}
>
{value}
{isSelected ? (
<span className="ml-2">
<IconCheck />
</span>
) : null}
</div>
)}
</Listbox.Option>
);
})}
</Listbox.Options>
</>
);
}}
</Listbox>
</div>
);
}

View File

@@ -0,0 +1,5 @@
export {ProductForm} from './ProductForm.client';
export {ProductGallery} from './ProductGallery.client';
export {ProductGrid} from './ProductGrid.client';
export {ProductDetail} from './ProductDetail.client';
export {ProductOptions} from './ProductOptions.client';

View File

@@ -0,0 +1,65 @@
import {gql, useShopQuery} from '@shopify/hydrogen';
import {PRODUCT_CARD_FRAGMENT} from '~/lib/fragments';
import {FeaturedCollections} from '~/components';
import {ProductSwimlane} from '~/components/index.server';
import {PAGINATION_SIZE} from '~/lib/const';
export function NoResultRecommendations({
country,
language,
}: {
country: string;
language: string;
}) {
const {data} = useShopQuery<any>({
query: SEARCH_NO_RESULTS_QUERY,
variables: {
country,
language,
pageBy: PAGINATION_SIZE,
},
preload: false,
});
return (
<>
<FeaturedCollections
title="Trending Collections"
data={data.featuredCollections.nodes}
/>
<ProductSwimlane
title="Trending Products"
data={data.featuredProducts.nodes}
/>
</>
);
}
const SEARCH_NO_RESULTS_QUERY = gql`
${PRODUCT_CARD_FRAGMENT}
query searchNoResult(
$country: CountryCode
$language: LanguageCode
$pageBy: Int!
) @inContext(country: $country, language: $language) {
featuredCollections: collections(first: 3, sortKey: UPDATED_AT) {
nodes {
id
title
handle
image {
altText
width
height
url
}
}
}
featuredProducts: products(first: $pageBy) {
nodes {
...ProductCard
}
}
}
`;

View File

@@ -0,0 +1,33 @@
import {Heading, Input, PageHeader} from '~/components';
import {Layout} from '~/components/index.server';
export function SearchPage({
searchTerm,
children,
}: {
searchTerm?: string | null;
children: React.ReactNode;
}) {
return (
<Layout>
<PageHeader>
<Heading as="h1" size="copy">
Search
</Heading>
<form className="relative flex w-full text-heading">
<Input
defaultValue={searchTerm}
placeholder="Search…"
type="search"
variant="search"
name="q"
/>
<button className="absolute right-0 py-2" type="submit">
Go
</button>
</form>
</PageHeader>
{children}
</Layout>
);
}

View File

@@ -0,0 +1,2 @@
export {NoResultRecommendations} from './NoResultRecommendations.server';
export {SearchPage} from './SearchPage.server';

View File

@@ -0,0 +1,55 @@
import {Link, Image} from '@shopify/hydrogen';
import type {Collection} from '@shopify/hydrogen/storefront-api-types';
import {Heading, Section, Grid} from '~/components';
export function FeaturedCollections({
data,
title = 'Collections',
...props
}: {
data: Collection[];
title?: string;
[key: string]: any;
}) {
const items = data.filter((item) => item.image).length;
const haveCollections = data.length > 0;
if (!haveCollections) return null;
return (
<Section {...props} heading={title}>
<Grid items={items}>
{data.map((collection) => {
if (!collection?.image) {
return null;
}
// TODO: Refactor to use CollectionCard
return (
<Link key={collection.id} to={`/collections/${collection.handle}`}>
<div className="grid gap-4">
<div className="card-image bg-primary/5 aspect-[3/2]">
{collection?.image && (
<Image
alt={`Image of ${collection.title}`}
data={collection.image}
height={400}
sizes="(max-width: 32em) 100vw, 33vw"
width={600}
widths={[400, 500, 600, 700, 800, 900]}
loaderOptions={{
scale: 2,
crop: 'center',
}}
/>
)}
</div>
<Heading size="copy">{collection.title}</Heading>
</div>
</Link>
);
})}
</Grid>
</Section>
);
}

View File

@@ -0,0 +1,143 @@
import {Image, Link, Video} from '@shopify/hydrogen';
import type {Media} from '@shopify/hydrogen/storefront-api-types';
import {Heading, Text} from '~/components';
interface Metafield {
value: string;
reference?: object;
}
export function Hero({
byline,
cta,
handle,
heading,
height,
loading,
spread,
spreadSecondary,
top,
}: {
byline: Metafield;
cta: Metafield;
handle: string;
heading: Metafield;
height?: 'full';
loading?: 'eager' | 'lazy';
spread: Metafield;
spreadSecondary: Metafield;
top?: boolean;
}) {
return (
<Link to={`/collections/${handle}`}>
<section
className={`relative justify-end flex flex-col w-full ${
top && '-mt-nav'
} ${
height === 'full'
? 'h-screen'
: 'aspect-[4/5] sm:aspect-square md:aspect-[5/4] lg:aspect-[3/2] xl:aspect-[2/1]'
}`}
>
<div className="absolute inset-0 grid flex-grow grid-flow-col pointer-events-none auto-cols-fr -z-10 content-stretch overflow-clip">
{spread?.reference && (
<div className="">
<SpreadMedia
scale={2}
sizes={
spreadSecondary?.reference
? '(min-width: 80em) 700px, (min-width: 48em) 450px, 500px'
: '(min-width: 80em) 1400px, (min-width: 48em) 900px, 500px'
}
widths={
spreadSecondary?.reference
? [500, 450, 700]
: [500, 900, 1400]
}
width={spreadSecondary?.reference ? 375 : 750}
data={spread.reference as Media}
loading={loading}
/>
</div>
)}
{spreadSecondary?.reference && (
<div className="hidden md:block">
<SpreadMedia
sizes="(min-width: 80em) 700, (min-width: 48em) 450, 500"
widths={[450, 700]}
width={375}
data={spreadSecondary.reference}
/>
</div>
)}
</div>
<div className="flex flex-col items-baseline justify-between gap-4 px-6 py-8 sm:px-8 md:px-12 bg-gradient-to-t dark:from-contrast/60 dark:text-primary from-primary/60 text-contrast">
{heading?.value && (
<Heading format as="h2" size="display" className="max-w-md">
{heading.value}
</Heading>
)}
{byline?.value && (
<Text format width="narrow" as="p" size="lead">
{byline.value}
</Text>
)}
{cta?.value && <Text size="lead">{cta.value}</Text>}
</div>
</section>
</Link>
);
}
interface SpreadMediaProps {
data: Media;
loading?: HTMLImageElement['loading'];
scale?: 2 | 3;
sizes: string;
width: number;
widths: number[];
}
function SpreadMedia({
data,
loading,
scale,
sizes,
width,
widths,
}: SpreadMediaProps) {
if (data.mediaContentType === 'VIDEO') {
return (
<Video
previewImageOptions={{scale, src: data.previewImage!.url}}
width={scale! * width}
className="block object-cover w-full h-full"
data={data}
controls={false}
muted
loop
playsInline
autoPlay
/>
);
}
if (data.mediaContentType === 'IMAGE') {
return (
<Image
widths={widths}
sizes={sizes}
alt={data.alt || 'Marketing Banner Image'}
className="block object-cover w-full h-full"
// @ts-ignore
data={data.image}
loading={loading}
width={width}
loaderOptions={{scale, crop: 'center'}}
/>
);
}
return null;
}

View File

@@ -0,0 +1,16 @@
import {Product} from '@shopify/hydrogen/storefront-api-types';
import {ProductCard} from '../cards/ProductCard.client';
export function ProductCards({products}: {products: Product[]}) {
return (
<>
{products.map((product) => (
<ProductCard
product={product}
key={product.id}
className={'snap-start w-80'}
/>
))}
</>
);
}

View File

@@ -0,0 +1,147 @@
import {Suspense, useMemo} from 'react';
import {gql, useShopQuery, useLocalization} from '@shopify/hydrogen';
import {PRODUCT_CARD_FRAGMENT} from '~/lib/fragments';
import {ProductCard, Section} from '~/components';
import type {
Product,
ProductConnection,
} from '@shopify/hydrogen/storefront-api-types';
const mockProducts = new Array(12).fill('');
export function ProductSwimlane({
title = 'Featured Products',
data = mockProducts,
count = 12,
...props
}) {
const productCardsMarkup = useMemo(() => {
// If the data is already provided, there's no need to query it, so we'll just return the data
if (typeof data === 'object') {
return <ProductCards products={data} />;
}
// If the data provided is a productId, we will query the productRecommendations API.
// To make sure we have enough products for the swimlane, we'll combine the results with our top selling products.
if (typeof data === 'string') {
return (
<Suspense>
<RecommendedProducts productId={data} count={count} />
</Suspense>
);
}
// If no data is provided, we'll go and query the top products
return <TopProducts count={count} />;
}, [count, data]);
return (
<Section heading={title} padding="y" {...props}>
<div className="swimlane hiddenScroll md:pb-8 md:scroll-px-8 lg:scroll-px-12 md:px-8 lg:px-12">
{productCardsMarkup}
</div>
</Section>
);
}
function ProductCards({products}: {products: Product[]}) {
return (
<>
{products.map((product) => (
<ProductCard
product={product}
key={product.id}
className={'snap-start w-80'}
/>
))}
</>
);
}
function RecommendedProducts({
productId,
count,
}: {
productId: string;
count: number;
}) {
const {
language: {isoCode: languageCode},
country: {isoCode: countryCode},
} = useLocalization();
const {data: products} = useShopQuery<{
recommended: Product[];
additional: ProductConnection;
}>({
query: RECOMMENDED_PRODUCTS_QUERY,
variables: {
count,
productId,
languageCode,
countryCode,
},
});
const mergedProducts = products.recommended
.concat(products.additional.nodes)
.filter(
(value, index, array) =>
array.findIndex((value2) => value2.id === value.id) === index,
);
const originalProduct = mergedProducts
.map((item) => item.id)
.indexOf(productId);
mergedProducts.splice(originalProduct, 1);
return <ProductCards products={mergedProducts} />;
}
function TopProducts({count}: {count: number}) {
const {
data: {products},
} = useShopQuery({
query: TOP_PRODUCTS_QUERY,
variables: {
count,
},
});
return <ProductCards products={products.nodes} />;
}
const RECOMMENDED_PRODUCTS_QUERY = gql`
${PRODUCT_CARD_FRAGMENT}
query productRecommendations(
$productId: ID!
$count: Int
$countryCode: CountryCode
$languageCode: LanguageCode
) @inContext(country: $countryCode, language: $languageCode) {
recommended: productRecommendations(productId: $productId) {
...ProductCard
}
additional: products(first: $count, sortKey: BEST_SELLING) {
nodes {
...ProductCard
}
}
}
`;
const TOP_PRODUCTS_QUERY = gql`
${PRODUCT_CARD_FRAGMENT}
query topProducts(
$count: Int
$countryCode: CountryCode
$languageCode: LanguageCode
) @inContext(country: $countryCode, language: $languageCode) {
products(first: $count, sortKey: BEST_SELLING) {
nodes {
...ProductCard
}
}
}
`;

View File

@@ -0,0 +1 @@
export {ProductSwimlane} from './ProductSwimlane.server';

View File

@@ -0,0 +1,2 @@
export {FeaturedCollections} from './FeaturedCollections';
export {Hero} from './Hero';

View File

@@ -0,0 +1,10 @@
export const PAGINATION_SIZE = 8;
export const DEFAULT_GRID_IMG_LOAD_EAGER_COUNT = 4;
export const ATTR_LOADING_EAGER = 'eager';
export function getImageLoadingPriority(
index: number,
maxEagerLoadCount = DEFAULT_GRID_IMG_LOAD_EAGER_COUNT,
) {
return index < maxEagerLoadCount ? ATTR_LOADING_EAGER : undefined;
}

View File

@@ -0,0 +1,66 @@
import {gql} from '@shopify/hydrogen';
export const MEDIA_FRAGMENT = gql`
fragment Media on Media {
mediaContentType
alt
previewImage {
url
}
... on MediaImage {
id
image {
url
width
height
}
}
... on Video {
id
sources {
mimeType
url
}
}
... on Model3d {
id
sources {
mimeType
url
}
}
... on ExternalVideo {
id
embedUrl
host
}
}
`;
export const PRODUCT_CARD_FRAGMENT = gql`
fragment ProductCard on Product {
id
title
publishedAt
handle
variants(first: 1) {
nodes {
id
image {
url
altText
width
height
}
priceV2 {
amount
currencyCode
}
compareAtPriceV2 {
amount
currencyCode
}
}
}
}
`;

View File

@@ -0,0 +1,3 @@
export * from './fragments';
export * from './placeholders';
export * from './utils';

View File

@@ -0,0 +1,226 @@
// Demo store placeholders
const PLACEHOLDERS = {
HEROS: [
// primaryHero
{
heading: {value: 'All Mountain All Season'},
byline: {
value: 'The All New Hydrogen Snowboard Exclusively From Shopify',
},
cta: {value: 'Shop Now →'},
handle: 'freestyle',
spread: {
reference: {
mediaContentType: 'IMAGE',
alt: 'Tracks in the snow leading to a person on a mountain top with a red jacket contrasting to an epic blue horizon with a mountain range in the distance.',
previewImage: {
url: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/files/Hydrogen_Hero_Feature_1.jpg?v=1654902468',
},
id: 'gid://shopify/MediaImage/29259478466616',
image: {
url: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/files/Hydrogen_Hero_Feature_1.jpg?v=1654902468',
width: 2500,
height: 3155,
},
},
},
spreadSecondary: {
reference: {
mediaContentType: 'IMAGE',
alt: 'A snowboarder standing on a mountain top in choppy snow, shows off the back of his snowboard which reads Hydrogen in a cursive script.',
previewImage: {
url: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/files/Hydrogen_Hero_Feature_2.jpg?v=1654902468',
},
id: 'gid://shopify/MediaImage/29259478499384',
image: {
url: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/files/Hydrogen_Hero_Feature_2.jpg?v=1654902468',
width: 2500,
height: 3155,
},
},
},
height: 'full',
top: true,
loading: 'eager',
},
// secondaryHero
{
heading: {value: 'The Winter 2022 Collection'},
byline: {value: 'Just Dropped'},
cta: {value: 'Shop Now →'},
handle: 'winter-2022',
spread: {
reference: {
mediaContentType: 'IMAGE',
alt: 'Three young women in snowboarding attire embracing and laughing while snow falls around them',
previewImage: {
url: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/files/Collection_Feature_Wide.jpg?v=1654902160',
},
id: 'gid://shopify/MediaImage/29259478302776',
image: {
url: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/files/Collection_Feature_Wide.jpg?v=1654902160',
width: 5000,
height: 2500,
},
},
},
spreadSecondary: null,
},
// tertiaryHero
{
heading: {value: 'From the Slopes to the Chalet'},
byline: null,
cta: {value: 'Shop Now →'},
handle: 'backcountry',
spread: {
reference: {
mediaContentType: 'IMAGE',
alt: 'A skier hikes up a mountain through the snow with skis over their shoulder.',
previewImage: {
url: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/files/Chalet_Collection_Feature_1.jpg?v=1654902306',
},
id: 'gid://shopify/MediaImage/29259478368312',
image: {
url: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/files/Chalet_Collection_Feature_1.jpg?v=1654902306',
width: 2500,
height: 2500,
},
},
},
spreadSecondary: {
reference: {
mediaContentType: 'IMAGE',
alt: 'A snow covered lodge is illuminated by lights at night with a dark starry sky and mountain backdrop.',
previewImage: {
url: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/files/Chalet_Collection_Feature_2.jpg?v=1654902306',
},
id: 'gid://shopify/MediaImage/29259478401080',
image: {
url: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/files/Chalet_Collection_Feature_2.jpg?v=1654902306',
width: 2500,
height: 2500,
},
},
},
},
],
PRODUCT_INFO: [
{
title: 'Description',
content:
'We threw snow tires on our core classics... Good for all year round! Named after my favorite football match of the year. Just like any of our joints, dress them up or down...',
},
{
title: 'Size and Fit',
content:
'We threw snow tires on our core classics... Good for all year round! Named after my favorite football match of the year. Just like any of our joints, dress them up or down...',
},
{
title: 'Delivery and Returns',
content: `The towels had been hanging from the rod for years. They were stained and worn, and quite frankly, just plain ugly. Debra didn't want to touch them but she really didn't have a choice. It was important for her to see what was living within them. Patrick didn't want to go. The fact that she was insisting they must go made him want to go even less. He had no desire to make small talk with strangers he would never again see just to be polite. But she insisted that Patrick go, and she would soon find out that this would be the biggest mistake she could make in their relationship.`,
},
],
PRODUCT: {
label: 'Limited Edition',
id: 'gid://shopify/Product/6730850828344',
title: 'The Hydrogen',
publishedAt: '2021-06-17T18:33:17Z',
handle: 'snowboard',
description:
"Description Our flagship board, ideal for technical terrain and those who dare to go where the chairlift can't take you. The Hydrogen excels in the backcountry making riding out of bounds as easy as resort groomers. New for 2021, the Hydrogen Snowboard has Oxygen Pack inserts giving you more float on the deepest days. Care Guide Clean well after use Wax regularly Specs Weight: 5 lb Length: 4 ft Width: 1 ft Manufactured on: 8/2/2021, 3:30:00 PM Manufactured by: Shopify",
priceRange: {
minVariantPrice: {
amount: '775.0',
currencyCode: 'CAD',
},
maxVariantPrice: {
amount: '775.0',
currencyCode: 'CAD',
},
},
options: [
{
name: 'Color',
values: ['Morning', 'Evening', 'Night'],
},
{
name: 'Size',
values: ['154', '158', '160'],
},
],
variants: {
nodes: [
{
id: 'gid://shopify/ProductVariant/41007289630776',
image: {
url: 'https://cdn.shopify.com/s/files/1/0551/4566/0472/products/hydrogen-morning.jpg?v=1636146509',
altText: 'The Hydrogen snowboard, color Morning',
width: 1200,
height: 1504,
},
priceV2: {
amount: '775.0',
currencyCode: 'CAD',
},
compareAtPriceV2: {
amount: '840.0',
currencyCode: 'CAD',
},
},
],
},
},
};
// Return placeholders for collection Heros when metafields are not set
export function getHeroPlaceholder(heros: any[]) {
if (!heros?.length) return [];
// when we pass a collection without metafields,
// we merge it with placeholder data
return heros.map((hero, index) => {
// assume passed hero has metafields data already
if (hero?.heading?.value) {
return hero;
}
// hero placeholder
const placeholder = PLACEHOLDERS.HEROS[index];
// prioritize metafield data if available, else the hero hero values
// otherwise the placeholder values
const byLine =
hero?.byLine || hero?.descriptionHtml
? {value: hero.descriptionHtml}
: placeholder.byline;
const heading =
hero?.heading || hero?.title ? {value: hero.title} : placeholder.heading;
// merge hero placeholder with hero data
return {
heading,
byLine,
cta: hero?.cta || placeholder.cta,
handle: hero?.handle || placeholder.handle,
id: hero?.id || index,
spread: hero?.spread || placeholder.spread,
spreadSecondary: hero?.spreadSecondary || placeholder.spreadSecondary,
height: placeholder?.height || undefined,
top: placeholder?.top || undefined,
};
});
}
// get product info placeholder data
export function getProductInfoPlaceholder() {
function getMultipleRandom(arr: any[], infos: number) {
const shuffled = [...arr].sort(() => 0.5 - Math.random());
return shuffled.slice(0, infos);
}
return getMultipleRandom(PLACEHOLDERS.PRODUCT_INFO, 3);
}
export function getProductPlaceholder() {
return PLACEHOLDERS.PRODUCT;
}

View File

@@ -0,0 +1,274 @@
import React, {useCallback} from 'react';
import {useServerProps} from '@shopify/hydrogen';
import {
Menu,
MenuItem,
MoneyV2,
UserError,
} from '@shopify/hydrogen/storefront-api-types';
// @ts-expect-error types not available
import typographicBase from 'typographic-base';
/**
* This is a hack until we have better built-in primitives for
* causing server components to re-render.
*
* @returns function when called will cause the current page to re-render on the server
*/
export function useRenderServerComponents() {
const {serverProps, setServerProps} = useServerProps();
return useCallback(() => {
setServerProps('renderRsc', !serverProps.renderRsc);
}, [serverProps, setServerProps]);
}
export function missingClass(string?: string, prefix?: string) {
if (!string) {
return true;
}
const regex = new RegExp(` ?${prefix}`, 'g');
return string.match(regex) === null;
}
export function formatText(input?: string | React.ReactNode) {
if (!input) {
return;
}
if (typeof input !== 'string') {
return input;
}
return typographicBase(input, {locale: 'en-us'}).replace(
/\s([^\s<]+)\s*$/g,
'\u00A0$1',
);
}
export function isNewArrival(date: string, daysOld = 30) {
return (
new Date(date).valueOf() >
new Date().setDate(new Date().getDate() - daysOld).valueOf()
);
}
export function isDiscounted(price: MoneyV2, compareAtPrice: MoneyV2) {
if (compareAtPrice?.amount > price?.amount) {
return true;
}
return false;
}
export function getExcerpt(text: string) {
const regex = /<p.*>(.*?)<\/p>/;
const match = regex.exec(text);
return match?.length ? match[0] : text;
}
function resolveToFromType(
{
customPrefixes,
pathname,
type,
}: {
customPrefixes: Record<string, string>;
pathname?: string;
type?: string;
} = {
customPrefixes: {},
},
) {
if (!pathname || !type) return '';
/*
MenuItemType enum
@see: https://shopify.dev/api/storefront/unstable/enums/MenuItemType
*/
const defaultPrefixes = {
BLOG: 'blogs',
COLLECTION: 'collections',
COLLECTIONS: 'collections', // Collections All (not documented)
FRONTPAGE: 'frontpage',
HTTP: '',
PAGE: 'pages',
CATALOG: 'collections/all', // Products All
PRODUCT: 'products',
SEARCH: 'search',
SHOP_POLICY: 'policies',
};
const pathParts = pathname.split('/');
const handle = pathParts.pop() || '';
const routePrefix: Record<string, string> = {
...defaultPrefixes,
...customPrefixes,
};
switch (true) {
// special cases
case type === 'FRONTPAGE':
return '/';
case type === 'ARTICLE': {
const blogHandle = pathParts.pop();
return routePrefix.BLOG
? `/${routePrefix.BLOG}/${blogHandle}/${handle}/`
: `/${blogHandle}/${handle}/`;
}
case type === 'COLLECTIONS':
return `/${routePrefix.COLLECTIONS}`;
case type === 'SEARCH':
return `/${routePrefix.SEARCH}`;
case type === 'CATALOG':
return `/${routePrefix.CATALOG}`;
// common cases: BLOG, PAGE, COLLECTION, PRODUCT, SHOP_POLICY, HTTP
default:
return routePrefix[type]
? `/${routePrefix[type]}/${handle}`
: `/${handle}`;
}
}
/*
Parse each menu link and adding, isExternal, to and target
*/
function parseItem(customPrefixes = {}) {
return function (item: MenuItem): EnhancedMenuItem {
if (!item?.url || !item?.type) {
// eslint-disable-next-line no-console
console.warn('Invalid menu item. Must include a url and type.');
// @ts-ignore
return;
}
// extract path from url because we don't need the origin on internal to attributes
const {pathname} = new URL(item.url);
/*
Currently the MenuAPI only returns online store urls e.g — xyz.myshopify.com/..
Note: update logic when API is updated to include the active qualified domain
*/
const isInternalLink = /\.myshopify\.com/g.test(item.url);
const parsedItem = isInternalLink
? // internal links
{
...item,
isExternal: false,
target: '_self',
to: resolveToFromType({type: item.type, customPrefixes, pathname}),
}
: // external links
{
...item,
isExternal: true,
target: '_blank',
to: item.url,
};
return {
...parsedItem,
items: item.items?.map(parseItem(customPrefixes)),
};
};
}
export interface EnhancedMenuItem extends MenuItem {
to: string;
target: string;
isExternal?: boolean;
items: EnhancedMenuItem[];
}
export interface EnhancedMenu extends Menu {
items: EnhancedMenuItem[];
}
/*
Recursively adds `to` and `target` attributes to links based on their url
and resource type.
It optionally overwrites url paths based on item.type
*/
export function parseMenu(menu: Menu, customPrefixes = {}): EnhancedMenu {
if (!menu?.items) {
// eslint-disable-next-line no-console
console.warn('Invalid menu passed to parseMenu');
// @ts-ignore
return menu;
}
return {
...menu,
items: menu.items.map(parseItem(customPrefixes)),
};
}
export function getApiErrorMessage(
field: string,
data: Record<string, any>,
errors: UserError[],
) {
if (errors?.length) return errors[0].message ?? errors[0];
if (data?.[field]?.customerUserErrors?.length)
return data[field].customerUserErrors[0].message;
return null;
}
export function statusMessage(status: string) {
const translations: Record<string, string> = {
ATTEMPTED_DELIVERY: 'Attempted delivery',
CANCELED: 'Canceled',
CONFIRMED: 'Confirmed',
DELIVERED: 'Delivered',
FAILURE: 'Failure',
FULFILLED: 'Fulfilled',
IN_PROGRESS: 'In Progress',
IN_TRANSIT: 'In transit',
LABEL_PRINTED: 'Label printed',
LABEL_PURCHASED: 'Label purchased',
LABEL_VOIDED: 'Label voided',
MARKED_AS_FULFILLED: 'Marked as fulfilled',
NOT_DELIVERED: 'Not delivered',
ON_HOLD: 'On Hold',
OPEN: 'Open',
OUT_FOR_DELIVERY: 'Out for delivery',
PARTIALLY_FULFILLED: 'Partially Fulfilled',
PENDING_FULFILLMENT: 'Pending',
PICKED_UP: 'Displayed as Picked up',
READY_FOR_PICKUP: 'Ready for pickup',
RESTOCKED: 'Restocked',
SCHEDULED: 'Scheduled',
SUBMITTED: 'Submitted',
UNFULFILLED: 'Unfulfilled',
};
try {
return translations?.[status];
} catch (error) {
return status;
}
}
export function emailValidation(email: HTMLInputElement) {
if (email.validity.valid) return null;
return email.validity.valueMissing
? 'Please enter an email'
: 'Please enter a valid email';
}
export function passwordValidation(password: HTMLInputElement) {
if (password.validity.valid) return null;
if (password.validity.valueMissing) {
return 'Please enter a password';
}
return 'Password must be at least 6 characters';
}

View File

@@ -0,0 +1,22 @@
import {Suspense} from 'react';
import {useRouteParams, Seo} from '@shopify/hydrogen';
import {AccountActivateForm} from '~/components';
import {Layout} from '~/components/index.server';
/**
* This page shows a form for the user to activate an account.
* It should only be accessed by a link emailed to the user.
*/
export default function ActivateAccount() {
const {id, activationToken} = useRouteParams();
return (
<Layout>
<Suspense>
<Seo type="noindex" data={{title: 'Activate account'}} />
</Suspense>
<AccountActivateForm id={id} activationToken={activationToken} />
</Layout>
);
}

View File

@@ -0,0 +1,83 @@
import {
CacheNone,
gql,
type HydrogenApiRouteOptions,
type HydrogenRequest,
} from '@shopify/hydrogen';
import {getApiErrorMessage} from '~/lib/utils';
/**
* This API route is used by the form on `/account/activate/[id]/[activationToken]`
* complete the reset of the user's password.
*/
export async function api(
request: HydrogenRequest,
{session, queryShop}: HydrogenApiRouteOptions,
) {
if (!session) {
return new Response('Session storage not available.', {
status: 400,
});
}
const jsonBody = await request.json();
if (!jsonBody?.id || !jsonBody?.password || !jsonBody?.activationToken) {
return new Response(
JSON.stringify({error: 'Incorrect password or activation token.'}),
{
status: 400,
},
);
}
const {data, errors} = await queryShop<{
customerActivate: any;
}>({
query: CUSTOMER_ACTIVATE_MUTATION,
variables: {
id: `gid://shopify/Customer/${jsonBody.id}`,
input: {
password: jsonBody.password,
activationToken: jsonBody.activationToken,
},
},
// @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
cache: CacheNone(),
});
if (data?.customerActivate?.customerAccessToken?.accessToken) {
await session.set(
'customerAccessToken',
data.customerActivate.customerAccessToken.accessToken,
);
return new Response(null, {
status: 200,
});
} else {
return new Response(
JSON.stringify({
error: getApiErrorMessage('customerActivate', data, errors),
}),
{status: 401},
);
}
}
const CUSTOMER_ACTIVATE_MUTATION = gql`
mutation customerActivate($id: ID!, $input: CustomerActivateInput!) {
customerActivate(id: $id, input: $input) {
customerAccessToken {
accessToken
expiresAt
}
customerUserErrors {
code
field
message
}
}
}
`;

View File

@@ -0,0 +1,198 @@
import {
CacheNone,
gql,
type HydrogenApiRouteOptions,
type HydrogenRequest,
} from '@shopify/hydrogen';
import {getApiErrorMessage} from '~/lib/utils';
import type {Address} from './index.server';
export async function api(
request: HydrogenRequest,
{params, session, queryShop}: HydrogenApiRouteOptions,
) {
if (!session) {
return new Response('Session storage not available.', {
status: 400,
});
}
const {customerAccessToken} = await session.get();
if (!customerAccessToken) return new Response(null, {status: 401});
if (request.method === 'PATCH')
return updateAddress(customerAccessToken, request, params, queryShop);
if (request.method === 'DELETE')
return deleteAddress(customerAccessToken, params, queryShop);
return new Response(null, {
status: 405,
headers: {
Allow: 'PATCH,DELETE',
},
});
}
async function deleteAddress(
customerAccessToken: string,
params: HydrogenApiRouteOptions['params'],
queryShop: HydrogenApiRouteOptions['queryShop'],
) {
const {data, errors} = await queryShop<{
customerAddressDelete: any;
}>({
query: DELETE_ADDRESS_MUTATION,
variables: {
customerAccessToken,
id: decodeURIComponent(params.addressId),
},
// @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
cache: CacheNone(),
});
const error = getApiErrorMessage('customerAddressDelete', data, errors);
if (error) return new Response(JSON.stringify({error}), {status: 400});
return new Response(null);
}
async function updateAddress(
customerAccessToken: string,
request: HydrogenRequest,
params: HydrogenApiRouteOptions['params'],
queryShop: HydrogenApiRouteOptions['queryShop'],
) {
const {
firstName,
lastName,
company,
address1,
address2,
country,
province,
city,
zip,
phone,
isDefaultAddress,
} = await request.json();
const address: Address = {};
if (firstName) address.firstName = firstName;
if (lastName) address.lastName = lastName;
if (company) address.company = company;
if (address1) address.address1 = address1;
if (address2) address.address2 = address2;
if (country) address.country = country;
if (province) address.province = province;
if (city) address.city = city;
if (zip) address.zip = zip;
if (phone) address.phone = phone;
const {data, errors} = await queryShop<{
customerAddressUpdate: any;
}>({
query: UPDATE_ADDRESS_MUTATION,
variables: {
address,
customerAccessToken,
id: decodeURIComponent(params.addressId),
},
// @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
cache: CacheNone(),
});
const error = getApiErrorMessage('customerAddressUpdate', data, errors);
if (error) return new Response(JSON.stringify({error}), {status: 400});
if (isDefaultAddress) {
const {data, errors} = await setDefaultAddress(
queryShop,
decodeURIComponent(params.addressId),
customerAccessToken,
);
const error = getApiErrorMessage(
'customerDefaultAddressUpdate',
data,
errors,
);
if (error) return new Response(JSON.stringify({error}), {status: 400});
}
return new Response(null);
}
export function setDefaultAddress(
queryShop: HydrogenApiRouteOptions['queryShop'],
addressId: string,
customerAccessToken: string,
) {
return queryShop<{
customerDefaultAddressUpdate: any;
}>({
query: UPDATE_DEFAULT_ADDRESS_MUTATION,
variables: {
customerAccessToken,
addressId,
},
// @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
cache: CacheNone(),
});
}
const UPDATE_ADDRESS_MUTATION = gql`
mutation customerAddressUpdate(
$address: MailingAddressInput!
$customerAccessToken: String!
$id: ID!
) {
customerAddressUpdate(
address: $address
customerAccessToken: $customerAccessToken
id: $id
) {
customerUserErrors {
code
field
message
}
}
}
`;
const UPDATE_DEFAULT_ADDRESS_MUTATION = gql`
mutation customerDefaultAddressUpdate(
$addressId: ID!
$customerAccessToken: String!
) {
customerDefaultAddressUpdate(
addressId: $addressId
customerAccessToken: $customerAccessToken
) {
customerUserErrors {
code
field
message
}
}
}
`;
const DELETE_ADDRESS_MUTATION = gql`
mutation customerAddressDelete($customerAccessToken: String!, $id: ID!) {
customerAddressDelete(customerAccessToken: $customerAccessToken, id: $id) {
customerUserErrors {
code
field
message
}
deletedCustomerAddressId
}
}
`;

View File

@@ -0,0 +1,128 @@
import {setDefaultAddress} from './[addressId].server';
import {
CacheNone,
gql,
type HydrogenApiRouteOptions,
type HydrogenRequest,
} from '@shopify/hydrogen';
import {getApiErrorMessage} from '~/lib/utils';
export interface Address {
firstName?: string;
lastName?: string;
company?: string;
address1?: string;
address2?: string;
country?: string;
province?: string;
city?: string;
zip?: string;
phone?: string;
}
export async function api(
request: HydrogenRequest,
{session, queryShop}: HydrogenApiRouteOptions,
) {
if (request.method !== 'POST') {
return new Response(null, {
status: 405,
headers: {
Allow: 'POST',
},
});
}
if (!session) {
return new Response('Session storage not available.', {
status: 400,
});
}
const {customerAccessToken} = await session.get();
if (!customerAccessToken) return new Response(null, {status: 401});
const {
firstName,
lastName,
company,
address1,
address2,
country,
province,
city,
zip,
phone,
isDefaultAddress,
} = await request.json();
const address: Address = {};
if (firstName) address.firstName = firstName;
if (lastName) address.lastName = lastName;
if (company) address.company = company;
if (address1) address.address1 = address1;
if (address2) address.address2 = address2;
if (country) address.country = country;
if (province) address.province = province;
if (city) address.city = city;
if (zip) address.zip = zip;
if (phone) address.phone = phone;
const {data, errors} = await queryShop<{
customerAddressCreate: any;
}>({
query: CREATE_ADDRESS_MUTATION,
variables: {
address,
customerAccessToken,
},
// @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
cache: CacheNone(),
});
const error = getApiErrorMessage('customerAddressCreate', data, errors);
if (error) return new Response(JSON.stringify({error}), {status: 400});
if (isDefaultAddress) {
const {data: defaultDataResponse, errors} = await setDefaultAddress(
queryShop,
data.customerAddressCreate.customerAddress.id,
customerAccessToken,
);
const error = getApiErrorMessage(
'customerDefaultAddressUpdate',
defaultDataResponse,
errors,
);
if (error) return new Response(JSON.stringify({error}), {status: 400});
}
return new Response(null);
}
const CREATE_ADDRESS_MUTATION = gql`
mutation customerAddressCreate(
$address: MailingAddressInput!
$customerAccessToken: String!
) {
customerAddressCreate(
address: $address
customerAccessToken: $customerAccessToken
) {
customerAddress {
id
}
customerUserErrors {
code
field
message
}
}
}
`;

View File

@@ -0,0 +1,308 @@
import {Suspense} from 'react';
import {
CacheNone,
flattenConnection,
gql,
Seo,
useSession,
useLocalization,
useShopQuery,
type HydrogenRouteProps,
type HydrogenRequest,
type HydrogenApiRouteOptions,
} from '@shopify/hydrogen';
import {PRODUCT_CARD_FRAGMENT} from '~/lib/fragments';
import {getApiErrorMessage} from '~/lib/utils';
import {
AccountAddressBook,
AccountDetails,
AccountOrderHistory,
FeaturedCollections,
LogoutButton,
PageHeader,
} from '~/components';
import {Layout, ProductSwimlane} from '~/components/index.server';
import type {
Collection,
CollectionConnection,
Customer,
MailingAddress,
Order,
Product,
ProductConnection,
} from '@shopify/hydrogen/storefront-api-types';
export default function Account({response}: HydrogenRouteProps) {
response.cache(CacheNone());
const {
language: {isoCode: languageCode},
country: {isoCode: countryCode},
} = useLocalization();
const {customerAccessToken} = useSession();
if (!customerAccessToken) return response.redirect('/account/login');
const {data} = useShopQuery<{
customer: Customer;
featuredCollections: CollectionConnection;
featuredProducts: ProductConnection;
}>({
query: CUSTOMER_QUERY,
variables: {
customerAccessToken,
language: languageCode,
country: countryCode,
},
cache: CacheNone(),
});
const {customer, featuredCollections, featuredProducts} = data;
if (!customer) return response.redirect('/account/login');
const addresses = flattenConnection<MailingAddress>(customer.addresses).map(
(address) => ({
...address,
id: address.id!.substring(0, address.id!.lastIndexOf('?')),
originalId: address.id,
}),
);
const defaultAddress = customer?.defaultAddress?.id?.substring(
0,
customer.defaultAddress.id.lastIndexOf('?'),
);
return (
<>
<AuthenticatedAccount
customer={customer}
addresses={addresses}
defaultAddress={defaultAddress}
featuredCollections={
flattenConnection<Collection>(featuredCollections) as Collection[]
}
featuredProducts={
flattenConnection<Product>(featuredProducts) as Product[]
}
/>
</>
);
}
function AuthenticatedAccount({
customer,
addresses,
defaultAddress,
featuredCollections,
featuredProducts,
}: {
customer: Customer;
addresses: any[];
defaultAddress?: string;
featuredCollections: Collection[];
featuredProducts: Product[];
}) {
const orders = flattenConnection(customer?.orders) || [];
const heading = customer
? customer.firstName
? `Welcome, ${customer.firstName}.`
: `Welcome to your account.`
: 'Account Details';
return (
<Layout>
<Suspense>
<Seo type="noindex" data={{title: 'Account details'}} />
</Suspense>
<PageHeader heading={heading}>
<LogoutButton>Sign out</LogoutButton>
</PageHeader>
{orders && <AccountOrderHistory orders={orders as Order[]} />}
<AccountDetails
firstName={customer.firstName as string}
lastName={customer.lastName as string}
phone={customer.phone as string}
email={customer.email as string}
/>
<AccountAddressBook
defaultAddress={defaultAddress}
addresses={addresses}
/>
{!orders && (
<>
<FeaturedCollections
title="Popular Collections"
data={featuredCollections}
/>
<ProductSwimlane data={featuredProducts} />
</>
)}
</Layout>
);
}
export async function api(
request: HydrogenRequest,
{session, queryShop}: HydrogenApiRouteOptions,
) {
if (request.method !== 'PATCH' && request.method !== 'DELETE') {
return new Response(null, {
status: 405,
headers: {
Allow: 'PATCH,DELETE',
},
});
}
if (!session) {
return new Response('Session storage not available.', {
status: 400,
});
}
const {customerAccessToken} = await session.get();
if (!customerAccessToken) return new Response(null, {status: 401});
const {email, phone, firstName, lastName, newPassword} = await request.json();
interface Customer {
email?: string;
phone?: string;
firstName?: string;
lastName?: string;
password?: string;
}
const customer: Customer = {};
if (email) customer.email = email;
if (phone) customer.phone = phone;
if (firstName) customer.firstName = firstName;
if (lastName) customer.lastName = lastName;
if (newPassword) customer.password = newPassword;
const {data, errors} = await queryShop<{customerUpdate: any}>({
query: CUSTOMER_UPDATE_MUTATION,
variables: {
customer,
customerAccessToken,
},
// @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
cache: CacheNone(),
});
const error = getApiErrorMessage('customerUpdate', data, errors);
if (error) return new Response(JSON.stringify({error}), {status: 400});
return new Response(null);
}
const CUSTOMER_QUERY = gql`
${PRODUCT_CARD_FRAGMENT}
query CustomerDetails(
$customerAccessToken: String!
$country: CountryCode
$language: LanguageCode
) @inContext(country: $country, language: $language) {
customer(customerAccessToken: $customerAccessToken) {
firstName
lastName
phone
email
defaultAddress {
id
formatted
}
addresses(first: 6) {
edges {
node {
id
formatted
firstName
lastName
company
address1
address2
country
province
city
zip
phone
}
}
}
orders(first: 250, sortKey: PROCESSED_AT, reverse: true) {
edges {
node {
id
orderNumber
processedAt
financialStatus
fulfillmentStatus
currentTotalPrice {
amount
currencyCode
}
lineItems(first: 2) {
edges {
node {
variant {
image {
url
altText
height
width
}
}
title
}
}
}
}
}
}
}
featuredProducts: products(first: 12) {
nodes {
...ProductCard
}
}
featuredCollections: collections(first: 3, sortKey: UPDATED_AT) {
nodes {
id
title
handle
image {
altText
width
height
url
}
}
}
}
`;
const CUSTOMER_UPDATE_MUTATION = gql`
mutation customerUpdate(
$customer: CustomerUpdateInput!
$customerAccessToken: String!
) {
customerUpdate(
customer: $customer
customerAccessToken: $customerAccessToken
) {
customerUserErrors {
code
field
message
}
}
}
`;

View File

@@ -0,0 +1,114 @@
import {Suspense} from 'react';
import {
useShopQuery,
CacheLong,
CacheNone,
Seo,
gql,
type HydrogenRouteProps,
HydrogenRequest,
HydrogenApiRouteOptions,
} from '@shopify/hydrogen';
import {AccountLoginForm} from '~/components';
import {Layout} from '~/components/index.server';
export default function Login({response}: HydrogenRouteProps) {
response.cache(CacheNone());
const {
data: {
shop: {name},
},
} = useShopQuery({
query: SHOP_QUERY,
cache: CacheLong(),
preload: '*',
});
return (
<Layout>
<Suspense>
<Seo type="noindex" data={{title: 'Login'}} />
</Suspense>
<AccountLoginForm shopName={name} />
</Layout>
);
}
const SHOP_QUERY = gql`
query shopInfo {
shop {
name
}
}
`;
export async function api(
request: HydrogenRequest,
{session, queryShop}: HydrogenApiRouteOptions,
) {
if (!session) {
return new Response('Session storage not available.', {status: 400});
}
const jsonBody = await request.json();
if (
!jsonBody.email ||
jsonBody.email === '' ||
!jsonBody.password ||
jsonBody.password === ''
) {
return new Response(
JSON.stringify({error: 'Incorrect email or password.'}),
{status: 400},
);
}
const {data, errors} = await queryShop<{customerAccessTokenCreate: any}>({
query: LOGIN_MUTATION,
variables: {
input: {
email: jsonBody.email,
password: jsonBody.password,
},
},
// @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
cache: CacheNone(),
});
if (data?.customerAccessTokenCreate?.customerAccessToken?.accessToken) {
await session.set(
'customerAccessToken',
data.customerAccessTokenCreate.customerAccessToken.accessToken,
);
return new Response(null, {
status: 200,
});
} else {
return new Response(
JSON.stringify({
error: data?.customerAccessTokenCreate?.customerUserErrors ?? errors,
}),
{status: 401},
);
}
}
const LOGIN_MUTATION = gql`
mutation customerAccessTokenCreate($input: CustomerAccessTokenCreateInput!) {
customerAccessTokenCreate(input: $input) {
customerUserErrors {
code
field
message
}
customerAccessToken {
accessToken
expiresAt
}
}
}
`;

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