Compare commits

..

5 Commits

Author SHA1 Message Date
Gal Schlezinger
f88a968438 remove path.resolve 2022-05-10 10:20:28 +03:00
Gal Schlezinger
67ea370642 Update packages/sourcemapped/README.md
Co-authored-by: Nathan Rajlich <n@n8.io>
2022-05-10 10:19:33 +03:00
Gal Schlezinger
d2daf28f13 Add a README 2022-05-09 18:31:42 +03:00
Gal Schlezinger
dc91dbd034 add files to package.json 2022-05-09 18:15:24 +03:00
Gal Schlezinger
77908f965c add sourcemapped module 2022-05-09 18:08:55 +03:00
2350 changed files with 19711 additions and 194117 deletions

View File

@@ -19,13 +19,6 @@ 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
# node-bridge
packages/node-bridge/bridge.js
packages/node-bridge/launcher.js

4
.gitattributes vendored
View File

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

39
.github/CODEOWNERS vendored
View File

@@ -1,26 +1,29 @@
# Documentation
# https://help.github.com/en/articles/about-code-owners
* @TooTallNate @EndangeredMassa @styfle
/.github/workflows @TooTallNate @EndangeredMassa @styfle @ijjk
/packages/frameworks @TooTallNate @EndangeredMassa @styfle @AndyBitz
* @TooTallNate
/.github/workflows @AndyBitz @styfle
/packages/frameworks @AndyBitz
/packages/cli/src/commands/build @TooTallNate @styfle @AndyBitz @gdborton @jaredpalmer
/packages/cli/src/commands/dev @TooTallNate @styfle @AndyBitz
/packages/cli/src/util/dev @TooTallNate @styfle @AndyBitz
/packages/cli/src/commands/domains @javivelasco @mglagola @anatrajkovska
/packages/cli/src/commands/certs @javivelasco @mglagola @anatrajkovska
/packages/cli/src/commands/env @styfle @lucleray
/packages/client @TooTallNate @EndangeredMassa @styfle
/packages/build-utils @TooTallNate @EndangeredMassa @styfle @AndyBitz
/packages/client @styfle @TooTallNate
/packages/build-utils @styfle @AndyBitz @TooTallNate
/packages/middleware @gdborton @javivelasco
/packages/node @TooTallNate @EndangeredMassa @styfle
/packages/node-bridge @TooTallNate @EndangeredMassa @styfle @ijjk
/packages/next @TooTallNate @ijjk
/packages/go @TooTallNate @EndangeredMassa @styfle
/packages/python @TooTallNate @EndangeredMassa @styfle
/packages/ruby @TooTallNate @EndangeredMassa @styfle
/packages/static-build @TooTallNate @EndangeredMassa @styfle @AndyBitz
/packages/routing-utils @TooTallNate @EndangeredMassa @styfle @ijjk
/examples @leerob
/packages/node @styfle @TooTallNate @lucleray
/packages/node-bridge @styfle @TooTallNate @lucleray
/packages/next @Timer @ijjk
/packages/go @styfle @TooTallNate
/packages/python @styfle @TooTallNate
/packages/ruby @styfle @TooTallNate
/packages/static-build @styfle @AndyBitz
/packages/routing-utils @styfle @dav-is @ijjk
/examples @mcsdevv
/examples/create-react-app @Timer
/examples/nextjs @timneutkens @ijjk @styfle
/examples/hugo @styfle
/examples/jekyll @styfle
/examples/zola @styfle
/examples/nextjs @timneutkens @Timer
/examples/hugo @mcsdevv @styfle
/examples/jekyll @mcsdevv @styfle
/examples/zola @mcsdevv @styfle

View File

