mirror of
https://github.com/vercel/commerce.git
synced 2025-05-19 07:56:59 +00:00
commit
6902e053a0
@ -1,23 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
tab_width = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.js]
|
||||
quote_type = single
|
||||
|
||||
[{*.c,*.cc,*.h,*.hh,*.cpp,*.hpp,*.m,*.mm,*.mpp,*.js,*.java,*.go,*.rs,*.php,*.ng,*.jsx,*.ts,*.d,*.cs,*.swift}]
|
||||
curly_bracket_next_line = false
|
||||
spaces_around_operators = true
|
||||
spaces_around_brackets = outside
|
||||
# close enough to 1TB
|
||||
indent_brace_style = K&R
|
7
.env.example
Normal file
7
.env.example
Normal file
@ -0,0 +1,7 @@
|
||||
COMPANY_NAME="Vercel Inc."
|
||||
TWITTER_CREATOR="@vercel"
|
||||
TWITTER_SITE="https://nextjs.org/commerce"
|
||||
SITE_NAME="Next.js Commerce"
|
||||
SHOPIFY_REVALIDATION_SECRET=
|
||||
SHOPIFY_STOREFRONT_ACCESS_TOKEN=
|
||||
SHOPIFY_STORE_DOMAIN=
|
@ -1,22 +0,0 @@
|
||||
# Available providers: bigcommerce, shopify, swell
|
||||
COMMERCE_PROVIDER=
|
||||
|
||||
BIGCOMMERCE_STOREFRONT_API_URL=
|
||||
BIGCOMMERCE_STOREFRONT_API_TOKEN=
|
||||
BIGCOMMERCE_STORE_API_URL=
|
||||
BIGCOMMERCE_STORE_API_TOKEN=
|
||||
BIGCOMMERCE_STORE_API_CLIENT_ID=
|
||||
BIGCOMMERCE_CHANNEL_ID=
|
||||
BIGCOMMERCE_STORE_URL=
|
||||
BIGCOMMERCE_STORE_API_STORE_HASH=
|
||||
BIGCOMMERCE_STORE_API_CLIENT_SECRET=
|
||||
|
||||
|
||||
NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN=
|
||||
NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=
|
||||
|
||||
NEXT_PUBLIC_SWELL_STORE_ID=
|
||||
NEXT_PUBLIC_SWELL_PUBLIC_KEY=
|
||||
|
||||
NEXT_PUBLIC_SALEOR_API_URL=
|
||||
NEXT_PUBLIC_SALEOR_CHANNEL=
|
23
.eslintrc.js
Normal file
23
.eslintrc.js
Normal file
@ -0,0 +1,23 @@
|
||||
module.exports = {
|
||||
extends: ['next', 'prettier'],
|
||||
plugins: ['unicorn'],
|
||||
rules: {
|
||||
'no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
args: 'after-used',
|
||||
caughtErrors: 'none',
|
||||
ignoreRestSiblings: true,
|
||||
vars: 'all'
|
||||
}
|
||||
],
|
||||
'prefer-const': 'error',
|
||||
'react-hooks/exhaustive-deps': 'error',
|
||||
'unicorn/filename-case': [
|
||||
'error',
|
||||
{
|
||||
case: 'kebabCase'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
6
.github/dependabot.yml
vendored
Normal file
6
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: 'weekly'
|
32
.github/workflows/test.yml
vendored
Normal file
32
.github/workflows/test.yml
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
name: test
|
||||
on: pull_request
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Cancel running workflows
|
||||
uses: styfle/cancel-workflow-action@0.11.0
|
||||
with:
|
||||
access_token: ${{ github.token }}
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Set node version
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
- name: Set pnpm version
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
run_install: false
|
||||
version: 7
|
||||
- name: Cache node_modules
|
||||
id: node-modules-cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: '**/node_modules'
|
||||
key: node-modules-cache-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
- name: Install dependencies
|
||||
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm install
|
||||
- name: Run tests
|
||||
run: pnpm test
|
20
.gitignore
vendored
20
.gitignore
vendored
@ -1,16 +1,17 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
.playwright
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
out/
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
@ -18,19 +19,20 @@ out/
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
.idea
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
@ -1,3 +1,3 @@
|
||||
node_modules
|
||||
.vercel
|
||||
.next
|
||||
public
|
||||
pnpm-lock.yaml
|
||||
|
14
.prettierrc
14
.prettierrc
@ -1,14 +0,0 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["framework/saleor/**/*"],
|
||||
"options": {
|
||||
"printWidth": 120
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["esbenp.prettier-vscode"]
|
||||
}
|
28
.vscode/launch.json
vendored
Normal file
28
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Next.js: debug server-side",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm dev"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug client-side",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:3000"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug full stack",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm dev",
|
||||
"serverReadyAction": {
|
||||
"pattern": "started server on .+, url: (https?://.+)",
|
||||
"uriFormat": "%s",
|
||||
"action": "debugWithChrome"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
9
.vscode/settings.json
vendored
Normal file
9
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": true,
|
||||
"source.organizeImports": true,
|
||||
"source.sortMembers": true
|
||||
}
|
||||
}
|
392
README.md
392
README.md
@ -1,155 +1,277 @@
|
||||
[](https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fcommerce&project-name=commerce&repo-name=commerce&demo-title=Next.js%20Commerce&demo-description=An%20all-in-one%20starter%20kit%20for%20high-performance%20e-commerce%20sites.&demo-url=https%3A%2F%2Fdemo.vercel.store&demo-image=https%3A%2F%2Fbigcommerce-demo-asset-ksvtgfvnd.vercel.app%2Fbigcommerce.png&integration-ids=oac_MuWZiE4jtmQ2ejZQaQ7ncuDT)
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fcommerce&project-name=commerce&repo-name=commerce&demo-title=Next.js%20Commerce&demo-url=https%3A%2F%2Fdemo.vercel.store&demo-image=https%3A%2F%2Fbigcommerce-demo-asset-ksvtgfvnd.vercel.app%2Fbigcommerce.png&env=COMPANY_NAME,SHOPIFY_REVALIDATION_SECRET,SHOPIFY_STORE_DOMAIN,SHOPIFY_STOREFRONT_ACCESS_TOKEN,SITE_NAME,TWITTER_CREATOR,TWITTER_SITE)
|
||||
|
||||
# Next.js Commerce
|
||||
|
||||
The all-in-one starter kit for high-performance e-commerce sites. With a few clicks, Next.js developers can clone, deploy and fully customize their own store.
|
||||
Start right now at [nextjs.org/commerce](https://nextjs.org/commerce)
|
||||
A Next.js 13 and App Router-ready ecommerce template featuring:
|
||||
|
||||
Demo live at: [demo.vercel.store](https://demo.vercel.store/)
|
||||
- Next.js App Router
|
||||
- Optimized for SEO using Next.js's Metadata
|
||||
- React Server Components (RSCs) and Suspense
|
||||
- Server Actions for mutations
|
||||
- Edge Runtime
|
||||
- New fetching and caching paradigms
|
||||
- Dynamic OG images
|
||||
- Styling with Tailwind CSS
|
||||
- Checkout and payments with Shopify
|
||||
- Automatic light/dark mode based on system settings
|
||||
|
||||
- Shopify Demo: https://shopify.vercel.store/
|
||||
- Swell Demo: https://swell.vercel.store/
|
||||
- BigCommerce Demo: https://bigcommerce.vercel.store/
|
||||
- Vendure Demo: https://vendure.vercel.store
|
||||
- Saleor Demo: https://saleor.vercel.store/
|
||||
<h3 id="v1-note"></h3>
|
||||
|
||||
## Features
|
||||
> Note: Looking for Next.js Commerce v1? View the [code](https://github.com/vercel/commerce/tree/v1), [demo](https://commerce-v1.vercel.store), and [release notes](https://github.com/vercel/commerce/releases/tag/v1).
|
||||
|
||||
- Performant by default
|
||||
- SEO Ready
|
||||
- Internationalization
|
||||
- Responsive
|
||||
- UI Components
|
||||
- Theming
|
||||
- Standardized Data Hooks
|
||||
- Integrations - Integrate seamlessly with the most common ecommerce platforms.
|
||||
- Dark Mode Support
|
||||
## Providers
|
||||
|
||||
## Integrations
|
||||
Vercel will only be actively maintaining a Shopify version [as outlined in our vision and strategy for Next.js Commerce](https://github.com/vercel/commerce/pull/966).
|
||||
|
||||
Next.js Commerce integrates out-of-the-box with BigCommerce, Shopify, Swell, Saleor and Vendure. We plan to support all major ecommerce backends.
|
||||
Vercel is more than happy to partner and work with any commerce provider to help them get a similar template up and running and listed below. Alternative providers should be able to fork this repository and swap out the `lib/shopify` file with their own implementation while leaving the rest of the template mostly unchanged.
|
||||
|
||||
## Considerations
|
||||
- Shopify (this repository)
|
||||
- [BigCommerce](https://github.com/bigcommerce/nextjs-commerce) ([Demo](https://next-commerce-v2.vercel.app/))
|
||||
- [Medusa](https://github.com/medusajs/vercel-commerce) ([Demo](https://medusa-nextjs-commerce.vercel.app/))
|
||||
- [Saleor](https://github.com/saleor/nextjs-commerce) ([Demo](https://saleor-commerce.vercel.app/))
|
||||
- [Swell](https://github.com/swellstores/verswell-commerce) ([Demo](https://verswell-commerce.vercel.app/))
|
||||
|
||||
- `framework/commerce` contains all types, helpers and functions to be used as base to build a new **provider**.
|
||||
- **Providers** live under `framework`'s root folder and they will extend Next.js Commerce types and functionality (`framework/commerce`).
|
||||
- We have a **Features API** to ensure feature parity between the UI and the Provider. The UI should update accordingly and no extra code should be bundled. All extra configuration for features will live under `features` in `commerce.config.json` and if needed it can also be accessed programatically.
|
||||
- Each **provider** should add its corresponding `next.config.js` and `commerce.config.json` adding specific data related to the provider. For example in case of BigCommerce, the images CDN and additional API routes.
|
||||
- **Providers don't depend on anything that's specific to the application they're used in**. They only depend on `framework/commerce`, on their own framework folder and on some dependencies included in `package.json`
|
||||
## Running locally
|
||||
|
||||
## Configuration
|
||||
You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js Commerce. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/concepts/projects/environment-variables) for this, but a `.env` file is all that is necessary.
|
||||
|
||||
### How to change providers
|
||||
|
||||
Open `.env.local` and change the value of `COMMERCE_PROVIDER` to the provider you would like to use, then set the environment variables for that provider (use `.env.template` as the base).
|
||||
|
||||
The setup for Shopify would look like this for example:
|
||||
|
||||
```
|
||||
COMMERCE_PROVIDER=shopify
|
||||
NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN=xxxxxxx.myshopify.com
|
||||
```
|
||||
|
||||
And check that the `tsconfig.json` resolves to the chosen provider:
|
||||
|
||||
```
|
||||
"@framework": ["framework/shopify"],
|
||||
"@framework/*": ["framework/shopify/*"]
|
||||
```
|
||||
|
||||
That's it!
|
||||
|
||||
### Features
|
||||
|
||||
Every provider defines the features that it supports under `framework/{provider}/commerce.config.json`
|
||||
|
||||
#### Features Available
|
||||
|
||||
The following features can be enabled or disabled. This means that the UI will remove all code related to the feature.
|
||||
For example: Turning `cart` off will disable Cart capabilities.
|
||||
|
||||
- cart
|
||||
- search
|
||||
- wishlist
|
||||
- customerAuth
|
||||
- customCheckout
|
||||
|
||||
#### How to turn Features on and off
|
||||
|
||||
> NOTE: The selected provider should support the feature that you are toggling. (This means that you can't turn wishlist on if the provider doesn't support this functionality out the box)
|
||||
|
||||
- Open `commerce.config.json`
|
||||
- You'll see a config file like this:
|
||||
```json
|
||||
{
|
||||
"features": {
|
||||
"wishlist": false,
|
||||
"customCheckout": true
|
||||
}
|
||||
}
|
||||
```
|
||||
- Turn `wishlist` on by setting `wishlist` to `true`.
|
||||
- Run the app and the wishlist functionality should be back on.
|
||||
|
||||
### How to create a new provider
|
||||
|
||||
Follow our docs for [Adding a new Commerce Provider](framework/commerce/new-provider.md).
|
||||
|
||||
If you succeeded building a provider, submit a PR with a valid demo and we'll review it asap.
|
||||
|
||||
## Contribute
|
||||
|
||||
Our commitment to Open Source can be found [here](https://vercel.com/oss).
|
||||
|
||||
1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
||||
2. Create a new branch `git checkout -b MY_BRANCH_NAME`
|
||||
3. Install yarn: `npm install -g yarn`
|
||||
4. Install the dependencies: `yarn`
|
||||
5. Duplicate `.env.template` and rename it to `.env.local`
|
||||
6. Add proper store values to `.env.local`
|
||||
7. Run `yarn dev` to build and watch for code changes
|
||||
|
||||
## Work in progress
|
||||
|
||||
We're using Github Projects to keep track of issues in progress and todo's. Here is our [Board](https://github.com/vercel/commerce/projects/1)
|
||||
|
||||
People actively working on this project: @okbel & @lfades.
|
||||
|
||||
## Troubleshoot
|
||||
|
||||
<details>
|
||||
<summary>I already own a BigCommerce store. What should I do?</summary>
|
||||
<br>
|
||||
First thing you do is: <b>set your environment variables</b>
|
||||
<br>
|
||||
<br>
|
||||
.env.local
|
||||
|
||||
```sh
|
||||
BIGCOMMERCE_STOREFRONT_API_URL=<>
|
||||
BIGCOMMERCE_STOREFRONT_API_TOKEN=<>
|
||||
BIGCOMMERCE_STORE_API_URL=<>
|
||||
BIGCOMMERCE_STORE_API_TOKEN=<>
|
||||
BIGCOMMERCE_STORE_API_CLIENT_ID=<>
|
||||
BIGCOMMERCE_CHANNEL_ID=<>
|
||||
```
|
||||
|
||||
If your project was started with a "Deploy with Vercel" button, you can use Vercel's CLI to retrieve these credentials.
|
||||
> Note: You should not commit your `.env` file or it will expose secrets that will allow others to control your Shopify store.
|
||||
|
||||
1. Install Vercel CLI: `npm i -g vercel`
|
||||
2. Link local instance with Vercel and Github accounts (creates .vercel file): `vercel link`
|
||||
3. Download your environment variables: `vercel env pull .env.local`
|
||||
2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link`
|
||||
3. Download your environment variables: `vercel env pull`
|
||||
|
||||
Next, you're free to customize the starter. More updates coming soon. Stay tuned.
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
</details>
|
||||
Your app should now be running on [localhost:3000](http://localhost:3000/).
|
||||
|
||||
<details>
|
||||
<summary>BigCommerce shows a Coming Soon page and requests a Preview Code</summary>
|
||||
<br>
|
||||
After Email confirmation, Checkout should be manually enabled through BigCommerce platform. Look for "Review & test your store" section through BigCommerce's dashboard.
|
||||
<br>
|
||||
<br>
|
||||
BigCommerce team has been notified and they plan to add more detailed about this subject.
|
||||
<summary>Expand if you work at Vercel and want to run locally and / or contribute</summary>
|
||||
|
||||
1. Run `vc link`.
|
||||
1. Select the `Vercel Solutions` scope.
|
||||
1. Connect to the existing `commerce-shopify` project.
|
||||
1. Run `vc env pull` to get environment variables.
|
||||
1. Run `pmpm dev` to ensure everything is working correctly.
|
||||
</details>
|
||||
|
||||
## How to configure your Shopify store for Next.js Commerce
|
||||
|
||||
Next.js Commerce requires a [paid Shopify plan](https://www.shopify.com/pricing).
|
||||
|
||||
> Note: Next.js Commerce will not work with a Shopify Starter plan as it does not allow installation of custom themes, which is required to run as a headless storefront.
|
||||
|
||||
### Add Shopify domain to an environment variable
|
||||
|
||||
Create a `SHOPIFY_STORE_DOMAIN` environment variable and use your Shopify domain as the the value (ie. `[your-shopify-store-subdomain].myshopify.com`).
|
||||
|
||||
> Note: Do not include the `https://`.
|
||||
|
||||
### Accessing the Shopify Storefront API
|
||||
|
||||
Next.js Commerce utilizes [Shopify's Storefront API](https://shopify.dev/docs/api/storefront) to create unique customer experiences. The API offers a full range of commerce options making it possible for customers to control products, collections, menus, pages, cart, checkout, and more.
|
||||
|
||||
In order to use the Shopify's Storefront API, you need to install the [Headless app](https://apps.shopify.com/headless) in your Shopify store.
|
||||
|
||||
Once installed, you'll need to create a `SHOPIFY_STOREFRONT_ACCESS_TOKEN` environment variable and use the public access token as the value.
|
||||
|
||||
> Note: Shopify does offer a Node.js Storefront API SDK. We use the Storefront API via GraphQL directly instead of the Node.js SDK so we have more control over fetching and caching.
|
||||
|
||||
<details>
|
||||
<summary>Expand to view detailed walkthrough</summary>
|
||||
|
||||
1. Navigate to `https://[your-shopify-store-subdomain].myshopify.com/admin/settings/apps`.
|
||||
1. Click the green `Shopify App Store` button.
|
||||

|
||||
1. Search for `Headless` and click on the `Headless` app.
|
||||

|
||||
1. Click the black `Add app` button.
|
||||

|
||||
1. Click the green `Add sales channel` button.
|
||||

|
||||
1. Click the green `Create storefront` button.
|
||||

|
||||
1. Copy and paste the public access token and assign it to a `SHOPIFY_STOREFRONT_ACCESS_TOKEN` environment variable.
|
||||

|
||||
1. If you ever need to reference the public access token again, you can navigate to `https://[your-shopify-store-subdomain].myshopify.com/admin/headless_storefronts`.
|
||||
</details>
|
||||
|
||||
### Install a headless theme
|
||||
|
||||
When using a headless Shopify setup, you normally don't want customers to access any of the theme pages except for checkout. However, you can't totally disable the theme and a lot of links will still point to the theme (e.g. links in emails, order details, plugins, checkout, etc.).
|
||||
|
||||
To enable a seamless flow between your headless site and Shopify, you can install the [Shopify Headless Theme](https://github.com/instantcommerce/shopify-headless-theme).
|
||||
|
||||
Follow the installation instructions and configure the theme with your headless site's values.
|
||||
|
||||
<details>
|
||||
<summary>Expand to view detailed walkthrough</summary>
|
||||
|
||||
1. Download [Shopify Headless Theme](https://github.com/instantcommerce/shopify-headless-theme).
|
||||

|
||||
1. Navigate to `https://[your-shopify-store-subdomain].myshopify.com/admin/themes`.
|
||||
1. Click `Add theme`, then `Upload zip file`.
|
||||

|
||||
1. Select the downloaded zip file from above, and click the green `Upload file` button.
|
||||

|
||||
1. Click `Customize`.
|
||||

|
||||
1. Click `Theme settings` (ie. the paintbrush icon), expand the `STOREFRONT` section, enter your headless store domain, click the gray `Publish` button.
|
||||

|
||||
1. Confirm the theme change by clicking the green `Save and publish` button.
|
||||

|
||||
1. The headless theme should now be your current active theme.
|
||||

|
||||
</details>
|
||||
|
||||
### Branding & Design
|
||||
|
||||
Since you're creating a headless Shopify store, you'll be in full control of your brand and design. However, there are still a few aspects we're leaving within Shopify's control.
|
||||
|
||||
- Checkout
|
||||
- Emails
|
||||
- Order status
|
||||
- Order history
|
||||
- Favicon (for any Shopify controlled pages)
|
||||
|
||||
You can use Shopify's admin to customize these pages to match your brand and design.
|
||||
|
||||
<details>
|
||||
<summary>Expand to view detailed walkthrough</summary>
|
||||
|
||||
#### Checkout, order status, and order history
|
||||
|
||||
1. Navigate to `https://[your-shopify-store-subdomain].myshopify.com/admin/settings/checkout`.
|
||||
1. Click the green `Customize` button.
|
||||

|
||||
1. Click `Branding` (ie. the paintbrush icon) and customize your brand. Please note, there are three steps / pages to the checkout flow. Use the dropdown to change pages and adjust branding as needed on each page. Click `Save` when you are done.
|
||||

|
||||
1. Navigate to `https://[your-shopify-store-subdomain].myshopify.com/admin/settings/branding`.
|
||||
1. Customize settings to match your brand.
|
||||

|
||||
|
||||
#### Emails
|
||||
|
||||
1. Navigate to `https://[your-shopify-store-subdomain].myshopify.com/admin/settings/email_settings`.
|
||||
1. Customize settings to match your brand.
|
||||

|
||||
|
||||
#### Favicon
|
||||
|
||||
1. Navigate to `https://[your-shopify-store-subdomain].myshopify.com/admin/themes`.
|
||||
1. Click the green `Customize` button.
|
||||

|
||||
1. Click `Theme settings` (ie. the paintbrush icon), expand the `FAVICON` section, upload favicon, then click the `Save` button.
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
### Configure webhooks for on-demand incremental static regeneration (ISR)
|
||||
|
||||
Utilizing [Shopify's webhooks](https://shopify.dev/docs/apps/webhooks), and listening for select [Shopify webhook event topics](https://shopify.dev/docs/api/admin-rest/2022-04/resources/webhook#event-topics), we can use [Next'js on-demand revalidation](https://nextjs.org/docs/app/building-your-application/data-fetching/revalidating#using-on-demand-revalidation) to keep data fetches indefinitely cached until certain events in the Shopify store occur.
|
||||
|
||||
Next.js is pre-configured to listen for the following Shopify webhook events and automatically revalidate fetches.
|
||||
|
||||
- `collections/create`
|
||||
- `collections/delete`
|
||||
- `collections/update`
|
||||
- `products/create`
|
||||
- `products/delete`
|
||||
- `products/update` (this also includes when variants are added, updated, and removed as well as when products are purchased so inventory and out of stocks can be updated)
|
||||
|
||||
<details>
|
||||
<summary>Expand to view detailed walkthrough</summary>
|
||||
|
||||
#### Setup secret for secure revalidation
|
||||
|
||||
1. Create your own secret or [generate a random UUID](https://www.uuidgenerator.net/guid).
|
||||
1. Create a [Vercel Environment Variable](https://vercel.com/docs/concepts/projects/environment-variables) named `SHOPIFY_REVALIDATION_SECRET` and use the value from above.
|
||||
|
||||
#### Configure Shopify webhooks
|
||||
|
||||
1. Navigate to `https://[your-shopify-store-subdomain].myshopify.com/admin/settings/notifications`.
|
||||
1. Add webhooks for all six event topics listed above. You can add more sets for other preview urls, environments, or local development. Append `?secret=[SECRET]` to each url, where `[SECRET]` is the secret you created above.
|
||||

|
||||

|
||||
|
||||
#### Testing webhooks during local development
|
||||
|
||||
The easiest way to test webhooks while developing locally is to use [ngrok](https://ngrok.com).
|
||||
|
||||
1. [Install and configure ngrok](https://ngrok.com/download) (you will need to create an account).
|
||||
1. Run your app locally, `npm run dev`.
|
||||
1. In a separate terminal session, run `ngrok http 3000`.
|
||||
1. Use the url generated by ngrok and add or update your webhook urls in Shopify.
|
||||

|
||||

|
||||
1. You can now make changes to your store and your local app should receive updates. You can also use the `Send test notification` button to trigger a generic webhook test.
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
### Using Shopify as a CMS
|
||||
|
||||
Next.js Commerce is fully powered by Shopify in a truly headless and data driven way.
|
||||
|
||||
#### Products
|
||||
|
||||
`https://[your-shopify-store-subdomain].myshopify.com/admin/products`
|
||||
|
||||
Only `Active` products are shown. `Draft` products will not be shown until they are marked as `Active`.
|
||||
|
||||
`Active` products can still be hidden and not seen by navigating the site, by adding a `nextjs-frontend-hidden` tag on the product. This tag will also tell search engines to not index or crawl the product. The product is still directly accessible via url. This feature is great for "secret" products you only want to people you share the url with.
|
||||
|
||||
Product options and option combinations are driven from Shopify options and variants. When selecting options on the product detail page, other option and variant combinations will be visually validated and verified for availability, like Amazon does.
|
||||
|
||||
Products that are active and "out of stock" are still shown on the site, but the ability to add the product to the cart is disabled.
|
||||
|
||||
#### Collections
|
||||
|
||||
`https://[your-shopify-store-subdomain].myshopify.com/admin/collections`
|
||||
|
||||
Create whatever collections you want and configure them however you want. All available collections will show on the search page as filters on the left, with one exception...
|
||||
|
||||
Any collection names that start with the word "hidden" will not show up on the headless front end. The Next.js Commerce theme comes pre-configured to look for two hidden collections. Collections were chosen for this over tags so that order of products could be controlled (collections allow for manual ordering).
|
||||
|
||||
Create the following collections:
|
||||
|
||||
- `Hidden: Homepage Featured Items` -- Products in this collection are displayed in the three featured blocks on the homepage.
|
||||
- `Hidden: Homepage Carousel` -- Products in this collection are displayed in the auto-scrolling carousel section on the homepage.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
#### Pages
|
||||
|
||||
`https://[your-shopify-store-subdomain].myshopify.com/admin/pages`
|
||||
|
||||
Next.js Commerce contains a dynamic `[page]` route. It will use the value to look for a corresponding page in Shopify. If a page is found, it will display its rich content using Tailwind's prose. If a page is not found, a 404 page is displayed.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
#### Navigation menus
|
||||
|
||||
`https://[your-shopify-store-subdomain].myshopify.com/admin/menus`
|
||||
|
||||
Next.js Commerce's header and footer navigation is pre-configured to be controlled by Shopify navigation menus. This means you have full control over what links go here. They can be to collections, pages, external links, and more.
|
||||
|
||||
Create the following navigation menus:
|
||||
|
||||
- `Next.js Frontend Header Menu` -- Menu items to be shown in the headless frontend header.
|
||||
- `Next.js Frontend Footer Menu` -- Menu items to be shown in the headless frontend footer.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
#### SEO
|
||||
|
||||
Shopify's products, collections, pages, etc. allow you to create custom SEO titles and descriptions. Next.js Commerce is pre-configured to display these custom values, but also comes with sensible default fallbacks if they are not provided.
|
||||
|
||||

|
||||
|
15
app/[page]/layout.tsx
Normal file
15
app/[page]/layout.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import Footer from 'components/layout/footer';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Suspense>
|
||||
<div className="w-full">
|
||||
<div className="mx-8 max-w-2xl py-20 sm:mx-auto">
|
||||
<Suspense>{children}</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
11
app/[page]/opengraph-image.tsx
Normal file
11
app/[page]/opengraph-image.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import OpengraphImage from 'components/opengraph-image';
|
||||
import { getPage } from 'lib/shopify';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export default async function Image({ params }: { params: { page: string } }) {
|
||||
const page = await getPage(params.page);
|
||||
const title = page.seo?.title || page.title;
|
||||
|
||||
return await OpengraphImage({ title });
|
||||
}
|
49
app/[page]/page.tsx
Normal file
49
app/[page]/page.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import Prose from 'components/prose';
|
||||
import { getPage } from 'lib/shopify';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export const revalidate = 43200; // 12 hours in seconds
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: { page: string };
|
||||
}): Promise<Metadata> {
|
||||
const page = await getPage(params.page);
|
||||
|
||||
if (!page) return notFound();
|
||||
|
||||
return {
|
||||
title: page.seo?.title || page.title,
|
||||
description: page.seo?.description || page.bodySummary,
|
||||
openGraph: {
|
||||
publishedTime: page.createdAt,
|
||||
modifiedTime: page.updatedAt,
|
||||
type: 'article'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params }: { params: { page: string } }) {
|
||||
const page = await getPage(params.page);
|
||||
|
||||
if (!page) return notFound();
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="mb-8 text-5xl font-bold">{page.title}</h1>
|
||||
<Prose className="mb-8" html={page.body as string} />
|
||||
<p className="text-sm italic">
|
||||
{`This document was last updated on ${new Intl.DateTimeFormat(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}).format(new Date(page.updatedAt))}.`}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
37
app/api/revalidate/route.ts
Normal file
37
app/api/revalidate/route.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { TAGS } from 'lib/constants';
|
||||
import { revalidateTag } from 'next/cache';
|
||||
import { headers } from 'next/headers';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
// We always need to respond with a 200 status code to Shopify,
|
||||
// otherwise it will continue to retry the request.
|
||||
export async function POST(req: NextRequest): Promise<Response> {
|
||||
const collectionWebhooks = ['collections/create', 'collections/delete', 'collections/update'];
|
||||
const productWebhooks = ['products/create', 'products/delete', 'products/update'];
|
||||
const topic = headers().get('x-shopify-topic') || 'unknown';
|
||||
const secret = req.nextUrl.searchParams.get('secret');
|
||||
const isCollectionUpdate = collectionWebhooks.includes(topic);
|
||||
const isProductUpdate = productWebhooks.includes(topic);
|
||||
|
||||
if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) {
|
||||
console.error('Invalid revalidation secret.');
|
||||
return NextResponse.json({ status: 200 });
|
||||
}
|
||||
|
||||
if (!isCollectionUpdate && !isProductUpdate) {
|
||||
// We don't need to revalidate anything for any other topics.
|
||||
return NextResponse.json({ status: 200 });
|
||||
}
|
||||
|
||||
if (isCollectionUpdate) {
|
||||
revalidateTag(TAGS.collections);
|
||||
}
|
||||
|
||||
if (isProductUpdate) {
|
||||
revalidateTag(TAGS.products);
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: 200, revalidated: true, now: Date.now() });
|
||||
}
|
10
app/error.tsx
Normal file
10
app/error.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
'use client';
|
||||
|
||||
export default function Error({ reset }: { reset: () => void }) {
|
||||
return (
|
||||
<div>
|
||||
<h2>Something went wrong.</h2>
|
||||
<button onClick={() => reset()}>Try again</button>
|
||||
</div>
|
||||
);
|
||||
}
|
Before Width: | Height: | Size: 535 B After Width: | Height: | Size: 535 B |
21
app/globals.css
Normal file
21
app/globals.css
Normal file
@ -0,0 +1,21 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (font: -apple-system-body) and (-webkit-appearance: none) {
|
||||
img[loading='lazy'] {
|
||||
clip-path: inset(0.6px);
|
||||
}
|
||||
}
|
||||
|
||||
a,
|
||||
input,
|
||||
button {
|
||||
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-neutral-600 dark:focus-visible:ring-offset-neutral-900;
|
||||
}
|
44
app/layout.tsx
Normal file
44
app/layout.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import Navbar from 'components/layout/navbar';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { ReactNode, Suspense } from 'react';
|
||||
import './globals.css';
|
||||
|
||||
const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env;
|
||||
|
||||
export const metadata = {
|
||||
title: {
|
||||
default: SITE_NAME,
|
||||
template: `%s | ${SITE_NAME}`
|
||||
},
|
||||
robots: {
|
||||
follow: true,
|
||||
index: true
|
||||
},
|
||||
...(TWITTER_CREATOR &&
|
||||
TWITTER_SITE && {
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
creator: TWITTER_CREATOR,
|
||||
site: TWITTER_SITE
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--font-inter'
|
||||
});
|
||||
|
||||
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className={inter.variable}>
|
||||
<body className="bg-neutral-50 text-black selection:bg-teal-300 dark:bg-neutral-900 dark:text-white dark:selection:bg-pink-500 dark:selection:text-white">
|
||||
<Navbar />
|
||||
<Suspense>
|
||||
<main>{children}</main>
|
||||
</Suspense>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
7
app/opengraph-image.tsx
Normal file
7
app/opengraph-image.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import OpengraphImage from 'components/opengraph-image';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export default async function Image() {
|
||||
return await OpengraphImage();
|
||||
}
|
27
app/page.tsx
Normal file
27
app/page.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { Carousel } from 'components/carousel';
|
||||
import { ThreeItemGrid } from 'components/grid/three-items';
|
||||
import Footer from 'components/layout/footer';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export const metadata = {
|
||||
description: 'High-performance ecommerce store built with Next.js, Vercel, and Shopify.',
|
||||
openGraph: {
|
||||
type: 'website'
|
||||
}
|
||||
};
|
||||
|
||||
export default async function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<ThreeItemGrid />
|
||||
<Suspense>
|
||||
<Carousel />
|
||||
<Suspense>
|
||||
<Footer />
|
||||
</Suspense>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
143
app/product/[handle]/page.tsx
Normal file
143
app/product/[handle]/page.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { GridTileImage } from 'components/grid/tile';
|
||||
import Footer from 'components/layout/footer';
|
||||
import { Gallery } from 'components/product/gallery';
|
||||
import { ProductDescription } from 'components/product/product-description';
|
||||
import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
|
||||
import { getProduct, getProductRecommendations } from 'lib/shopify';
|
||||
import { Image } from 'lib/shopify/types';
|
||||
import Link from 'next/link';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: { handle: string };
|
||||
}): Promise<Metadata> {
|
||||
const product = await getProduct(params.handle);
|
||||
|
||||
if (!product) return notFound();
|
||||
|
||||
const { url, width, height, altText: alt } = product.featuredImage || {};
|
||||
const hide = !product.tags.includes(HIDDEN_PRODUCT_TAG);
|
||||
|
||||
return {
|
||||
title: product.seo.title || product.title,
|
||||
description: product.seo.description || product.description,
|
||||
robots: {
|
||||
index: hide,
|
||||
follow: hide,
|
||||
googleBot: {
|
||||
index: hide,
|
||||
follow: hide
|
||||
}
|
||||
},
|
||||
openGraph: url
|
||||
? {
|
||||
images: [
|
||||
{
|
||||
url,
|
||||
width,
|
||||
height,
|
||||
alt
|
||||
}
|
||||
]
|
||||
}
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProductPage({ params }: { params: { handle: string } }) {
|
||||
const product = await getProduct(params.handle);
|
||||
|
||||
if (!product) return notFound();
|
||||
|
||||
const productJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Product',
|
||||
name: product.title,
|
||||
description: product.description,
|
||||
image: product.featuredImage.url,
|
||||
offers: {
|
||||
'@type': 'AggregateOffer',
|
||||
availability: product.availableForSale
|
||||
? 'https://schema.org/InStock'
|
||||
: 'https://schema.org/OutOfStock',
|
||||
priceCurrency: product.priceRange.minVariantPrice.currencyCode,
|
||||
highPrice: product.priceRange.maxVariantPrice.amount,
|
||||
lowPrice: product.priceRange.minVariantPrice.amount
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(productJsonLd)
|
||||
}}
|
||||
/>
|
||||
<div className="mx-auto max-w-screen-2xl px-4">
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-8 px-4 dark:border-neutral-800 dark:bg-black md:p-12 lg:grid lg:grid-cols-6">
|
||||
<div className="lg:col-span-4">
|
||||
<Gallery
|
||||
images={product.images.map((image: Image) => ({
|
||||
src: image.url,
|
||||
altText: image.altText
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="py-6 pr-8 md:pr-12 lg:col-span-2">
|
||||
<ProductDescription product={product} />
|
||||
</div>
|
||||
</div>
|
||||
<Suspense>
|
||||
<RelatedProducts id={product.id} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense>
|
||||
<Footer />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function RelatedProducts({ id }: { id: string }) {
|
||||
const relatedProducts = await getProductRecommendations(id);
|
||||
|
||||
if (!relatedProducts.length) return null;
|
||||
|
||||
return (
|
||||
<div className="py-8">
|
||||
<h2 className="mb-4 text-2xl font-bold">Related Products</h2>
|
||||
<div className="flex w-full gap-4 overflow-x-auto pt-1">
|
||||
{relatedProducts.map((product, i) => {
|
||||
return (
|
||||
<Link
|
||||
key={i}
|
||||
className="w-full flex-none min-[475px]:w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5"
|
||||
href={`/product/${product.handle}`}
|
||||
>
|
||||
<GridTileImage
|
||||
alt={product.title}
|
||||
label={{
|
||||
title: product.title,
|
||||
amount: product.priceRange.maxVariantPrice.amount,
|
||||
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
||||
}}
|
||||
src={product.featuredImage?.url}
|
||||
width={600}
|
||||
height={600}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
15
app/robots.ts
Normal file
15
app/robots.ts
Normal file
@ -0,0 +1,15 @@
|
||||
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
|
||||
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
|
||||
: 'http://localhost:3000';
|
||||
|
||||
export default function robots() {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*'
|
||||
}
|
||||
],
|
||||
sitemap: `${baseUrl}/sitemap.xml`,
|
||||
host: baseUrl
|
||||
};
|
||||
}
|
11
app/search/[collection]/opengraph-image.tsx
Normal file
11
app/search/[collection]/opengraph-image.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import OpengraphImage from 'components/opengraph-image';
|
||||
import { getCollection } from 'lib/shopify';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export default async function Image({ params }: { params: { collection: string } }) {
|
||||
const collection = await getCollection(params.collection);
|
||||
const title = collection?.seo?.title || collection?.title;
|
||||
|
||||
return await OpengraphImage({ title });
|
||||
}
|
49
app/search/[collection]/page.tsx
Normal file
49
app/search/[collection]/page.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { getCollection, getCollectionProducts } from 'lib/shopify';
|
||||
import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import Grid from 'components/grid';
|
||||
import ProductGridItems from 'components/layout/product-grid-items';
|
||||
import { defaultSort, sorting } from 'lib/constants';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: { collection: string };
|
||||
}): Promise<Metadata> {
|
||||
const collection = await getCollection(params.collection);
|
||||
|
||||
if (!collection) return notFound();
|
||||
|
||||
return {
|
||||
title: collection.seo?.title || collection.title,
|
||||
description:
|
||||
collection.seo?.description || collection.description || `${collection.title} products`
|
||||
};
|
||||
}
|
||||
|
||||
export default async function CategoryPage({
|
||||
params,
|
||||
searchParams
|
||||
}: {
|
||||
params: { collection: string };
|
||||
searchParams?: { [key: string]: string | string[] | undefined };
|
||||
}) {
|
||||
const { sort } = searchParams as { [key: string]: string };
|
||||
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
|
||||
const products = await getCollectionProducts({ collection: params.collection, sortKey, reverse });
|
||||
|
||||
return (
|
||||
<section>
|
||||
{products.length === 0 ? (
|
||||
<p className="py-3 text-lg">{`No products found in this collection`}</p>
|
||||
) : (
|
||||
<Grid className="grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<ProductGridItems products={products} />
|
||||
</Grid>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
22
app/search/layout.tsx
Normal file
22
app/search/layout.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import Footer from 'components/layout/footer';
|
||||
import Collections from 'components/layout/search/collections';
|
||||
import FilterList from 'components/layout/search/filter';
|
||||
import { sorting } from 'lib/constants';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export default function SearchLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Suspense>
|
||||
<div className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-4 pb-4 text-black dark:text-white md:flex-row">
|
||||
<div className="order-first w-full flex-none md:max-w-[125px]">
|
||||
<Collections />
|
||||
</div>
|
||||
<div className="order-last min-h-screen w-full md:order-none">{children}</div>
|
||||
<div className="order-none flex-none md:order-last md:w-[125px]">
|
||||
<FilterList list={sorting} title="Sort by" />
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
15
app/search/loading.tsx
Normal file
15
app/search/loading.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import Grid from 'components/grid';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<Grid className="grid-cols-2 lg:grid-cols-3">
|
||||
{Array(12)
|
||||
.fill(0)
|
||||
.map((_, index) => {
|
||||
return (
|
||||
<Grid.Item key={index} className="animate-pulse bg-neutral-100 dark:bg-neutral-900" />
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
}
|
41
app/search/page.tsx
Normal file
41
app/search/page.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import Grid from 'components/grid';
|
||||
import ProductGridItems from 'components/layout/product-grid-items';
|
||||
import { defaultSort, sorting } from 'lib/constants';
|
||||
import { getProducts } from 'lib/shopify';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Search',
|
||||
description: 'Search for products in the store.'
|
||||
};
|
||||
|
||||
export default async function SearchPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: { [key: string]: string | string[] | undefined };
|
||||
}) {
|
||||
const { sort, q: searchValue } = searchParams as { [key: string]: string };
|
||||
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
|
||||
|
||||
const products = await getProducts({ sortKey, reverse, query: searchValue });
|
||||
const resultsText = products.length > 1 ? 'results' : 'result';
|
||||
|
||||
return (
|
||||
<>
|
||||
{searchValue ? (
|
||||
<p>
|
||||
{products.length === 0
|
||||
? 'There are no products that match '
|
||||
: `Showing ${products.length} ${resultsText} for `}
|
||||
<span className="font-bold">"{searchValue}"</span>
|
||||
</p>
|
||||
) : null}
|
||||
{products.length > 0 ? (
|
||||
<Grid className="grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<ProductGridItems products={products} />
|
||||
</Grid>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
40
app/sitemap.ts
Normal file
40
app/sitemap.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { getCollections, getPages, getProducts } from 'lib/shopify';
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
|
||||
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
|
||||
: 'http://localhost:3000';
|
||||
|
||||
export default async function sitemap(): Promise<Promise<Promise<MetadataRoute.Sitemap>>> {
|
||||
const routesMap = [''].map((route) => ({
|
||||
url: `${baseUrl}${route}`,
|
||||
lastModified: new Date().toISOString()
|
||||
}));
|
||||
|
||||
const collectionsPromise = getCollections().then((collections) =>
|
||||
collections.map((collection) => ({
|
||||
url: `${baseUrl}${collection.path}`,
|
||||
lastModified: collection.updatedAt
|
||||
}))
|
||||
);
|
||||
|
||||
const productsPromise = getProducts({}).then((products) =>
|
||||
products.map((product) => ({
|
||||
url: `${baseUrl}/product/${product.handle}`,
|
||||
lastModified: product.updatedAt
|
||||
}))
|
||||
);
|
||||
|
||||
const pagesPromise = getPages().then((pages) =>
|
||||
pages.map((page) => ({
|
||||
url: `${baseUrl}/${page.handle}`,
|
||||
lastModified: page.updatedAt
|
||||
}))
|
||||
);
|
||||
|
||||
const fetchedRoutes = (
|
||||
await Promise.all([collectionsPromise, productsPromise, pagesPromise])
|
||||
).flat();
|
||||
|
||||
return [...routesMap, ...fetchedRoutes];
|
||||
}
|
137
assets/base.css
137
assets/base.css
@ -1,137 +0,0 @@
|
||||
:root {
|
||||
--primary: #ffffff;
|
||||
--primary-2: #f1f3f5;
|
||||
--secondary: #000000;
|
||||
--secondary-2: #111;
|
||||
--selection: var(--cyan);
|
||||
|
||||
--text-base: #000000;
|
||||
--text-primary: #000000;
|
||||
--text-secondary: white;
|
||||
|
||||
--hover: rgba(0, 0, 0, 0.075);
|
||||
--hover-1: rgba(0, 0, 0, 0.15);
|
||||
--hover-2: rgba(0, 0, 0, 0.25);
|
||||
--cyan: #22b8cf;
|
||||
--green: #37b679;
|
||||
--red: #da3c3c;
|
||||
--purple: #f81ce5;
|
||||
--blue: #0070f3;
|
||||
|
||||
--pink: #ff0080;
|
||||
--pink-light: #ff379c;
|
||||
|
||||
--magenta: #eb367f;
|
||||
|
||||
--violet: #7928ca;
|
||||
--violet-dark: #4c2889;
|
||||
|
||||
--accent-0: #fff;
|
||||
--accent-1: #fafafa;
|
||||
--accent-2: #eaeaea;
|
||||
--accent-3: #999999;
|
||||
--accent-4: #888888;
|
||||
--accent-5: #666666;
|
||||
--accent-6: #444444;
|
||||
--accent-7: #333333;
|
||||
--accent-8: #111111;
|
||||
--accent-9: #000;
|
||||
|
||||
--font-sans: -apple-system, system-ui, BlinkMacSystemFont, 'Helvetica Neue',
|
||||
'Helvetica', sans-serif;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--primary: #000000;
|
||||
--primary-2: #111;
|
||||
--secondary: #ffffff;
|
||||
--secondary-2: #f1f3f5;
|
||||
--hover: rgba(255, 255, 255, 0.075);
|
||||
--hover-1: rgba(255, 255, 255, 0.15);
|
||||
--hover-2: rgba(255, 255, 255, 0.25);
|
||||
--selection: var(--purple);
|
||||
|
||||
--text-base: white;
|
||||
--text-primary: white;
|
||||
--text-secondary: black;
|
||||
|
||||
--accent-9: #fff;
|
||||
--accent-8: #fafafa;
|
||||
--accent-7: #eaeaea;
|
||||
--accent-6: #999999;
|
||||
--accent-5: #888888;
|
||||
--accent-4: #666666;
|
||||
--accent-3: #444444;
|
||||
--accent-2: #333333;
|
||||
--accent-1: #111111;
|
||||
--accent-0: #000;
|
||||
}
|
||||
|
||||
*,
|
||||
*:before,
|
||||
*:after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
touch-action: manipulation;
|
||||
font-feature-settings: 'case' 1, 'rlig' 1, 'calt' 0;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: var(--primary);
|
||||
color: var(--text-primary);
|
||||
overscroll-behavior-x: none;
|
||||
}
|
||||
|
||||
body {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.animated {
|
||||
-webkit-animation-duration: 1s;
|
||||
animation-duration: 1s;
|
||||
-webkit-animation-fill-mode: both;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
.fadeIn {
|
||||
-webkit-animation-name: fadeIn;
|
||||
animation-name: fadeIn;
|
||||
}
|
||||
|
||||
@-webkit-keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
/**
|
||||
* Chrome has a bug with transitions on load since 2012!
|
||||
*
|
||||
* To prevent a "pop" of content, you have to disable all transitions until
|
||||
* the page is done loading.
|
||||
*
|
||||
* https://lab.laukstein.com/bug/input
|
||||
* https://twitter.com/timer150/status/1345217126680899584
|
||||
*/
|
||||
body.loading * {
|
||||
transition: none !important;
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
.fit {
|
||||
min-height: calc(100vh - 88px);
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
@tailwind base;
|
||||
@import './base.css';
|
||||
|
||||
@tailwind components;
|
||||
@import './components.css';
|
||||
|
||||
@tailwind utilities;
|
@ -1,27 +0,0 @@
|
||||
{
|
||||
"schema": {
|
||||
"https://buybutton.store/graphql": {
|
||||
"headers": {
|
||||
"Authorization": "Bearer xzy"
|
||||
}
|
||||
}
|
||||
},
|
||||
"documents": [
|
||||
{
|
||||
"./framework/bigcommerce/api/**/*.ts": {
|
||||
"noRequire": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"generates": {
|
||||
"./framework/bigcommerce/schema.d.ts": {
|
||||
"plugins": ["typescript", "typescript-operations"]
|
||||
},
|
||||
"./framework/bigcommerce/schema.graphql": {
|
||||
"plugins": ["schema-ast"]
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"afterAllFileWrite": ["prettier --write"]
|
||||
}
|
||||
}
|
33
codegen.json
33
codegen.json
@ -1,33 +0,0 @@
|
||||
{
|
||||
"schema": {
|
||||
"https://master.staging.saleor.cloud/graphql/": {}
|
||||
},
|
||||
"documents": [
|
||||
{
|
||||
"./framework/saleor/utils/queries/get-all-products-query.ts": {
|
||||
"noRequire": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"./framework/saleor/utils/queries/get-all-products-paths-query.ts": {
|
||||
"noRequire": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"./framework/saleor/utils/queries/get-products.ts": {
|
||||
"noRequire": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"generates": {
|
||||
"./framework/saleor/schema.d.ts": {
|
||||
"plugins": ["typescript", "typescript-operations"]
|
||||
},
|
||||
"./framework/saleor/schema.graphql": {
|
||||
"plugins": ["schema-ast"]
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"afterAllFileWrite": ["prettier --write"]
|
||||
}
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
{
|
||||
"features": {
|
||||
"cart": true,
|
||||
"search": true,
|
||||
"wishlist": false,
|
||||
"customerAuth": false,
|
||||
"customCheckout": false
|
||||
}
|
||||
}
|
@ -1,78 +0,0 @@
|
||||
import { FC, useEffect, useState, useCallback } from 'react'
|
||||
import { validate } from 'email-validator'
|
||||
import { useUI } from '@components/ui/context'
|
||||
import { Logo, Button, Input } from '@components/ui'
|
||||
|
||||
interface Props {}
|
||||
|
||||
const ForgotPassword: FC<Props> = () => {
|
||||
// Form State
|
||||
const [email, setEmail] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [disabled, setDisabled] = useState(false)
|
||||
|
||||
const { setModalView, closeModal } = useUI()
|
||||
|
||||
const handleResetPassword = async (e: React.SyntheticEvent<EventTarget>) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!dirty && !disabled) {
|
||||
setDirty(true)
|
||||
handleValidation()
|
||||
}
|
||||
}
|
||||
|
||||
const handleValidation = useCallback(() => {
|
||||
// Unable to send form unless fields are valid.
|
||||
if (dirty) {
|
||||
setDisabled(!validate(email))
|
||||
}
|
||||
}, [email, dirty])
|
||||
|
||||
useEffect(() => {
|
||||
handleValidation()
|
||||
}, [handleValidation])
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleResetPassword}
|
||||
className="w-80 flex flex-col justify-between p-3"
|
||||
>
|
||||
<div className="flex justify-center pb-12 ">
|
||||
<Logo width="64px" height="64px" />
|
||||
</div>
|
||||
<div className="flex flex-col space-y-4">
|
||||
{message && (
|
||||
<div className="text-red border border-red p-3">{message}</div>
|
||||
)}
|
||||
|
||||
<Input placeholder="Email" onChange={setEmail} type="email" />
|
||||
<div className="pt-2 w-full flex flex-col">
|
||||
<Button
|
||||
variant="slim"
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
>
|
||||
Recover Password
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<span className="pt-3 text-center text-sm">
|
||||
<span className="text-accent-7">Do you have an account?</span>
|
||||
{` `}
|
||||
<a
|
||||
className="text-accent-9 font-bold hover:underline cursor-pointer"
|
||||
onClick={() => setModalView('LOGIN_VIEW')}
|
||||
>
|
||||
Log In
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default ForgotPassword
|
@ -1,104 +0,0 @@
|
||||
import { FC, useEffect, useState, useCallback } from 'react'
|
||||
import { Logo, Button, Input } from '@components/ui'
|
||||
import useLogin from '@framework/auth/use-login'
|
||||
import { useUI } from '@components/ui/context'
|
||||
import { validate } from 'email-validator'
|
||||
|
||||
interface Props {}
|
||||
|
||||
const LoginView: FC<Props> = () => {
|
||||
// Form State
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [disabled, setDisabled] = useState(false)
|
||||
const { setModalView, closeModal } = useUI()
|
||||
|
||||
const login = useLogin()
|
||||
|
||||
const handleLogin = async (e: React.SyntheticEvent<EventTarget>) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!dirty && !disabled) {
|
||||
setDirty(true)
|
||||
handleValidation()
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setMessage('')
|
||||
await login({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
setLoading(false)
|
||||
closeModal()
|
||||
} catch ({ errors }) {
|
||||
setMessage(errors[0].message)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleValidation = useCallback(() => {
|
||||
// Test for Alphanumeric password
|
||||
const validPassword = /^(?=.*[a-zA-Z])(?=.*[0-9])/.test(password)
|
||||
|
||||
// Unable to send form unless fields are valid.
|
||||
if (dirty) {
|
||||
setDisabled(!validate(email) || password.length < 7 || !validPassword)
|
||||
}
|
||||
}, [email, password, dirty])
|
||||
|
||||
useEffect(() => {
|
||||
handleValidation()
|
||||
}, [handleValidation])
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleLogin}
|
||||
className="w-80 flex flex-col justify-between p-3"
|
||||
>
|
||||
<div className="flex justify-center pb-12 ">
|
||||
<Logo width="64px" height="64px" />
|
||||
</div>
|
||||
<div className="flex flex-col space-y-3">
|
||||
{message && (
|
||||
<div className="text-red border border-red p-3">
|
||||
{message}. Did you {` `}
|
||||
<a
|
||||
className="text-accent-9 inline font-bold hover:underline cursor-pointer"
|
||||
onClick={() => setModalView('FORGOT_VIEW')}
|
||||
>
|
||||
forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<Input type="email" placeholder="Email" onChange={setEmail} />
|
||||
<Input type="password" placeholder="Password" onChange={setPassword} />
|
||||
|
||||
<Button
|
||||
variant="slim"
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
>
|
||||
Log In
|
||||
</Button>
|
||||
<div className="pt-1 text-center text-sm">
|
||||
<span className="text-accent-7">Don't have an account?</span>
|
||||
{` `}
|
||||
<a
|
||||
className="text-accent-9 font-bold hover:underline cursor-pointer"
|
||||
onClick={() => setModalView('SIGNUP_VIEW')}
|
||||
>
|
||||
Sign Up
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginView
|
@ -1,114 +0,0 @@
|
||||
import { FC, useEffect, useState, useCallback } from 'react'
|
||||
import { validate } from 'email-validator'
|
||||
import { Info } from '@components/icons'
|
||||
import { useUI } from '@components/ui/context'
|
||||
import { Logo, Button, Input } from '@components/ui'
|
||||
import useSignup from '@framework/auth/use-signup'
|
||||
|
||||
interface Props {}
|
||||
|
||||
const SignUpView: FC<Props> = () => {
|
||||
// Form State
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [lastName, setLastName] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [disabled, setDisabled] = useState(false)
|
||||
|
||||
const signup = useSignup()
|
||||
const { setModalView, closeModal } = useUI()
|
||||
|
||||
const handleSignup = async (e: React.SyntheticEvent<EventTarget>) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!dirty && !disabled) {
|
||||
setDirty(true)
|
||||
handleValidation()
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setMessage('')
|
||||
await signup({
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
password,
|
||||
})
|
||||
setLoading(false)
|
||||
closeModal()
|
||||
} catch ({ errors }) {
|
||||
setMessage(errors[0].message)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleValidation = useCallback(() => {
|
||||
// Test for Alphanumeric password
|
||||
const validPassword = /^(?=.*[a-zA-Z])(?=.*[0-9])/.test(password)
|
||||
|
||||
// Unable to send form unless fields are valid.
|
||||
if (dirty) {
|
||||
setDisabled(!validate(email) || password.length < 7 || !validPassword)
|
||||
}
|
||||
}, [email, password, dirty])
|
||||
|
||||
useEffect(() => {
|
||||
handleValidation()
|
||||
}, [handleValidation])
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSignup}
|
||||
className="w-80 flex flex-col justify-between p-3"
|
||||
>
|
||||
<div className="flex justify-center pb-12 ">
|
||||
<Logo width="64px" height="64px" />
|
||||
</div>
|
||||
<div className="flex flex-col space-y-4">
|
||||
{message && (
|
||||
<div className="text-red border border-red p-3">{message}</div>
|
||||
)}
|
||||
<Input placeholder="First Name" onChange={setFirstName} />
|
||||
<Input placeholder="Last Name" onChange={setLastName} />
|
||||
<Input type="email" placeholder="Email" onChange={setEmail} />
|
||||
<Input type="password" placeholder="Password" onChange={setPassword} />
|
||||
<span className="text-accent-8">
|
||||
<span className="inline-block align-middle ">
|
||||
<Info width="15" height="15" />
|
||||
</span>{' '}
|
||||
<span className="leading-6 text-sm">
|
||||
<strong>Info</strong>: Passwords must be longer than 7 chars and
|
||||
include numbers.{' '}
|
||||
</span>
|
||||
</span>
|
||||
<div className="pt-2 w-full flex flex-col">
|
||||
<Button
|
||||
variant="slim"
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
>
|
||||
Sign Up
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<span className="pt-1 text-center text-sm">
|
||||
<span className="text-accent-7">Do you have an account?</span>
|
||||
{` `}
|
||||
<a
|
||||
className="text-accent-9 font-bold hover:underline cursor-pointer"
|
||||
onClick={() => setModalView('LOGIN_VIEW')}
|
||||
>
|
||||
Log In
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default SignUpView
|
@ -1,3 +0,0 @@
|
||||
export { default as LoginView } from './LoginView'
|
||||
export { default as SignUpView } from './SignUpView'
|
||||
export { default as ForgotPassword } from './ForgotPassword'
|
36
components/carousel.tsx
Normal file
36
components/carousel.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { getCollectionProducts } from 'lib/shopify';
|
||||
import Link from 'next/link';
|
||||
import { GridTileImage } from './grid/tile';
|
||||
|
||||
export async function Carousel() {
|
||||
// Collections that start with `hidden-*` are hidden from the search page.
|
||||
const products = await getCollectionProducts({ collection: 'hidden-homepage-carousel' });
|
||||
|
||||
if (!products?.length) return null;
|
||||
|
||||
return (
|
||||
<div className=" w-full overflow-x-auto pb-6 pt-1">
|
||||
<div className="flex animate-carousel gap-4">
|
||||
{[...products, ...products].map((product, i) => (
|
||||
<Link
|
||||
key={`${product.handle}${i}`}
|
||||
href={`/product/${product.handle}`}
|
||||
className="h-[30vh] w-2/3 flex-none md:w-1/3"
|
||||
>
|
||||
<GridTileImage
|
||||
alt={product.title}
|
||||
label={{
|
||||
title: product.title,
|
||||
amount: product.priceRange.maxVariantPrice.amount,
|
||||
currencyCode: product.priceRange.maxVariantPrice.currencyCode
|
||||
}}
|
||||
src={product.featuredImage?.url}
|
||||
width={600}
|
||||
height={600}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,32 +0,0 @@
|
||||
.root {
|
||||
@apply flex flex-col py-4;
|
||||
}
|
||||
|
||||
.root:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.quantity {
|
||||
appearance: textfield;
|
||||
@apply w-8 border-accent-2 border mx-3 rounded text-center text-sm text-black;
|
||||
}
|
||||
|
||||
.quantity::-webkit-outer-spin-button,
|
||||
.quantity::-webkit-inner-spin-button {
|
||||
@apply appearance-none m-0;
|
||||
}
|
||||
|
||||
.productImage {
|
||||
position: absolute;
|
||||
transform: scale(1.9);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 30% !important;
|
||||
top: 30% !important;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.productName {
|
||||
@apply font-medium cursor-pointer pb-1;
|
||||
margin-top: -4px;
|
||||
}
|
@ -1,151 +0,0 @@
|
||||
import { ChangeEvent, FocusEventHandler, useEffect, useState } from 'react'
|
||||
import cn from 'classnames'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import s from './CartItem.module.css'
|
||||
import { Trash, Plus, Minus, Cross } from '@components/icons'
|
||||
import { useUI } from '@components/ui/context'
|
||||
import type { LineItem } from '@commerce/types/cart'
|
||||
import usePrice from '@framework/product/use-price'
|
||||
import useUpdateItem from '@framework/cart/use-update-item'
|
||||
import useRemoveItem from '@framework/cart/use-remove-item'
|
||||
import Quantity from '@components/ui/Quantity'
|
||||
|
||||
type ItemOption = {
|
||||
name: string
|
||||
nameId: number
|
||||
value: string
|
||||
valueId: number
|
||||
}
|
||||
|
||||
const CartItem = ({
|
||||
item,
|
||||
variant = 'default',
|
||||
currencyCode,
|
||||
...rest
|
||||
}: {
|
||||
variant?: 'default' | 'display'
|
||||
item: LineItem
|
||||
currencyCode: string
|
||||
}) => {
|
||||
const { closeSidebarIfPresent } = useUI()
|
||||
const [removing, setRemoving] = useState(false)
|
||||
const [quantity, setQuantity] = useState<number>(item.quantity)
|
||||
const removeItem = useRemoveItem()
|
||||
const updateItem = useUpdateItem({ item })
|
||||
|
||||
const { price } = usePrice({
|
||||
amount: item.variant.price * item.quantity,
|
||||
baseAmount: item.variant.listPrice * item.quantity,
|
||||
currencyCode,
|
||||
})
|
||||
|
||||
const handleChange = async ({
|
||||
target: { value },
|
||||
}: ChangeEvent<HTMLInputElement>) => {
|
||||
setQuantity(Number(value))
|
||||
await updateItem({ quantity: Number(value) })
|
||||
}
|
||||
|
||||
const increaseQuantity = async (n = 1) => {
|
||||
const val = Number(quantity) + n
|
||||
setQuantity(val)
|
||||
await updateItem({ quantity: val })
|
||||
}
|
||||
|
||||
const handleRemove = async () => {
|
||||
setRemoving(true)
|
||||
try {
|
||||
await removeItem(item)
|
||||
} catch (error) {
|
||||
setRemoving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Add a type for this
|
||||
const options = (item as any).options
|
||||
|
||||
useEffect(() => {
|
||||
// Reset the quantity state if the item quantity changes
|
||||
if (item.quantity !== Number(quantity)) {
|
||||
setQuantity(item.quantity)
|
||||
}
|
||||
}, [item.quantity])
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn(s.root, {
|
||||
'opacity-50 pointer-events-none': removing,
|
||||
})}
|
||||
{...rest}
|
||||
>
|
||||
<div className="flex flex-row space-x-4 py-4">
|
||||
<div className="w-16 h-16 bg-violet relative overflow-hidden cursor-pointer z-0">
|
||||
<Link href={`/product/${item.path}`}>
|
||||
<Image
|
||||
onClick={() => closeSidebarIfPresent()}
|
||||
className={s.productImage}
|
||||
width={150}
|
||||
height={150}
|
||||
src={item.variant.image!.url}
|
||||
alt={item.variant.image!.altText}
|
||||
unoptimized
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col text-base">
|
||||
<Link href={`/product/${item.path}`}>
|
||||
<span
|
||||
className={s.productName}
|
||||
onClick={() => closeSidebarIfPresent()}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
</Link>
|
||||
{options && options.length > 0 && (
|
||||
<div className="flex items-center pb-1">
|
||||
{options.map((option: ItemOption, i: number) => (
|
||||
<div
|
||||
key={`${item.id}-${option.name}`}
|
||||
className="text-sm font-semibold text-accent-7 inline-flex items-center justify-center"
|
||||
>
|
||||
{option.name}
|
||||
{option.name === 'Color' ? (
|
||||
<span
|
||||
className="mx-2 rounded-full bg-transparent border w-5 h-5 p-1 text-accent-9 inline-flex items-center justify-center overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: `${option.value}`,
|
||||
}}
|
||||
></span>
|
||||
) : (
|
||||
<span className="mx-2 rounded-full bg-transparent border h-5 p-1 text-accent-9 inline-flex items-center justify-center overflow-hidden">
|
||||
{option.value}
|
||||
</span>
|
||||
)}
|
||||
{i === options.length - 1 ? '' : <span className="mr-3" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{variant === 'display' && (
|
||||
<div className="text-sm tracking-wider">{quantity}x</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col justify-between space-y-2 text-sm">
|
||||
<span>{price}</span>
|
||||
</div>
|
||||
</div>
|
||||
{variant === 'default' && (
|
||||
<Quantity
|
||||
value={quantity}
|
||||
handleRemove={handleRemove}
|
||||
handleChange={handleChange}
|
||||
increase={() => increaseQuantity(1)}
|
||||
decrease={() => increaseQuantity(-1)}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default CartItem
|
@ -1 +0,0 @@
|
||||
export { default } from './CartItem'
|
@ -1,11 +0,0 @@
|
||||
.root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.root.empty {
|
||||
@apply bg-secondary text-secondary;
|
||||
}
|
||||
|
||||
.lineItemsList {
|
||||
@apply py-4 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-accent-2 border-accent-2;
|
||||
}
|
@ -1,129 +0,0 @@
|
||||
import cn from 'classnames'
|
||||
import Link from 'next/link'
|
||||
import { FC } from 'react'
|
||||
import s from './CartSidebarView.module.css'
|
||||
import CartItem from '../CartItem'
|
||||
import { Button, Text } from '@components/ui'
|
||||
import { useUI } from '@components/ui/context'
|
||||
import { Bag, Cross, Check } from '@components/icons'
|
||||
import useCart from '@framework/cart/use-cart'
|
||||
import usePrice from '@framework/product/use-price'
|
||||
import SidebarLayout from '@components/common/SidebarLayout'
|
||||
|
||||
const CartSidebarView: FC = () => {
|
||||
const { closeSidebar, setSidebarView } = useUI()
|
||||
const { data, isLoading, isEmpty } = useCart()
|
||||
|
||||
const { price: subTotal } = usePrice(
|
||||
data && {
|
||||
amount: Number(data.subtotalPrice),
|
||||
currencyCode: data.currency.code,
|
||||
}
|
||||
)
|
||||
const { price: total } = usePrice(
|
||||
data && {
|
||||
amount: Number(data.totalPrice),
|
||||
currencyCode: data.currency.code,
|
||||
}
|
||||
)
|
||||
const handleClose = () => closeSidebar()
|
||||
const goToCheckout = () => setSidebarView('CHECKOUT_VIEW')
|
||||
|
||||
const error = null
|
||||
const success = null
|
||||
|
||||
return (
|
||||
<SidebarLayout
|
||||
className={cn({
|
||||
[s.empty]: error || success || isLoading || isEmpty,
|
||||
})}
|
||||
handleClose={handleClose}
|
||||
>
|
||||
{isLoading || isEmpty ? (
|
||||
<div className="flex-1 px-4 flex flex-col justify-center items-center">
|
||||
<span className="border border-dashed border-primary rounded-full flex items-center justify-center w-16 h-16 p-12 bg-secondary text-secondary">
|
||||
<Bag className="absolute" />
|
||||
</span>
|
||||
<h2 className="pt-6 text-2xl font-bold tracking-wide text-center">
|
||||
Your cart is empty
|
||||
</h2>
|
||||
<p className="text-accent-3 px-10 text-center pt-2">
|
||||
Biscuit oat cake wafer icing ice cream tiramisu pudding cupcake.
|
||||
</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex-1 px-4 flex flex-col justify-center items-center">
|
||||
<span className="border border-white rounded-full flex items-center justify-center w-16 h-16">
|
||||
<Cross width={24} height={24} />
|
||||
</span>
|
||||
<h2 className="pt-6 text-xl font-light text-center">
|
||||
We couldn’t process the purchase. Please check your card information
|
||||
and try again.
|
||||
</h2>
|
||||
</div>
|
||||
) : success ? (
|
||||
<div className="flex-1 px-4 flex flex-col justify-center items-center">
|
||||
<span className="border border-white rounded-full flex items-center justify-center w-16 h-16">
|
||||
<Check />
|
||||
</span>
|
||||
<h2 className="pt-6 text-xl font-light text-center">
|
||||
Thank you for your order.
|
||||
</h2>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-4 sm:px-6 flex-1">
|
||||
<Link href="/cart">
|
||||
<Text variant="sectionHeading" onClick={handleClose}>
|
||||
My Cart
|
||||
</Text>
|
||||
</Link>
|
||||
<ul className={s.lineItemsList}>
|
||||
{data!.lineItems.map((item: any) => (
|
||||
<CartItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
currencyCode={data!.currency.code}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 px-6 py-6 sm:px-6 sticky z-20 bottom-0 w-full right-0 left-0 bg-accent-0 border-t text-sm">
|
||||
<ul className="pb-2">
|
||||
<li className="flex justify-between py-1">
|
||||
<span>Subtotal</span>
|
||||
<span>{subTotal}</span>
|
||||
</li>
|
||||
<li className="flex justify-between py-1">
|
||||
<span>Taxes</span>
|
||||
<span>Calculated at checkout</span>
|
||||
</li>
|
||||
<li className="flex justify-between py-1">
|
||||
<span>Shipping</span>
|
||||
<span className="font-bold tracking-wide">FREE</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="flex justify-between border-t border-accent-2 py-3 font-bold mb-2">
|
||||
<span>Total</span>
|
||||
<span>{total}</span>
|
||||
</div>
|
||||
<div>
|
||||
{process.env.COMMERCE_CUSTOMCHECKOUT_ENABLED ? (
|
||||
<Button Component="a" width="100%" onClick={goToCheckout}>
|
||||
Proceed to Checkout ({total})
|
||||
</Button>
|
||||
) : (
|
||||
<Button href="/checkout" Component="a" width="100%">
|
||||
Proceed to Checkout
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SidebarLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default CartSidebarView
|
@ -1 +0,0 @@
|
||||
export { default } from './CartSidebarView'
|
68
components/cart/actions.ts
Normal file
68
components/cart/actions.ts
Normal file
@ -0,0 +1,68 @@
|
||||
'use server';
|
||||
|
||||
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export const addItem = async (variantId: string | undefined): Promise<Error | undefined> => {
|
||||
let cartId = cookies().get('cartId')?.value;
|
||||
let cart;
|
||||
|
||||
if (cartId) {
|
||||
cart = await getCart(cartId);
|
||||
}
|
||||
|
||||
if (!cartId || !cart) {
|
||||
cart = await createCart();
|
||||
cartId = cart.id;
|
||||
cookies().set('cartId', cartId);
|
||||
}
|
||||
|
||||
if (!variantId) {
|
||||
return new Error('Missing variantId');
|
||||
}
|
||||
try {
|
||||
await addToCart(cartId, [{ merchandiseId: variantId, quantity: 1 }]);
|
||||
} catch (e) {
|
||||
return new Error('Error adding item', { cause: e });
|
||||
}
|
||||
};
|
||||
|
||||
export const removeItem = async (lineId: string): Promise<Error | undefined> => {
|
||||
const cartId = cookies().get('cartId')?.value;
|
||||
|
||||
if (!cartId) {
|
||||
return new Error('Missing cartId');
|
||||
}
|
||||
try {
|
||||
await removeFromCart(cartId, [lineId]);
|
||||
} catch (e) {
|
||||
return new Error('Error removing item', { cause: e });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateItemQuantity = async ({
|
||||
lineId,
|
||||
variantId,
|
||||
quantity
|
||||
}: {
|
||||
lineId: string;
|
||||
variantId: string;
|
||||
quantity: number;
|
||||
}): Promise<Error | undefined> => {
|
||||
const cartId = cookies().get('cartId')?.value;
|
||||
|
||||
if (!cartId) {
|
||||
return new Error('Missing cartId');
|
||||
}
|
||||
try {
|
||||
await updateCart(cartId, [
|
||||
{
|
||||
id: lineId,
|
||||
merchandiseId: variantId,
|
||||
quantity
|
||||
}
|
||||
]);
|
||||
} catch (e) {
|
||||
return new Error('Error updating item quantity', { cause: e });
|
||||
}
|
||||
};
|
73
components/cart/add-to-cart.tsx
Normal file
73
components/cart/add-to-cart.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { PlusIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import { addItem } from 'components/cart/actions';
|
||||
import LoadingDots from 'components/loading-dots';
|
||||
import { ProductVariant } from 'lib/shopify/types';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState, useTransition } from 'react';
|
||||
|
||||
export function AddToCart({
|
||||
variants,
|
||||
availableForSale
|
||||
}: {
|
||||
variants: ProductVariant[];
|
||||
availableForSale: boolean;
|
||||
}) {
|
||||
const [selectedVariantId, setSelectedVariantId] = useState<string | undefined>(undefined);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
const variant = variants.find((variant: ProductVariant) =>
|
||||
variant.selectedOptions.every(
|
||||
(option) => option.value === searchParams.get(option.name.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
setSelectedVariantId(variant?.id);
|
||||
}, [searchParams, variants, setSelectedVariantId]);
|
||||
|
||||
const title = !availableForSale
|
||||
? 'Out of stock'
|
||||
: !selectedVariantId
|
||||
? 'Please select options'
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Add item to cart"
|
||||
disabled={isPending || !availableForSale || !selectedVariantId}
|
||||
title={title}
|
||||
onClick={() => {
|
||||
// Safeguard in case someone messes with `disabled` in devtools.
|
||||
if (!availableForSale || !selectedVariantId) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const error = await addItem(selectedVariantId);
|
||||
|
||||
if (error) {
|
||||
alert(error);
|
||||
return;
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
className={clsx(
|
||||
'relative flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white hover:opacity-90',
|
||||
{
|
||||
'cursor-not-allowed opacity-60 hover:opacity-60': !availableForSale || !selectedVariantId,
|
||||
'cursor-not-allowed': isPending
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="absolute left-0 ml-4">
|
||||
{!isPending ? <PlusIcon className="h-5" /> : <LoadingDots className="mb-3 bg-white" />}
|
||||
</div>
|
||||
<span>{availableForSale ? 'Add To Cart' : 'Out Of Stock'}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
10
components/cart/close-cart.tsx
Normal file
10
components/cart/close-cart.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function CloseCart({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className="relative flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white">
|
||||
<XMarkIcon className={clsx('h-6 transition-all ease-in-out hover:scale-110 ', className)} />
|
||||
</div>
|
||||
);
|
||||
}
|
44
components/cart/delete-item-button.tsx
Normal file
44
components/cart/delete-item-button.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import LoadingDots from 'components/loading-dots';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { removeItem } from 'components/cart/actions';
|
||||
import type { CartItem } from 'lib/shopify/types';
|
||||
import { useTransition } from 'react';
|
||||
|
||||
export default function DeleteItemButton({ item }: { item: CartItem }) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Remove cart item"
|
||||
onClick={() => {
|
||||
startTransition(async () => {
|
||||
const error = await removeItem(item.id);
|
||||
|
||||
if (error) {
|
||||
alert(error);
|
||||
return;
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
disabled={isPending}
|
||||
className={clsx(
|
||||
'ease flex h-[17px] w-[17px] items-center justify-center rounded-full bg-neutral-500 transition-all duration-200',
|
||||
{
|
||||
'cursor-not-allowed px-0': isPending
|
||||
}
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<LoadingDots className="bg-white" />
|
||||
) : (
|
||||
<XMarkIcon className="hover:text-accent-3 mx-[1px] h-4 w-4 text-white dark:text-black" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
60
components/cart/edit-item-quantity-button.tsx
Normal file
60
components/cart/edit-item-quantity-button.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTransition } from 'react';
|
||||
|
||||
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import { removeItem, updateItemQuantity } from 'components/cart/actions';
|
||||
import LoadingDots from 'components/loading-dots';
|
||||
import type { CartItem } from 'lib/shopify/types';
|
||||
|
||||
export default function EditItemQuantityButton({
|
||||
item,
|
||||
type
|
||||
}: {
|
||||
item: CartItem;
|
||||
type: 'plus' | 'minus';
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
|
||||
onClick={() => {
|
||||
startTransition(async () => {
|
||||
const error =
|
||||
type === 'minus' && item.quantity - 1 === 0
|
||||
? await removeItem(item.id)
|
||||
: await updateItemQuantity({
|
||||
lineId: item.id,
|
||||
variantId: item.merchandise.id,
|
||||
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
|
||||
});
|
||||
|
||||
if (error) {
|
||||
alert(error);
|
||||
return;
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
disabled={isPending}
|
||||
className={clsx(
|
||||
'ease flex h-full min-w-[36px] max-w-[36px] flex-none items-center justify-center rounded-full px-2 transition-all duration-200 hover:border-neutral-800 hover:opacity-80',
|
||||
{
|
||||
'cursor-not-allowed': isPending,
|
||||
'ml-auto': type === 'minus'
|
||||
}
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<LoadingDots className="bg-black dark:bg-white" />
|
||||
) : type === 'plus' ? (
|
||||
<PlusIcon className="h-4 w-4 dark:text-neutral-500" />
|
||||
) : (
|
||||
<MinusIcon className="h-4 w-4 dark:text-neutral-500" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
export { default as CartSidebarView } from './CartSidebarView'
|
||||
export { default as CartItem } from './CartItem'
|
14
components/cart/index.tsx
Normal file
14
components/cart/index.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { getCart } from 'lib/shopify';
|
||||
import { cookies } from 'next/headers';
|
||||
import CartModal from './modal';
|
||||
|
||||
export default async function Cart() {
|
||||
const cartId = cookies().get('cartId')?.value;
|
||||
let cart;
|
||||
|
||||
if (cartId) {
|
||||
cart = await getCart(cartId);
|
||||
}
|
||||
|
||||
return <CartModal cart={cart} />;
|
||||
}
|
191
components/cart/modal.tsx
Normal file
191
components/cart/modal.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
'use client';
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { ShoppingCartIcon } from '@heroicons/react/24/outline';
|
||||
import Price from 'components/price';
|
||||
import { DEFAULT_OPTION } from 'lib/constants';
|
||||
import type { Cart } from 'lib/shopify/types';
|
||||
import { createUrl } from 'lib/utils';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import CloseCart from './close-cart';
|
||||
import DeleteItemButton from './delete-item-button';
|
||||
import EditItemQuantityButton from './edit-item-quantity-button';
|
||||
import OpenCart from './open-cart';
|
||||
|
||||
type MerchandiseSearchParams = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
export default function CartModal({ cart }: { cart: Cart | undefined }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const quantityRef = useRef(cart?.totalQuantity);
|
||||
const openCart = () => setIsOpen(true);
|
||||
const closeCart = () => setIsOpen(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Open cart modal when when quantity changes.
|
||||
if (cart?.totalQuantity !== quantityRef.current) {
|
||||
// But only if it's not already open (quantity also changes when editing items in cart).
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
|
||||
// Always update the quantity reference
|
||||
quantityRef.current = cart?.totalQuantity;
|
||||
}
|
||||
}, [isOpen, cart?.totalQuantity, quantityRef]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button aria-label="Open cart" onClick={openCart}>
|
||||
<OpenCart quantity={cart?.totalQuantity} />
|
||||
</button>
|
||||
<Transition show={isOpen}>
|
||||
<Dialog onClose={closeCart} className="relative z-50">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transition-all ease-in-out duration-300"
|
||||
enterFrom="opacity-0 backdrop-blur-none"
|
||||
enterTo="opacity-100 backdrop-blur-[.5px]"
|
||||
leave="transition-all ease-in-out duration-200"
|
||||
leaveFrom="opacity-100 backdrop-blur-[.5px]"
|
||||
leaveTo="opacity-0 backdrop-blur-none"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
|
||||
</Transition.Child>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transition-all ease-in-out duration-300"
|
||||
enterFrom="translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="transition-all ease-in-out duration-200"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="translate-x-full"
|
||||
>
|
||||
<Dialog.Panel className="fixed bottom-0 right-0 top-0 flex h-full w-full flex-col border-l border-neutral-200 bg-white/80 p-6 text-black backdrop-blur-xl dark:border-neutral-700 dark:bg-black/80 dark:text-white md:w-[390px]">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-lg font-semibold">My Cart</p>
|
||||
|
||||
<button aria-label="Close cart" onClick={closeCart}>
|
||||
<CloseCart />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!cart || cart.lines.length === 0 ? (
|
||||
<div className="mt-20 flex w-full flex-col items-center justify-center overflow-hidden">
|
||||
<ShoppingCartIcon className="h-16" />
|
||||
<p className="mt-6 text-center text-2xl font-bold">Your cart is empty.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col justify-between overflow-hidden p-1">
|
||||
<ul className="flex-grow overflow-auto py-4">
|
||||
{cart.lines.map((item, i) => {
|
||||
const merchandiseSearchParams = {} as MerchandiseSearchParams;
|
||||
|
||||
item.merchandise.selectedOptions.forEach(({ name, value }) => {
|
||||
if (value !== DEFAULT_OPTION) {
|
||||
merchandiseSearchParams[name.toLowerCase()] = value;
|
||||
}
|
||||
});
|
||||
|
||||
const merchandiseUrl = createUrl(
|
||||
`/product/${item.merchandise.product.handle}`,
|
||||
new URLSearchParams(merchandiseSearchParams)
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={i}
|
||||
className="flex w-full flex-col border-b border-neutral-300 dark:border-neutral-700"
|
||||
>
|
||||
<div className="relative flex w-full flex-row justify-between px-1 py-4">
|
||||
<div className="absolute z-40 -mt-2 ml-[55px]">
|
||||
<DeleteItemButton item={item} />
|
||||
</div>
|
||||
<Link
|
||||
href={merchandiseUrl}
|
||||
onClick={closeCart}
|
||||
className="z-30 flex flex-row space-x-4"
|
||||
>
|
||||
<div className="relative h-16 w-16 cursor-pointer overflow-hidden rounded-md border border-neutral-300 bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800">
|
||||
<Image
|
||||
className="h-full w-full object-cover "
|
||||
width={64}
|
||||
height={64}
|
||||
alt={
|
||||
item.merchandise.product.featuredImage.altText ||
|
||||
item.merchandise.product.title
|
||||
}
|
||||
src={item.merchandise.product.featuredImage.url}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col text-base">
|
||||
<span className="leading-tight">
|
||||
{item.merchandise.product.title}
|
||||
</span>
|
||||
{item.merchandise.title !== DEFAULT_OPTION ? (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{item.merchandise.title}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex h-16 flex-col justify-between">
|
||||
<Price
|
||||
className="flex justify-end space-y-2 text-right text-sm"
|
||||
amount={item.cost.totalAmount.amount}
|
||||
currencyCode={item.cost.totalAmount.currencyCode}
|
||||
/>
|
||||
<div className="ml-auto flex h-9 flex-row items-center rounded-full border border-neutral-200 dark:border-neutral-700">
|
||||
<EditItemQuantityButton item={item} type="minus" />
|
||||
<p className="w-6 text-center ">
|
||||
<span className="w-full text-sm">{item.quantity}</span>
|
||||
</p>
|
||||
<EditItemQuantityButton item={item} type="plus" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="py-4 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 dark:border-neutral-700">
|
||||
<p>Taxes</p>
|
||||
<Price
|
||||
className="text-right text-base text-black dark:text-white"
|
||||
amount={cart.cost.totalTaxAmount.amount}
|
||||
currencyCode={cart.cost.totalTaxAmount.currencyCode}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 pt-1 dark:border-neutral-700">
|
||||
<p>Shipping</p>
|
||||
<p className="text-right">Calculated at checkout</p>
|
||||
</div>
|
||||
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 pt-1 dark:border-neutral-700">
|
||||
<p>Total</p>
|
||||
<Price
|
||||
className="text-right text-base text-black dark:text-white"
|
||||
amount={cart.cost.totalAmount.amount}
|
||||
currencyCode={cart.cost.totalAmount.currencyCode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={cart.checkoutUrl}
|
||||
className="block w-full rounded-full bg-blue-600 p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100"
|
||||
>
|
||||
Proceed to Checkout
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
24
components/cart/open-cart.tsx
Normal file
24
components/cart/open-cart.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { ShoppingCartIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function OpenCart({
|
||||
className,
|
||||
quantity
|
||||
}: {
|
||||
className?: string;
|
||||
quantity?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white">
|
||||
<ShoppingCartIcon
|
||||
className={clsx('h-4 transition-all ease-in-out hover:scale-110 ', className)}
|
||||
/>
|
||||
|
||||
{quantity ? (
|
||||
<div className="absolute right-0 top-0 -mr-2 -mt-2 h-4 w-4 rounded bg-blue-600 text-[11px] font-medium text-white">
|
||||
{quantity}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
.root {
|
||||
min-height: calc(100vh - 322px);
|
||||
}
|
||||
|
||||
.lineItemsList {
|
||||
@apply py-4 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-accent-2 border-accent-2;
|
||||
}
|
@ -1,89 +0,0 @@
|
||||
import cn from 'classnames'
|
||||
import Link from 'next/link'
|
||||
import { FC } from 'react'
|
||||
import CartItem from '@components/cart/CartItem'
|
||||
import { Button, Text } from '@components/ui'
|
||||
import { useUI } from '@components/ui/context'
|
||||
import useCart from '@framework/cart/use-cart'
|
||||
import usePrice from '@framework/product/use-price'
|
||||
import ShippingWidget from '../ShippingWidget'
|
||||
import PaymentWidget from '../PaymentWidget'
|
||||
import SidebarLayout from '@components/common/SidebarLayout'
|
||||
import s from './CheckoutSidebarView.module.css'
|
||||
|
||||
const CheckoutSidebarView: FC = () => {
|
||||
const { setSidebarView } = useUI()
|
||||
const { data } = useCart()
|
||||
|
||||
const { price: subTotal } = usePrice(
|
||||
data && {
|
||||
amount: Number(data.subtotalPrice),
|
||||
currencyCode: data.currency.code,
|
||||
}
|
||||
)
|
||||
const { price: total } = usePrice(
|
||||
data && {
|
||||
amount: Number(data.totalPrice),
|
||||
currencyCode: data.currency.code,
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarLayout
|
||||
className={s.root}
|
||||
handleBack={() => setSidebarView('CART_VIEW')}
|
||||
>
|
||||
<div className="px-4 sm:px-6 flex-1">
|
||||
<Link href="/cart">
|
||||
<Text variant="sectionHeading">Checkout</Text>
|
||||
</Link>
|
||||
|
||||
<PaymentWidget onClick={() => setSidebarView('PAYMENT_VIEW')} />
|
||||
<ShippingWidget onClick={() => setSidebarView('SHIPPING_VIEW')} />
|
||||
|
||||
<ul className={s.lineItemsList}>
|
||||
{data!.lineItems.map((item: any) => (
|
||||
<CartItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
currencyCode={data!.currency.code}
|
||||
variant="display"
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 px-6 py-6 sm:px-6 sticky z-20 bottom-0 w-full right-0 left-0 bg-accent-0 border-t text-sm">
|
||||
<ul className="pb-2">
|
||||
<li className="flex justify-between py-1">
|
||||
<span>Subtotal</span>
|
||||
<span>{subTotal}</span>
|
||||
</li>
|
||||
<li className="flex justify-between py-1">
|
||||
<span>Taxes</span>
|
||||
<span>Calculated at checkout</span>
|
||||
</li>
|
||||
<li className="flex justify-between py-1">
|
||||
<span>Shipping</span>
|
||||
<span className="font-bold tracking-wide">FREE</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="flex justify-between border-t border-accent-2 py-3 font-bold mb-2">
|
||||
<span>Total</span>
|
||||
<span>{total}</span>
|
||||
</div>
|
||||
<div>
|
||||
{/* Once data is correcly filled */}
|
||||
{/* <Button Component="a" width="100%">
|
||||
Confirm Purchase
|
||||
</Button> */}
|
||||
<Button Component="a" width="100%" variant="ghost" disabled>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default CheckoutSidebarView
|
@ -1 +0,0 @@
|
||||
export { default } from './CheckoutSidebarView'
|
@ -1,17 +0,0 @@
|
||||
.fieldset {
|
||||
@apply flex flex-col my-3;
|
||||
}
|
||||
|
||||
.fieldset .label {
|
||||
@apply text-accent-7 uppercase text-xs font-medium mb-2;
|
||||
}
|
||||
|
||||
.fieldset .input,
|
||||
.fieldset .select {
|
||||
@apply p-2 border border-accent-2 w-full text-sm font-normal;
|
||||
}
|
||||
|
||||
.fieldset .input:focus,
|
||||
.fieldset .select:focus {
|
||||
@apply outline-none shadow-outline-normal;
|
||||
}
|
@ -1,84 +0,0 @@
|
||||
import { FC } from 'react'
|
||||
import cn from 'classnames'
|
||||
import { Button, Text } from '@components/ui'
|
||||
import { useUI } from '@components/ui/context'
|
||||
import s from './PaymentMethodView.module.css'
|
||||
import SidebarLayout from '@components/common/SidebarLayout'
|
||||
|
||||
const PaymentMethodView: FC = () => {
|
||||
const { setSidebarView } = useUI()
|
||||
|
||||
return (
|
||||
<SidebarLayout handleBack={() => setSidebarView('CHECKOUT_VIEW')}>
|
||||
<div className="px-4 sm:px-6 flex-1">
|
||||
<Text variant="sectionHeading"> Payment Method</Text>
|
||||
<div>
|
||||
<div className={s.fieldset}>
|
||||
<label className={s.label}>Cardholder Name</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
<div className="grid gap-3 grid-flow-row grid-cols-12">
|
||||
<div className={cn(s.fieldset, 'col-span-7')}>
|
||||
<label className={s.label}>Card Number</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
<div className={cn(s.fieldset, 'col-span-3')}>
|
||||
<label className={s.label}>Expires</label>
|
||||
<input className={s.input} placeholder="MM/YY" />
|
||||
</div>
|
||||
<div className={cn(s.fieldset, 'col-span-2')}>
|
||||
<label className={s.label}>CVC</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
</div>
|
||||
<hr className="border-accent-2 my-6" />
|
||||
<div className="grid gap-3 grid-flow-row grid-cols-12">
|
||||
<div className={cn(s.fieldset, 'col-span-6')}>
|
||||
<label className={s.label}>First Name</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
<div className={cn(s.fieldset, 'col-span-6')}>
|
||||
<label className={s.label}>Last Name</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={s.fieldset}>
|
||||
<label className={s.label}>Company (Optional)</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
<div className={s.fieldset}>
|
||||
<label className={s.label}>Street and House Number</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
<div className={s.fieldset}>
|
||||
<label className={s.label}>Apartment, Suite, Etc. (Optional)</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
<div className="grid gap-3 grid-flow-row grid-cols-12">
|
||||
<div className={cn(s.fieldset, 'col-span-6')}>
|
||||
<label className={s.label}>Postal Code</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
<div className={cn(s.fieldset, 'col-span-6')}>
|
||||
<label className={s.label}>City</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={s.fieldset}>
|
||||
<label className={s.label}>Country/Region</label>
|
||||
<select className={s.select}>
|
||||
<option>Hong Kong</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sticky z-20 bottom-0 w-full right-0 left-0 py-12 bg-accent-0 border-t border-accent-2 px-6">
|
||||
<Button Component="a" width="100%" variant="ghost">
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default PaymentMethodView
|
@ -1 +0,0 @@
|
||||
export { default } from './PaymentMethodView'
|
@ -1,4 +0,0 @@
|
||||
.root {
|
||||
@apply border border-accent-2 px-6 py-5 mb-4 text-center
|
||||
flex items-center cursor-pointer hover:border-accent-4;
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
import { FC } from 'react'
|
||||
import s from './PaymentWidget.module.css'
|
||||
import { ChevronRight, CreditCard } from '@components/icons'
|
||||
|
||||
interface ComponentProps {
|
||||
onClick?: () => any
|
||||
}
|
||||
|
||||
const PaymentWidget: FC<ComponentProps> = ({ onClick }) => {
|
||||
/* Shipping Address
|
||||
Only available with checkout set to true -
|
||||
This means that the provider does offer checkout functionality. */
|
||||
return (
|
||||
<div onClick={onClick} className={s.root}>
|
||||
<div className="flex flex-1 items-center">
|
||||
<CreditCard className="w-5 flex" />
|
||||
<span className="ml-5 text-sm text-center font-medium">
|
||||
Add Payment Method
|
||||
</span>
|
||||
{/* <span>VISA #### #### #### 2345</span> */}
|
||||
</div>
|
||||
<div>
|
||||
<ChevronRight />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PaymentWidget
|
@ -1 +0,0 @@
|
||||
export { default } from './PaymentWidget'
|
@ -1,21 +0,0 @@
|
||||
.fieldset {
|
||||
@apply flex flex-col my-3;
|
||||
}
|
||||
|
||||
.fieldset .label {
|
||||
@apply text-accent-7 uppercase text-xs font-medium mb-2;
|
||||
}
|
||||
|
||||
.fieldset .input,
|
||||
.fieldset .select {
|
||||
@apply p-2 border border-accent-2 w-full text-sm font-normal;
|
||||
}
|
||||
|
||||
.fieldset .input:focus,
|
||||
.fieldset .select:focus {
|
||||
@apply outline-none shadow-outline-normal;
|
||||
}
|
||||
|
||||
.radio {
|
||||
@apply bg-black;
|
||||
}
|
@ -1,78 +0,0 @@
|
||||
import { FC } from 'react'
|
||||
import cn from 'classnames'
|
||||
import s from './ShippingView.module.css'
|
||||
import Button from '@components/ui/Button'
|
||||
import { useUI } from '@components/ui/context'
|
||||
import SidebarLayout from '@components/common/SidebarLayout'
|
||||
|
||||
const PaymentMethodView: FC = () => {
|
||||
const { setSidebarView } = useUI()
|
||||
|
||||
return (
|
||||
<SidebarLayout handleBack={() => setSidebarView('CHECKOUT_VIEW')}>
|
||||
<div className="px-4 sm:px-6 flex-1">
|
||||
<h2 className="pt-1 pb-8 text-2xl font-semibold tracking-wide cursor-pointer inline-block">
|
||||
Shipping
|
||||
</h2>
|
||||
<div>
|
||||
<div className="flex flex-row my-3 items-center">
|
||||
<input className={s.radio} type="radio" />
|
||||
<span className="ml-3 text-sm">Same as billing address</span>
|
||||
</div>
|
||||
<div className="flex flex-row my-3 items-center">
|
||||
<input className={s.radio} type="radio" />
|
||||
<span className="ml-3 text-sm">
|
||||
Use a different shipping address
|
||||
</span>
|
||||
</div>
|
||||
<hr className="border-accent-2 my-6" />
|
||||
<div className="grid gap-3 grid-flow-row grid-cols-12">
|
||||
<div className={cn(s.fieldset, 'col-span-6')}>
|
||||
<label className={s.label}>First Name</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
<div className={cn(s.fieldset, 'col-span-6')}>
|
||||
<label className={s.label}>Last Name</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={s.fieldset}>
|
||||
<label className={s.label}>Company (Optional)</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
<div className={s.fieldset}>
|
||||
<label className={s.label}>Street and House Number</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
<div className={s.fieldset}>
|
||||
<label className={s.label}>Apartment, Suite, Etc. (Optional)</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
<div className="grid gap-3 grid-flow-row grid-cols-12">
|
||||
<div className={cn(s.fieldset, 'col-span-6')}>
|
||||
<label className={s.label}>Postal Code</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
<div className={cn(s.fieldset, 'col-span-6')}>
|
||||
<label className={s.label}>City</label>
|
||||
<input className={s.input} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={s.fieldset}>
|
||||
<label className={s.label}>Country/Region</label>
|
||||
<select className={s.select}>
|
||||
<option>Hong Kong</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sticky z-20 bottom-0 w-full right-0 left-0 py-12 bg-accent-0 border-t border-accent-2 px-6">
|
||||
<Button Component="a" width="100%" variant="ghost">
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default PaymentMethodView
|
@ -1 +0,0 @@
|
||||
export { default } from './ShippingView'
|
@ -1,4 +0,0 @@
|
||||
.root {
|
||||
@apply border border-accent-2 px-6 py-5 mb-4 text-center
|
||||
flex items-center cursor-pointer hover:border-accent-4;
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
import { FC } from 'react'
|
||||
import s from './ShippingWidget.module.css'
|
||||
import { ChevronRight, MapPin } from '@components/icons'
|
||||
import cn from 'classnames'
|
||||
|
||||
interface ComponentProps {
|
||||
onClick?: () => any
|
||||
}
|
||||
|
||||
const ShippingWidget: FC<ComponentProps> = ({ onClick }) => {
|
||||
/* Shipping Address
|
||||
Only available with checkout set to true -
|
||||
This means that the provider does offer checkout functionality. */
|
||||
return (
|
||||
<div onClick={onClick} className={s.root}>
|
||||
<div className="flex flex-1 items-center">
|
||||
<MapPin className="w-5 flex" />
|
||||
<span className="ml-5 text-sm text-center font-medium">
|
||||
Add Shipping Address
|
||||
</span>
|
||||
{/* <span>
|
||||
1046 Kearny Street.<br/>
|
||||
San Franssisco, California
|
||||
</span> */}
|
||||
</div>
|
||||
<div>
|
||||
<ChevronRight />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShippingWidget
|
@ -1 +0,0 @@
|
||||
export { default } from './ShippingWidget'
|
@ -1,24 +0,0 @@
|
||||
import { FC, useRef, useEffect } from 'react'
|
||||
import { useUserAvatar } from '@lib/hooks/useUserAvatar'
|
||||
|
||||
interface Props {
|
||||
className?: string
|
||||
children?: any
|
||||
}
|
||||
|
||||
const Avatar: FC<Props> = ({}) => {
|
||||
let ref = useRef() as React.MutableRefObject<HTMLInputElement>
|
||||
let { userAvatar } = useUserAvatar()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{ backgroundImage: userAvatar }}
|
||||
className="inline-block h-8 w-8 rounded-full border-2 border-primary hover:border-secondary focus:border-secondary transition-colors ease-linear"
|
||||
>
|
||||
{/* Add an image - We're generating a gradient as placeholder <img></img> */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Avatar
|
@ -1 +0,0 @@
|
||||
export { default } from './Avatar'
|
@ -1,7 +0,0 @@
|
||||
.root {
|
||||
@apply text-center p-6 bg-primary text-sm flex-row justify-center items-center font-medium fixed bottom-0 w-full z-30 transition-all duration-300 ease-out;
|
||||
|
||||
@screen md {
|
||||
@apply flex text-left;
|
||||
}
|
||||
}
|
@ -1,39 +0,0 @@
|
||||
import cn from 'classnames'
|
||||
import s from './FeatureBar.module.css'
|
||||
|
||||
interface FeatureBarProps {
|
||||
className?: string
|
||||
title: string
|
||||
description?: string
|
||||
hide?: boolean
|
||||
action?: React.ReactNode
|
||||
}
|
||||
|
||||
const FeatureBar: React.FC<FeatureBarProps> = ({
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
action,
|
||||
hide,
|
||||
}) => {
|
||||
const rootClassName = cn(
|
||||
s.root,
|
||||
{
|
||||
transform: true,
|
||||
'translate-y-0 opacity-100': !hide,
|
||||
'translate-y-full opacity-0': hide,
|
||||
},
|
||||
className
|
||||
)
|
||||
return (
|
||||
<div className={rootClassName}>
|
||||
<span className="block md:inline">{title}</span>
|
||||
<span className="block mb-6 md:inline md:mb-0 md:ml-2">
|
||||
{description}
|
||||
</span>
|
||||
{action && action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FeatureBar
|
@ -1 +0,0 @@
|
||||
export { default } from './FeatureBar'
|
@ -1,13 +0,0 @@
|
||||
.root {
|
||||
@apply border-t border-accent-2;
|
||||
}
|
||||
|
||||
.link {
|
||||
& > svg {
|
||||
@apply transform duration-75 ease-linear;
|
||||
}
|
||||
|
||||
&:hover > svg {
|
||||
@apply scale-110;
|
||||
}
|
||||
}
|
@ -1,117 +0,0 @@
|
||||
import { FC } from 'react'
|
||||
import cn from 'classnames'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import type { Page } from '@commerce/types/page'
|
||||
import getSlug from '@lib/get-slug'
|
||||
import { Github, Vercel } from '@components/icons'
|
||||
import { Logo, Container } from '@components/ui'
|
||||
import { I18nWidget } from '@components/common'
|
||||
import s from './Footer.module.css'
|
||||
|
||||
interface Props {
|
||||
className?: string
|
||||
children?: any
|
||||
pages?: Page[]
|
||||
}
|
||||
|
||||
const links = [
|
||||
{
|
||||
name: 'Home',
|
||||
url: '/',
|
||||
},
|
||||
]
|
||||
|
||||
const Footer: FC<Props> = ({ className, pages }) => {
|
||||
const { sitePages } = usePages(pages)
|
||||
const rootClassName = cn(s.root, className)
|
||||
|
||||
return (
|
||||
<footer className={rootClassName}>
|
||||
<Container>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 border-b border-accent-2 py-12 text-primary bg-primary transition-colors duration-150">
|
||||
<div className="col-span-1 lg:col-span-2">
|
||||
<Link href="/">
|
||||
<a className="flex flex-initial items-center font-bold md:mr-24">
|
||||
<span className="rounded-full border border-accent-6 mr-2">
|
||||
<Logo />
|
||||
</span>
|
||||
<span>ACME</span>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="col-span-1 lg:col-span-8">
|
||||
<div className="grid md:grid-rows-4 md:grid-cols-3 md:grid-flow-col">
|
||||
{[...links, ...sitePages].map((page) => (
|
||||
<span key={page.url} className="py-3 md:py-0 md:pb-4">
|
||||
<Link href={page.url!}>
|
||||
<a className="text-accent-9 hover:text-accent-6 transition ease-in-out duration-150">
|
||||
{page.name}
|
||||
</a>
|
||||
</Link>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 lg:col-span-2 flex items-start lg:justify-end text-primary">
|
||||
<div className="flex space-x-6 items-center h-10">
|
||||
<a
|
||||
className={s.link}
|
||||
aria-label="Github Repository"
|
||||
href="https://github.com/vercel/commerce"
|
||||
>
|
||||
<Github />
|
||||
</a>
|
||||
<I18nWidget />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-6 pb-10 flex flex-col md:flex-row justify-between items-center space-y-4 text-accent-6 text-sm">
|
||||
<div>
|
||||
<span>© 2020 ACME, Inc. All rights reserved.</span>
|
||||
</div>
|
||||
<div className="flex items-center text-primary text-sm">
|
||||
<span className="text-primary">Created by</span>
|
||||
<a
|
||||
rel="noopener"
|
||||
href="https://vercel.com"
|
||||
aria-label="Vercel.com Link"
|
||||
target="_blank"
|
||||
className="text-primary"
|
||||
>
|
||||
<Vercel
|
||||
className="inline-block h-6 ml-3 text-primary"
|
||||
alt="Vercel.com Logo"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
function usePages(pages?: Page[]) {
|
||||
const { locale } = useRouter()
|
||||
const sitePages: Page[] = []
|
||||
|
||||
if (pages) {
|
||||
pages.forEach((page) => {
|
||||
const slug = page.url && getSlug(page.url)
|
||||
if (!slug) return
|
||||
if (locale && !slug.startsWith(`${locale}/`)) return
|
||||
sitePages.push(page)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
sitePages: sitePages.sort(bySortOrder),
|
||||
}
|
||||
}
|
||||
|
||||
// Sort pages by the sort order assigned in the BC dashboard
|
||||
function bySortOrder(a: Page, b: Page) {
|
||||
return (a.sort_order ?? 0) - (b.sort_order ?? 0)
|
||||
}
|
||||
|
||||
export default Footer
|
@ -1 +0,0 @@
|
||||
export { default } from './Footer'
|
@ -1,18 +0,0 @@
|
||||
import { FC } from 'react'
|
||||
import NextHead from 'next/head'
|
||||
import { DefaultSeo } from 'next-seo'
|
||||
import config from '@config/seo.json'
|
||||
|
||||
const Head: FC = () => {
|
||||
return (
|
||||
<>
|
||||
<DefaultSeo {...config} />
|
||||
<NextHead>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="manifest" href="/site.webmanifest" key="site-manifest" />
|
||||
</NextHead>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Head
|
@ -1 +0,0 @@
|
||||
export { default } from './Head'
|
@ -1,23 +0,0 @@
|
||||
.root {
|
||||
@apply py-12 flex flex-col w-full px-6;
|
||||
|
||||
@screen md {
|
||||
@apply flex-row;
|
||||
}
|
||||
|
||||
& .asideWrapper {
|
||||
@apply pr-3 w-full relative;
|
||||
|
||||
@screen md {
|
||||
@apply w-48;
|
||||
}
|
||||
}
|
||||
|
||||
& .aside {
|
||||
@apply flex flex-row w-full justify-around mb-12;
|
||||
|
||||
@screen md {
|
||||
@apply mb-0 block sticky top-32;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,73 +0,0 @@
|
||||
import { FC } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Product } from '@commerce/types/product'
|
||||
import { Grid } from '@components/ui'
|
||||
import { ProductCard } from '@components/product'
|
||||
import s from './HomeAllProductsGrid.module.css'
|
||||
import { getCategoryPath, getDesignerPath } from '@lib/search'
|
||||
|
||||
interface Props {
|
||||
categories?: any
|
||||
brands?: any
|
||||
products?: Product[]
|
||||
}
|
||||
|
||||
const HomeAllProductsGrid: FC<Props> = ({
|
||||
categories,
|
||||
brands,
|
||||
products = [],
|
||||
}) => {
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.asideWrapper}>
|
||||
<div className={s.aside}>
|
||||
<ul className="mb-10">
|
||||
<li className="py-1 text-base font-bold tracking-wide">
|
||||
<Link href={getCategoryPath('')}>
|
||||
<a>All Categories</a>
|
||||
</Link>
|
||||
</li>
|
||||
{categories.map((cat: any) => (
|
||||
<li key={cat.path} className="py-1 text-accent-8 text-base">
|
||||
<Link href={getCategoryPath(cat.path)}>
|
||||
<a>{cat.name}</a>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ul className="">
|
||||
<li className="py-1 text-base font-bold tracking-wide">
|
||||
<Link href={getDesignerPath('')}>
|
||||
<a>All Designers</a>
|
||||
</Link>
|
||||
</li>
|
||||
{brands.flatMap(({ node }: any) => (
|
||||
<li key={node.path} className="py-1 text-accent-8 text-base">
|
||||
<Link href={getDesignerPath(node.path)}>
|
||||
<a>{node.name}</a>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Grid layout="normal">
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
key={product.path}
|
||||
product={product}
|
||||
variant="simple"
|
||||
imgProps={{
|
||||
width: 480,
|
||||
height: 480,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HomeAllProductsGrid
|
@ -1 +0,0 @@
|
||||
export { default } from './HomeAllProductsGrid'
|
@ -1,46 +0,0 @@
|
||||
.root {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.button {
|
||||
@apply h-10 px-2 rounded-md border border-accent-2 flex items-center justify-center transition-colors ease-linear;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
@apply border-accent-3 shadow-sm;
|
||||
}
|
||||
|
||||
.button:focus {
|
||||
@apply outline-none;
|
||||
}
|
||||
|
||||
.dropdownMenu {
|
||||
@apply fixed right-0 top-12 mt-2 origin-top-right outline-none bg-primary z-40 w-full h-full;
|
||||
|
||||
@screen lg {
|
||||
@apply absolute border border-accent-1 shadow-lg w-56 h-auto;
|
||||
}
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
@screen md {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
@apply flex cursor-pointer px-6 py-3 transition ease-in-out duration-150 text-primary leading-6 font-medium items-center;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
@apply bg-accent-1;
|
||||
}
|
||||
|
||||
.icon {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.icon.active {
|
||||
transform: rotate(180deg);
|
||||
}
|
@ -1,101 +0,0 @@
|
||||
import cn from 'classnames'
|
||||
import Link from 'next/link'
|
||||
import { FC, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import s from './I18nWidget.module.css'
|
||||
import { Cross, ChevronUp } from '@components/icons'
|
||||
import ClickOutside from '@lib/click-outside'
|
||||
interface LOCALE_DATA {
|
||||
name: string
|
||||
img: {
|
||||
filename: string
|
||||
alt: string
|
||||
}
|
||||
}
|
||||
|
||||
const LOCALES_MAP: Record<string, LOCALE_DATA> = {
|
||||
es: {
|
||||
name: 'Español',
|
||||
img: {
|
||||
filename: 'flag-es-co.svg',
|
||||
alt: 'Bandera Colombiana',
|
||||
},
|
||||
},
|
||||
'en-US': {
|
||||
name: 'English',
|
||||
img: {
|
||||
filename: 'flag-en-us.svg',
|
||||
alt: 'US Flag',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const I18nWidget: FC = () => {
|
||||
const [display, setDisplay] = useState(false)
|
||||
const {
|
||||
locale,
|
||||
locales,
|
||||
defaultLocale = 'en-US',
|
||||
asPath: currentPath,
|
||||
} = useRouter()
|
||||
|
||||
const options = locales?.filter((val) => val !== locale)
|
||||
const currentLocale = locale || defaultLocale
|
||||
|
||||
return (
|
||||
<ClickOutside active={display} onClick={() => setDisplay(false)}>
|
||||
<nav className={s.root}>
|
||||
<div
|
||||
className="flex items-center relative"
|
||||
onClick={() => setDisplay(!display)}
|
||||
>
|
||||
<button className={s.button} aria-label="Language selector">
|
||||
<img
|
||||
width="20"
|
||||
height="20"
|
||||
className="block mr-2 w-5"
|
||||
src={`/${LOCALES_MAP[currentLocale].img.filename}`}
|
||||
alt={LOCALES_MAP[currentLocale].img.alt}
|
||||
/>
|
||||
{options && (
|
||||
<span className="cursor-pointer">
|
||||
<ChevronUp className={cn(s.icon, { [s.active]: display })} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute top-0 right-0">
|
||||
{options?.length && display ? (
|
||||
<div className={s.dropdownMenu}>
|
||||
<div className="flex flex-row justify-end px-6">
|
||||
<button
|
||||
onClick={() => setDisplay(false)}
|
||||
aria-label="Close panel"
|
||||
className={s.closeButton}
|
||||
>
|
||||
<Cross className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<ul>
|
||||
{options.map((locale) => (
|
||||
<li key={locale}>
|
||||
<Link href={currentPath} locale={locale}>
|
||||
<a
|
||||
className={cn(s.item)}
|
||||
onClick={() => setDisplay(false)}
|
||||
>
|
||||
{LOCALES_MAP[locale].name}
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</nav>
|
||||
</ClickOutside>
|
||||
)
|
||||
}
|
||||
|
||||
export default I18nWidget
|
@ -1 +0,0 @@
|
||||
export { default } from './I18nWidget'
|
@ -1,4 +0,0 @@
|
||||
.root {
|
||||
@apply h-full bg-primary mx-auto transition-colors duration-150;
|
||||
max-width: 2460px;
|
||||
}
|
@ -1,126 +0,0 @@
|
||||
import cn from 'classnames'
|
||||
import React, { FC } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/router'
|
||||
import { CommerceProvider } from '@framework'
|
||||
import { useUI } from '@components/ui/context'
|
||||
import type { Page } from '@commerce/types/page'
|
||||
import { Navbar, Footer } from '@components/common'
|
||||
import type { Category } from '@commerce/types/site'
|
||||
import ShippingView from '@components/checkout/ShippingView'
|
||||
import CartSidebarView from '@components/cart/CartSidebarView'
|
||||
import { useAcceptCookies } from '@lib/hooks/useAcceptCookies'
|
||||
import { Sidebar, Button, Modal, LoadingDots } from '@components/ui'
|
||||
import PaymentMethodView from '@components/checkout/PaymentMethodView'
|
||||
import CheckoutSidebarView from '@components/checkout/CheckoutSidebarView'
|
||||
|
||||
import LoginView from '@components/auth/LoginView'
|
||||
import s from './Layout.module.css'
|
||||
|
||||
const Loading = () => (
|
||||
<div className="w-80 h-80 flex items-center text-center justify-center p-3">
|
||||
<LoadingDots />
|
||||
</div>
|
||||
)
|
||||
|
||||
const dynamicProps = {
|
||||
loading: () => <Loading />,
|
||||
}
|
||||
|
||||
const SignUpView = dynamic(
|
||||
() => import('@components/auth/SignUpView'),
|
||||
dynamicProps
|
||||
)
|
||||
|
||||
const ForgotPassword = dynamic(
|
||||
() => import('@components/auth/ForgotPassword'),
|
||||
dynamicProps
|
||||
)
|
||||
|
||||
const FeatureBar = dynamic(
|
||||
() => import('@components/common/FeatureBar'),
|
||||
dynamicProps
|
||||
)
|
||||
|
||||
interface Props {
|
||||
pageProps: {
|
||||
pages?: Page[]
|
||||
categories: Category[]
|
||||
}
|
||||
}
|
||||
|
||||
const ModalView: FC<{ modalView: string; closeModal(): any }> = ({
|
||||
modalView,
|
||||
closeModal,
|
||||
}) => {
|
||||
return (
|
||||
<Modal onClose={closeModal}>
|
||||
{modalView === 'LOGIN_VIEW' && <LoginView />}
|
||||
{modalView === 'SIGNUP_VIEW' && <SignUpView />}
|
||||
{modalView === 'FORGOT_VIEW' && <ForgotPassword />}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const ModalUI: FC = () => {
|
||||
const { displayModal, closeModal, modalView } = useUI()
|
||||
return displayModal ? (
|
||||
<ModalView modalView={modalView} closeModal={closeModal} />
|
||||
) : null
|
||||
}
|
||||
|
||||
const SidebarView: FC<{ sidebarView: string; closeSidebar(): any }> = ({
|
||||
sidebarView,
|
||||
closeSidebar,
|
||||
}) => {
|
||||
return (
|
||||
<Sidebar onClose={closeSidebar}>
|
||||
{sidebarView === 'CART_VIEW' && <CartSidebarView />}
|
||||
{sidebarView === 'CHECKOUT_VIEW' && <CheckoutSidebarView />}
|
||||
{sidebarView === 'PAYMENT_VIEW' && <PaymentMethodView />}
|
||||
{sidebarView === 'SHIPPING_VIEW' && <ShippingView />}
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarUI: FC = () => {
|
||||
const { displaySidebar, closeSidebar, sidebarView } = useUI()
|
||||
return displaySidebar ? (
|
||||
<SidebarView sidebarView={sidebarView} closeSidebar={closeSidebar} />
|
||||
) : null
|
||||
}
|
||||
|
||||
const Layout: FC<Props> = ({
|
||||
children,
|
||||
pageProps: { categories = [], ...pageProps },
|
||||
}) => {
|
||||
const { acceptedCookies, onAcceptCookies } = useAcceptCookies()
|
||||
const { locale = 'en-US' } = useRouter()
|
||||
const navBarlinks = categories.slice(0, 2).map((c) => ({
|
||||
label: c.name,
|
||||
href: `/search/${c.slug}`,
|
||||
}))
|
||||
|
||||
return (
|
||||
<CommerceProvider locale={locale}>
|
||||
<div className={cn(s.root)}>
|
||||
<Navbar links={navBarlinks} />
|
||||
<main className="fit">{children}</main>
|
||||
<Footer pages={pageProps.pages} />
|
||||
<ModalUI />
|
||||
<SidebarUI />
|
||||
<FeatureBar
|
||||
title="This site uses cookies to improve your experience. By clicking, you agree to our Privacy Policy."
|
||||
hide={acceptedCookies}
|
||||
action={
|
||||
<Button className="mx-5" onClick={() => onAcceptCookies()}>
|
||||
Accept cookies
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CommerceProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
@ -1 +0,0 @@
|
||||
export { default } from './Layout'
|
@ -1,35 +0,0 @@
|
||||
.root {
|
||||
@apply sticky top-0 bg-primary z-40 transition-all duration-150;
|
||||
min-height: 74px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
@apply relative flex flex-row justify-between py-4 md:py-4;
|
||||
}
|
||||
|
||||
.navMenu {
|
||||
@apply hidden ml-6 space-x-4 lg:block;
|
||||
}
|
||||
|
||||
.link {
|
||||
@apply inline-flex items-center leading-6
|
||||
transition ease-in-out duration-75 cursor-pointer
|
||||
text-accent-5;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
@apply text-accent-9;
|
||||
}
|
||||
|
||||
.link:focus {
|
||||
@apply outline-none text-accent-8;
|
||||
}
|
||||
|
||||
.logo {
|
||||
@apply cursor-pointer rounded-full border transform duration-100 ease-in-out;
|
||||
|
||||
&:hover {
|
||||
@apply shadow-md;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
@ -1,53 +0,0 @@
|
||||
import { FC } from 'react'
|
||||
import Link from 'next/link'
|
||||
import s from './Navbar.module.css'
|
||||
import NavbarRoot from './NavbarRoot'
|
||||
import { Logo, Container } from '@components/ui'
|
||||
import { Searchbar, UserNav } from '@components/common'
|
||||
|
||||
interface Link {
|
||||
href: string
|
||||
label: string
|
||||
}
|
||||
interface NavbarProps {
|
||||
links?: Link[]
|
||||
}
|
||||
|
||||
const Navbar: FC<NavbarProps> = ({ links }) => (
|
||||
<NavbarRoot>
|
||||
<Container>
|
||||
<div className={s.nav}>
|
||||
<div className="flex items-center flex-1">
|
||||
<Link href="/">
|
||||
<a className={s.logo} aria-label="Logo">
|
||||
<Logo />
|
||||
</a>
|
||||
</Link>
|
||||
<nav className={s.navMenu}>
|
||||
<Link href="/search">
|
||||
<a className={s.link}>All</a>
|
||||
</Link>
|
||||
{links?.map((l) => (
|
||||
<Link href={l.href} key={l.href}>
|
||||
<a className={s.link}>{l.label}</a>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
{process.env.COMMERCE_SEARCH_ENABLED && (
|
||||
<div className="justify-center flex-1 hidden lg:flex">
|
||||
<Searchbar />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end flex-1 space-x-8">
|
||||
<UserNav />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex pb-4 lg:px-6 lg:hidden">
|
||||
<Searchbar id="mobile-search" />
|
||||
</div>
|
||||
</Container>
|
||||
</NavbarRoot>
|
||||
)
|
||||
|
||||
export default Navbar
|
@ -1,33 +0,0 @@
|
||||
import { FC, useState, useEffect } from 'react'
|
||||
import throttle from 'lodash.throttle'
|
||||
import cn from 'classnames'
|
||||
import s from './Navbar.module.css'
|
||||
|
||||
const NavbarRoot: FC = ({ children }) => {
|
||||
const [hasScrolled, setHasScrolled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = throttle(() => {
|
||||
const offset = 0
|
||||
const { scrollTop } = document.documentElement
|
||||
const scrolled = scrollTop > offset
|
||||
|
||||
if (hasScrolled !== scrolled) {
|
||||
setHasScrolled(scrolled)
|
||||
}
|
||||
}, 200)
|
||||
|
||||
document.addEventListener('scroll', handleScroll)
|
||||
return () => {
|
||||
document.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [hasScrolled])
|
||||
|
||||
return (
|
||||
<div className={cn(s.root, { 'shadow-magical': hasScrolled })}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NavbarRoot
|
@ -1 +0,0 @@
|
||||
export { default } from './Navbar'
|
@ -1,29 +0,0 @@
|
||||
.root {
|
||||
@apply relative text-sm bg-accent-0 text-base w-full transition-colors duration-150 border border-accent-2;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply bg-transparent px-3 py-2 appearance-none w-full transition duration-150 ease-in-out pr-10;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
@apply text-accent-3;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
@apply outline-none shadow-outline-normal;
|
||||
}
|
||||
|
||||
.iconContainer {
|
||||
@apply absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@apply h-5 w-5;
|
||||
}
|
||||
|
||||
@screen sm {
|
||||
.input {
|
||||
min-width: 300px;
|
||||
}
|
||||
}
|
@ -1,63 +0,0 @@
|
||||
import { FC, InputHTMLAttributes, useEffect, useMemo } from 'react'
|
||||
import cn from 'classnames'
|
||||
import s from './Searchbar.module.css'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
interface Props {
|
||||
className?: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
const Searchbar: FC<Props> = ({ className, id = 'search' }) => {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
router.prefetch('/search')
|
||||
}, [])
|
||||
|
||||
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
const q = e.currentTarget.value
|
||||
|
||||
router.push(
|
||||
{
|
||||
pathname: `/search`,
|
||||
query: q ? { q } : {},
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return useMemo(
|
||||
() => (
|
||||
<div className={cn(s.root, className)}>
|
||||
<label className="hidden" htmlFor={id}>
|
||||
Search
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
className={s.input}
|
||||
placeholder="Search for products..."
|
||||
defaultValue={router.query.q}
|
||||
onKeyUp={handleKeyUp}
|
||||
/>
|
||||
<div className={s.iconContainer}>
|
||||
<svg className={s.icon} fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
[]
|
||||
)
|
||||
}
|
||||
|
||||
export default Searchbar
|
@ -1 +0,0 @@
|
||||
export { default } from './Searchbar'
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user