Compare commits

...

75 Commits

Author SHA1 Message Date
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
745 changed files with 112096 additions and 12009 deletions

View File

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

4
.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
# https://prettier.io/docs/en/ignore.html
# ignore this file with an intentional syntax error
packages/cli/test/dev/fixtures/edge-function-error/api/edge-error-syntax.js

View File

@@ -24,7 +24,6 @@ export function sendToVercelAnalytics(metric) {
speed: getConnectionSpeed(),
};
console.log({ body });
const blob = new Blob([new URLSearchParams(body).toString()], {
// This content type is necessary for `sendBeacon`
type: 'application/x-www-form-urlencoded',

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

View File

@@ -0,0 +1,25 @@
import type {HydrogenApiRouteOptions, HydrogenRequest} from '@shopify/hydrogen';
export async function api(
request: HydrogenRequest,
{session}: HydrogenApiRouteOptions,
) {
if (request.method !== 'POST') {
return new Response('Post required to logout', {
status: 405,
headers: {
Allow: 'POST',
},
});
}
if (!session) {
return new Response('Session storage not available.', {
status: 400,
});
}
await session.set('customerAccessToken', '');
return new Response();
}

View File

@@ -0,0 +1,442 @@
import {
CacheNone,
flattenConnection,
gql,
type HydrogenRouteProps,
Image,
Link,
Money,
Seo,
useRouteParams,
useSession,
useLocalization,
useShopQuery,
} from '@shopify/hydrogen';
import type {
Customer,
DiscountApplication,
DiscountApplicationConnection,
Order,
OrderLineItem,
} from '@shopify/hydrogen/storefront-api-types';
import {Suspense} from 'react';
import {Text, PageHeader, Heading} from '~/components';
import {Layout} from '~/components/index.server';
import {statusMessage} from '~/lib/utils';
export default function OrderDetails({response}: HydrogenRouteProps) {
const {id} = useRouteParams();
response.cache(CacheNone());
const {
language: {isoCode: languageCode},
country: {isoCode: countryCode},
} = useLocalization();
const {customerAccessToken} = useSession();
if (!customerAccessToken) return response.redirect('/account/login');
if (!id) return response.redirect('/account/');
const {data} = useShopQuery<{
customer?: Customer;
}>({
query: ORDER_QUERY,
variables: {
customerAccessToken,
orderId: `id:${id}`,
language: languageCode,
country: countryCode,
},
cache: CacheNone(),
});
const [order] = flattenConnection<Order>(data?.customer?.orders ?? {}) || [
null,
];
if (!order) return null;
const lineItems = flattenConnection<OrderLineItem>(order.lineItems!);
const discountApplications = flattenConnection<DiscountApplication>(
order.discountApplications as DiscountApplicationConnection,
);
const firstDiscount = discountApplications[0]?.value;
const discountValue =
firstDiscount?.__typename === 'MoneyV2' && firstDiscount;
const discountPercentage =
firstDiscount?.__typename === 'PricingPercentageValue' &&
firstDiscount?.percentage;
return (
<Layout>
<Suspense>
<Seo type="noindex" data={{title: `Order ${order.name}`}} />
</Suspense>
<PageHeader heading={`Order detail`}>
<Link to="/account">
<Text color="subtle">Return to Account Overview</Text>
</Link>
</PageHeader>
<div className="w-full p-6 sm:grid-cols-1 md:p-8 lg:p-12 lg:py-6">
<div>
<Text as="h3" size="lead">
Order No. {order.name}
</Text>
<Text className="mt-2" as="p">
Placed on {new Date(order.processedAt!).toDateString()}
</Text>
<div className="grid items-start gap-12 sm:grid-cols-1 md:grid-cols-4 md:gap-16 sm:divide-y sm:divide-gray-200">
<table className="min-w-full my-8 divide-y divide-gray-300 md:col-span-3">
<thead>
<tr className="align-baseline ">
<th
scope="col"
className="pb-4 pl-0 pr-3 font-semibold text-left"
>
Product
</th>
<th
scope="col"
className="hidden px-4 pb-4 font-semibold text-right sm:table-cell md:table-cell"
>
Price
</th>
<th
scope="col"
className="hidden px-4 pb-4 font-semibold text-right sm:table-cell md:table-cell"
>
Quantity
</th>
<th
scope="col"
className="px-4 pb-4 font-semibold text-right"
>
Total
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{lineItems.map((lineItem) => (
<tr key={lineItem.variant!.id}>
<td className="w-full py-4 pl-0 pr-3 align-top sm:align-middle max-w-0 sm:w-auto sm:max-w-none">
<div className="flex gap-6">
<Link
to={`/products/${lineItem.variant!.product!.handle}`}
>
{lineItem?.variant?.image && (
<div className="w-24 card-image aspect-square">
<Image
src={lineItem.variant.image.src!}
width={lineItem.variant.image.width!}
height={lineItem.variant.image.height!}
alt={lineItem.variant.image.altText!}
loaderOptions={{
scale: 2,
crop: 'center',
}}
/>
</div>
)}
</Link>
<div className="flex-col justify-center hidden lg:flex">
<Text as="p">{lineItem.title}</Text>
<Text size="fine" className="mt-1" as="p">
{lineItem.variant!.title}
</Text>
</div>
<dl className="grid">
<dt className="sr-only">Product</dt>
<dd className="truncate lg:hidden">
<Heading size="copy" format as="h3">
{lineItem.title}
</Heading>
<Text size="fine" className="mt-1">
{lineItem.variant!.title}
</Text>
</dd>
<dt className="sr-only">Price</dt>
<dd className="truncate sm:hidden">
<Text size="fine" className="mt-4">
<Money data={lineItem.variant!.priceV2!} />
</Text>
</dd>
<dt className="sr-only">Quantity</dt>
<dd className="truncate sm:hidden">
<Text className="mt-1" size="fine">
Qty: {lineItem.quantity}
</Text>
</dd>
</dl>
</div>
</td>
<td className="hidden px-3 py-4 text-right align-top sm:align-middle sm:table-cell">
<Money data={lineItem.variant!.priceV2!} />
</td>
<td className="hidden px-3 py-4 text-right align-top sm:align-middle sm:table-cell">
{lineItem.quantity}
</td>
<td className="px-3 py-4 text-right align-top sm:align-middle sm:table-cell">
<Text>
<Money data={lineItem.discountedTotalPrice!} />
</Text>
</td>
</tr>
))}
</tbody>
<tfoot>
{((discountValue && discountValue.amount) ||
discountPercentage) && (
<tr>
<th
scope="row"
colSpan={3}
className="hidden pt-6 pl-6 pr-3 font-normal text-right sm:table-cell md:pl-0"
>
<Text>Discounts</Text>
</th>
<th
scope="row"
className="pt-6 pr-3 font-normal text-left sm:hidden"
>
<Text>Discounts</Text>
</th>
<td className="pt-6 pl-3 pr-4 font-medium text-right text-green-700 md:pr-3">
{discountPercentage ? (
<span className="text-sm">
-{discountPercentage}% OFF
</span>
) : (
discountValue && <Money data={discountValue!} />
)}
</td>
</tr>
)}
<tr>
<th
scope="row"
colSpan={3}
className="hidden pt-6 pl-6 pr-3 font-normal text-right sm:table-cell md:pl-0"
>
<Text>Subtotal</Text>
</th>
<th
scope="row"
className="pt-6 pr-3 font-normal text-left sm:hidden"
>
<Text>Subtotal</Text>
</th>
<td className="pt-6 pl-3 pr-4 text-right md:pr-3">
<Money data={order.subtotalPriceV2!} />
</td>
</tr>
<tr>
<th
scope="row"
colSpan={3}
className="hidden pt-4 pl-6 pr-3 font-normal text-right sm:table-cell md:pl-0"
>
Tax
</th>
<th
scope="row"
className="pt-4 pr-3 font-normal text-left sm:hidden"
>
<Text>Tax</Text>
</th>
<td className="pt-4 pl-3 pr-4 text-right md:pr-3">
<Money data={order.totalTaxV2!} />
</td>
</tr>
<tr>
<th
scope="row"
colSpan={3}
className="hidden pt-4 pl-6 pr-3 font-semibold text-right sm:table-cell md:pl-0"
>
Total
</th>
<th
scope="row"
className="pt-4 pr-3 font-semibold text-left sm:hidden"
>
<Text>Total</Text>
</th>
<td className="pt-4 pl-3 pr-4 font-semibold text-right md:pr-3">
<Money data={order.totalPriceV2!} />
</td>
</tr>
</tfoot>
</table>
<div className="sticky border-none top-nav md:my-8">
<Heading size="copy" className="font-semibold" as="h3">
Shipping Address
</Heading>
{order?.shippingAddress ? (
<ul className="mt-6">
<li>
<Text>
{order.shippingAddress.firstName &&
order.shippingAddress.firstName + ' '}
{order.shippingAddress.lastName}
</Text>
</li>
{order?.shippingAddress?.formatted ? (
order.shippingAddress.formatted.map((line) => (
<li key={line}>
<Text>{line}</Text>
</li>
))
) : (
<></>
)}
</ul>
) : (
<p className="mt-3">No shipping address defined</p>
)}
<Heading size="copy" className="mt-8 font-semibold" as="h3">
Status
</Heading>
<div
className={`mt-3 px-3 py-1 text-xs font-medium rounded-full inline-block w-auto ${
order.fulfillmentStatus === 'FULFILLED'
? 'bg-green-100 text-green-800'
: 'bg-primary/20 text-primary/50'
}`}
>
<Text size="fine">
{statusMessage(order.fulfillmentStatus!)}
</Text>
</div>
</div>
</div>
</div>
</div>
</Layout>
);
}
// @see: https://shopify.dev/api/storefront/2022-07/objects/Order#fields
const ORDER_QUERY = gql`
fragment Money on MoneyV2 {
amount
currencyCode
}
fragment AddressFull on MailingAddress {
address1
address2
city
company
country
countryCodeV2
firstName
formatted
id
lastName
name
phone
province
provinceCode
zip
}
fragment DiscountApplication on DiscountApplication {
value {
... on MoneyV2 {
amount
currencyCode
}
... on PricingPercentageValue {
percentage
}
}
}
fragment Image on Image {
altText
height
src: url(transform: {crop: CENTER, maxHeight: 96, maxWidth: 96, scale: 2})
id
width
}
fragment ProductVariant on ProductVariant {
image {
...Image
}
priceV2 {
...Money
}
product {
handle
}
sku
title
}
fragment LineItemFull on OrderLineItem {
title
quantity
discountAllocations {
allocatedAmount {
...Money
}
discountApplication {
...DiscountApplication
}
}
originalTotalPrice {
...Money
}
discountedTotalPrice {
...Money
}
variant {
...ProductVariant
}
}
query orderById(
$customerAccessToken: String!
$orderId: String!
$country: CountryCode
$language: LanguageCode
) @inContext(country: $country, language: $language) {
customer(customerAccessToken: $customerAccessToken) {
orders(first: 1, query: $orderId) {
nodes {
id
name
orderNumber
processedAt
fulfillmentStatus
totalTaxV2 {
...Money
}
totalPriceV2 {
...Money
}
subtotalPriceV2 {
...Money
}
shippingAddress {
...AddressFull
}
discountApplications(first: 100) {
nodes {
...DiscountApplication
}
}
lineItems(first: 100) {
nodes {
...LineItemFull
}
}
}
}
}
}
`;

View File

@@ -0,0 +1,71 @@
import {Suspense} from 'react';
import {
CacheNone,
Seo,
gql,
type HydrogenRequest,
type HydrogenApiRouteOptions,
type HydrogenRouteProps,
} from '@shopify/hydrogen';
import {AccountRecoverForm} from '~/components';
import {Layout} from '~/components/index.server';
/**
* A form for the user to fill out to _initiate_ a password reset.
* If the form succeeds, an email will be sent to the user with a link
* to reset their password. Clicking the link leads the user to the
* page `/account/reset/[resetToken]`.
*/
export default function AccountRecover({response}: HydrogenRouteProps) {
response.cache(CacheNone());
return (
<Layout>
<Suspense>
<Seo type="noindex" data={{title: 'Recover password'}} />
</Suspense>
<AccountRecoverForm />
</Layout>
);
}
export async function api(
request: HydrogenRequest,
{queryShop}: HydrogenApiRouteOptions,
) {
const jsonBody = await request.json();
if (!jsonBody.email || jsonBody.email === '') {
return new Response(JSON.stringify({error: 'Email required'}), {
status: 400,
});
}
await queryShop({
query: CUSTOMER_RECOVER_MUTATION,
variables: {
email: jsonBody.email,
},
// @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
cache: CacheNone(),
});
// Ignore errors, we don't want to tell the user if the email was
// valid or not, thereby allowing them to determine who uses the site
return new Response(null, {
status: 200,
});
}
const CUSTOMER_RECOVER_MUTATION = gql`
mutation customerRecover($email: String!) {
customerRecover(email: $email) {
customerUserErrors {
code
field
message
}
}
}
`;

View File

@@ -0,0 +1,95 @@
import {Suspense} from 'react';
import {
CacheNone,
Seo,
gql,
type HydrogenRequest,
type HydrogenApiRouteOptions,
type HydrogenRouteProps,
} from '@shopify/hydrogen';
import {AccountCreateForm} from '~/components';
import {Layout} from '~/components/index.server';
import {getApiErrorMessage} from '~/lib/utils';
export default function Register({response}: HydrogenRouteProps) {
response.cache(CacheNone());
return (
<Layout>
<Suspense>
<Seo type="noindex" data={{title: 'Register'}} />
</Suspense>
<AccountCreateForm />
</Layout>
);
}
export async function api(
request: HydrogenRequest,
{queryShop}: HydrogenApiRouteOptions,
) {
const jsonBody = await request.json();
if (
!jsonBody.email ||
jsonBody.email === '' ||
!jsonBody.password ||
jsonBody.password === ''
) {
return new Response(
JSON.stringify({error: 'Email and password are required'}),
{status: 400},
);
}
const {data, errors} = await queryShop<{customerCreate: any}>({
query: CUSTOMER_CREATE_MUTATION,
variables: {
input: {
email: jsonBody.email,
password: jsonBody.password,
firstName: jsonBody.firstName,
lastName: jsonBody.lastName,
},
},
// @ts-expect-error `queryShop.cache` is not yet supported but soon will be.
cache: CacheNone(),
});
const errorMessage = getApiErrorMessage('customerCreate', data, errors);
if (
!errorMessage &&
data &&
data.customerCreate &&
data.customerCreate.customer &&
data.customerCreate.customer.id
) {
return new Response(null, {
status: 200,
});
} else {
return new Response(
JSON.stringify({
error: errorMessage ?? 'Unknown error',
}),
{status: 401},
);
}
}
const CUSTOMER_CREATE_MUTATION = gql`
mutation customerCreate($input: CustomerCreateInput!) {
customerCreate(input: $input) {
customer {
id
}
customerUserErrors {
code
field
message
}
}
}
`;

View File

@@ -0,0 +1,23 @@
import {Suspense} from 'react';
import {useRouteParams, Seo} from '@shopify/hydrogen';
import {AccountPasswordResetForm} from '~/components';
import {Layout} from '~/components/index.server';
/**
* This page shows a form for the user to enter a new password.
* It should only be accessed by a link emailed to the user after
* they initiate a password reset from `/account/recover`.
*/
export default function ResetPassword() {
const {id, resetToken} = useRouteParams();
return (
<Layout>
<Suspense>
<Seo type="noindex" data={{title: 'Reset password'}} />
</Suspense>
<AccountPasswordResetForm id={id} resetToken={resetToken} />
</Layout>
);
}

View File

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

View File

@@ -0,0 +1,36 @@
import {
useShopQuery,
gql,
CacheLong,
type HydrogenRouteProps,
} from '@shopify/hydrogen';
import type {Shop} from '@shopify/hydrogen/storefront-api-types';
/*
This route redirects you to your Shopify Admin
by querying for your myshopify.com domain.
Learn more about the redirect method here:
https://developer.mozilla.org/en-US/docs/Web/API/Response/redirect
*/
export default function AdminRedirect({response}: HydrogenRouteProps) {
const {data} = useShopQuery<{
shop: Shop;
}>({
query: SHOP_QUERY,
cache: CacheLong(),
});
const {url} = data.shop.primaryDomain;
return response.redirect(`${url}/admin`);
}
const SHOP_QUERY = gql`
query {
shop {
primaryDomain {
url
}
}
}
`;

View File

@@ -0,0 +1,37 @@
import {gql} from '@shopify/hydrogen';
import type {HydrogenApiRouteOptions, HydrogenRequest} from '@shopify/hydrogen';
import {ProductConnection} from '@shopify/hydrogen/storefront-api-types';
import {PRODUCT_CARD_FRAGMENT} from '~/lib/fragments';
export async function api(
_request: HydrogenRequest,
{queryShop}: HydrogenApiRouteOptions,
) {
const {
data: {products},
} = await queryShop<{
products: ProductConnection;
}>({
query: TOP_PRODUCTS_QUERY,
variables: {
count: 4,
},
});
return products.nodes;
}
const TOP_PRODUCTS_QUERY = gql`
${PRODUCT_CARD_FRAGMENT}
query topProducts(
$count: Int
$countryCode: CountryCode
$languageCode: LanguageCode
) @inContext(country: $countryCode, language: $languageCode) {
products(first: $count, sortKey: BEST_SELLING) {
nodes {
...ProductCard
}
}
}
`;

View File

@@ -0,0 +1,34 @@
import {gql} from '@shopify/hydrogen';
import type {HydrogenApiRouteOptions, HydrogenRequest} from '@shopify/hydrogen';
import type {Localization} from '@shopify/hydrogen/storefront-api-types';
export async function api(
_request: HydrogenRequest,
{queryShop}: HydrogenApiRouteOptions,
) {
const {
data: {
localization: {availableCountries},
},
} = await queryShop<{
localization: Localization;
}>({
query: COUNTRIES_QUERY,
});
return availableCountries.sort((a, b) => a.name.localeCompare(b.name));
}
const COUNTRIES_QUERY = gql`
query Localization {
localization {
availableCountries {
isoCode
name
currency {
isoCode
}
}
}
}
`;

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