@@ -23,7 +23,7 @@ Make sure all the tests pass before making changes.
## Verifying your change
Once you are done with your changes (we even suggest doing it along the way), make sure all the tests still pass by running:
Once you are done with your changes (we even suggest doing it along the way), make sure all the test still pass by running:
```
yarn test-unit
@@ -64,7 +64,7 @@ Integration tests create deployments to your Vercel account using the `test` pro
x-now-trace=iad1]
```
In such cases, you can visit the URL of the failed deployment and append `/_logs` to see the build error. In the case above, that would be https://test-8ashcdlew.vercel.app/_logs
In such cases you can visit the URL of the failed deployment and append `/_logs` so see the build error. In the case above, that would be https://test-8ashcdlew.vercel.app/_logs
The logs of this deployment will contain the actual error which may help you to understand what went wrong.
@@ -82,11 +82,11 @@ nodeFileTrace(['path/to/entrypoint.js'], {
.then(e => console.error(e));
```
When you run this script, you'll see all the imported files. If anything file is missing, the bug is in [@vercel/nft](https://github.com/vercel/nft) and not the Builder.
When you run this script, you'll see all imported files. If anything file is missing, the bug is in [@vercel/nft](https://github.com/vercel/nft) and not the Builder.
## Deploy a Builder with existing project
Sometimes you want to test changes to a Builder against an existing project, maybe with `vercel dev` or actual deployment. You can avoid publishing every Builder change to npm by uploading the Builder as a tarball.
Sometimes you want to test changes to a Builder against an existing project, maybe with `vercel dev` or an actual deployment. You can avoid publishing every Builder change to npm by uploading the Builder as a tarball.
1. Change directory to the desired Builder `cd ./packages/node`
2. Run `yarn build` to compile typescript and other build steps

View File

@@ -13,5 +13,6 @@ jobs:
steps:
- uses: styfle/cancel-workflow-action@0.9.1
with:
workflow_id: test.yml, test-integration-cli.yml, test-unit.yml
workflow_id: 849295, 849296, 849297, 849298
access_token: ${{ github.token }}

View File

@@ -16,15 +16,9 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest]
node: [14]
node: [12]
runs-on: ${{ matrix.os }}
steps:
- name: Conditionally set remote env
if: github.event.pull_request.head.repo.full_name == github.repository
run: |
echo "TURBO_REMOTE_ONLY=true" >> $GITHUB_ENV
echo "TURBO_TEAM=vercel" >> $GITHUB_ENV
echo "TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}" >> $GITHUB_ENV
- uses: actions/setup-go@v2
with:
go-version: '1.13.15'

View File

@@ -0,0 +1,43 @@
name: Dev
on:
push:
branches:
- main
tags:
- '!*'
pull_request:
jobs:
test:
name: Dev
timeout-minutes: 75
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
node: [12]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-go@v2
with:
go-version: '1.13.15'
- uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node }}
- uses: actions/checkout@v2
with:
fetch-depth: 100
- run: git --version
- run: git fetch origin main --depth=100
- run: git fetch origin ${{ github.ref }} --depth=100
- run: git diff origin/main...HEAD --name-only
- name: Install Hugo
if: matrix.os == 'macos-latest'
run: curl -L -O https://github.com/gohugoio/hugo/releases/download/v0.56.0/hugo_0.56.0_macOS-64bit.tar.gz && tar -xzf hugo_0.56.0_macOS-64bit.tar.gz && mv ./hugo packages/cli/test/dev/fixtures/08-hugo/
- run: yarn install --network-timeout 1000000
- run: yarn run build
- run: yarn test-integration-dev
env:
VERCEL_TEAM_TOKEN: ${{ secrets.VERCEL_TEAM_TOKEN }}
VERCEL_REGISTRATION_URL: ${{ secrets.VERCEL_REGISTRATION_URL }}

View File

@@ -0,0 +1,35 @@
name: E2E
on:
push:
branches:
- main
tags:
- '!*'
pull_request:
jobs:
test:
name: E2E
timeout-minutes: 120
runs-on: ubuntu-latest
steps:
- uses: actions/setup-go@v2
with:
go-version: '1.13.15'
- uses: actions/setup-node@v2
with:
node-version: 12
- uses: actions/checkout@v2
with:
fetch-depth: 100
- run: git --version
- run: git fetch origin main --depth=100
- run: git fetch origin ${{ github.ref }} --depth=100
- run: git diff origin/main...HEAD --name-only
- run: yarn install --network-timeout 1000000
- run: yarn run build
- run: yarn test-integration-once
env:
VERCEL_TEAM_TOKEN: ${{ secrets.VERCEL_TEAM_TOKEN }}
VERCEL_REGISTRATION_URL: ${{ secrets.VERCEL_REGISTRATION_URL }}

View File

@@ -16,15 +16,9 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [14]
node: [12]
runs-on: ${{ matrix.os }}
steps:
- name: Conditionally set remote env
if: github.event.pull_request.head.repo.full_name == github.repository
run: |
echo "TURBO_REMOTE_ONLY=true" >> $GITHUB_ENV
echo "TURBO_TEAM=vercel" >> $GITHUB_ENV
echo "TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}" >> $GITHUB_ENV
- uses: actions/setup-go@v2
with:
go-version: '1.13.15'
@@ -41,9 +35,9 @@ jobs:
- run: yarn install --network-timeout 1000000
- run: yarn run build
- run: yarn run lint
if: matrix.os == 'ubuntu-latest' && matrix.node == 14 # only run lint once
if: matrix.os == 'ubuntu-latest' && matrix.node == 12 # only run lint once
- run: yarn run test-unit
- run: yarn workspace vercel run coverage
if: matrix.os == 'ubuntu-latest' && matrix.node == 14 # only run coverage once
if: matrix.os == 'ubuntu-latest' && matrix.node == 12 # only run coverage once
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -1,90 +0,0 @@
name: Tests
on:
push:
branches:
- main
tags:
- '!*'
pull_request:
env:
NODE_VERSION: '14'
jobs:
setup:
name: Find Changes
runs-on: ubuntu-latest
outputs:
tests: ${{ steps['set-tests'].outputs['tests'] }}
steps:
- uses: actions/checkout@v2
- run: git --version
- run: git fetch origin main
- uses: actions/setup-node@v2
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'yarn'
- run: yarn install --network-timeout 1000000
- id: set-tests
run: |
TESTS_ARRAY=$(node utils/chunk-tests.js $SCRIPT_NAME)
echo "Files to test:"
echo "$TESTS_ARRAY"
echo "::set-output name=tests::$TESTS_ARRAY"
test:
timeout-minutes: 120
runs-on: ${{ matrix.runner }}
name: ${{matrix.scriptName}} (${{matrix.packageName}}, ${{matrix.chunkNumber}}, ${{ matrix.runner }})
if: ${{ needs.setup.outputs['tests'] != '[]' }}
needs:
- setup
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(needs.setup.outputs['tests']) }}
steps:
- name: Conditionally set remote env
if: github.event.pull_request.head.repo.full_name == github.repository
run: |
echo "TURBO_REMOTE_ONLY=true" >> $GITHUB_ENV
echo "TURBO_TEAM=vercel" >> $GITHUB_ENV
echo "TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}" >> $GITHUB_ENV
- uses: actions/checkout@v2
with:
fetch-depth: 2
- uses: actions/setup-go@v2
with:
go-version: '1.13.15'
- uses: actions/setup-node@v2
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'yarn'
- name: Install Hugo
if: matrix.runner == 'macos-latest'
run: curl -L -O https://github.com/gohugoio/hugo/releases/download/v0.56.0/hugo_0.56.0_macOS-64bit.tar.gz && tar -xzf hugo_0.56.0_macOS-64bit.tar.gz && mv ./hugo packages/cli/test/dev/fixtures/08-hugo/
- run: yarn install --network-timeout 1000000
- name: Build ${{matrix.packageName}} and all its dependencies
run: yarn turbo run build --cache-dir=".turbo" --scope=${{matrix.packageName}} --include-dependencies --no-deps
env:
FORCE_COLOR: '1'
- name: Test ${{matrix.packageName}}
run: node_modules/.bin/turbo run test --cache-dir=".turbo" --scope=${{matrix.packageName}} --no-deps -- ${{ join(matrix.testPaths, ' ') }}
shell: bash
env:
VERCEL_TEAM_TOKEN: ${{ secrets.VERCEL_TEAM_TOKEN }}
VERCEL_REGISTRATION_URL: ${{ secrets.VERCEL_REGISTRATION_URL }}
FORCE_COLOR: '1'
conclusion:
needs:
- test
runs-on: ubuntu-latest
name: E2E
steps:
- name: Done
run: echo "Done."

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,7 @@
"description": "API for the vercel/vercel repo",
"main": "index.js",
"scripts": {
"//TODO": "We should add this pkg to yarn workspaces",
"vercel-build": "cd .. && yarn install && yarn vercel-build"
"vercel-build": "node ../utils/run.js build all"
},
"dependencies": {
"@sentry/node": "5.11.1",

View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "ES2020",
"target": "esnext",
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -48,6 +48,6 @@
"qunit-dom": "^0.8.4"
},
"engines": {
"node": "14.x"
"node": "8.* || >= 10.*"
}
}

View File

@@ -1,18 +0,0 @@
{
"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

@@ -1,8 +0,0 @@
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',
},
};

View File

@@ -1,79 +0,0 @@
# 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

@@ -1,50 +0,0 @@
# 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

@@ -1,18 +0,0 @@
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

@@ -1,17 +0,0 @@
<!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

@@ -1,49 +0,0 @@
{
"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

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

View File

@@ -1,48 +0,0 @@
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

@@ -1,28 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 690 B

View File

@@ -1,135 +0,0 @@
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

@@ -1,22 +0,0 @@
// 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

@@ -1,37 +0,0 @@
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

@@ -1,30 +0,0 @@
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

@@ -1,183 +0,0 @@
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

@@ -1,161 +0,0 @@
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

@@ -1,337 +0,0 @@
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

@@ -1,175 +0,0 @@
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

@@ -1,72 +0,0 @@
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

@@ -1,66 +0,0 @@
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

@@ -1,342 +0,0 @@
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

@@ -1,285 +0,0 @@
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

@@ -1,38 +0,0 @@
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

@@ -1,189 +0,0 @@
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

@@ -1,134 +0,0 @@
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

@@ -1,11 +0,0 @@
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

@@ -1,38 +0,0 @@
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

@@ -1,36 +0,0 @@
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

@@ -1,87 +0,0 @@
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

@@ -1,126 +0,0 @@
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

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

View File

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

View File

@@ -1,100 +0,0 @@
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

@@ -1,85 +0,0 @@
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

@@ -1,103 +0,0 @@
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

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

View File

@@ -1,42 +0,0 @@
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

@@ -1,44 +0,0 @@
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

@@ -1,45 +0,0 @@
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

@@ -1,236 +0,0 @@
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

@@ -1,24 +0,0 @@
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

@@ -1,20 +0,0 @@
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

@@ -1,62 +0,0 @@
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

@@ -1,24 +0,0 @@
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

@@ -1,57 +0,0 @@
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

@@ -1,9 +0,0 @@
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

@@ -1,18 +0,0 @@
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

@@ -1,117 +0,0 @@
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

@@ -1,46 +0,0 @@
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

@@ -1,63 +0,0 @@
// @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

@@ -1,230 +0,0 @@
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

@@ -1,129 +0,0 @@
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

@@ -1,43 +0,0 @@
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

@@ -1,43 +0,0 @@
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

@@ -1,105 +0,0 @@
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

@@ -1,38 +0,0 @@
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

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

View File

@@ -1,5 +0,0 @@
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

@@ -1,5 +0,0 @@
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

@@ -1,10 +0,0 @@
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

@@ -1,54 +0,0 @@
// @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

@@ -1,144 +0,0 @@
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

@@ -1,106 +0,0 @@
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

@@ -1,113 +0,0 @@
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

@@ -1,141 +0,0 @@
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

@@ -1,5 +0,0 @@
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

@@ -1,65 +0,0 @@
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

@@ -1,33 +0,0 @@
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

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

View File

@@ -1,55 +0,0 @@
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

@@ -1,143 +0,0 @@
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

@@ -1,16 +0,0 @@
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

@@ -1,147 +0,0 @@
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

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

View File

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

View File

@@ -1,10 +0,0 @@
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

@@ -1,66 +0,0 @@
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
}
}
}
}
`;

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