mirror of
https://github.com/vercel/commerce.git
synced 2025-05-18 23:46:58 +00:00
Merge pull request #17 from zenzen-sol/sol/preview
chose: preview -> narai
This commit is contained in:
commit
0d2fef5f42
6
.github/workflows/test.yml
vendored
6
.github/workflows/test.yml
vendored
@ -5,11 +5,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Cancel running workflows
|
- name: Cancel running workflows
|
||||||
uses: styfle/cancel-workflow-action@0.11.0
|
uses: styfle/cancel-workflow-action@0.12.0
|
||||||
with:
|
with:
|
||||||
access_token: ${{ github.token }}
|
access_token: ${{ github.token }}
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
- name: Set node version
|
- name: Set node version
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
@ -27,6 +27,6 @@ jobs:
|
|||||||
key: node-modules-cache-${{ hashFiles('**/pnpm-lock.yaml') }}
|
key: node-modules-cache-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||||
run: pnpm install
|
run: pnpm install --no-frozen-lockfile
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: pnpm test
|
run: pnpm test
|
||||||
|
BIN
.yarn/cache/@babel-helper-validator-identifier-npm-7.22.20-18305bb306-136412784d.zip
vendored
Normal file
BIN
.yarn/cache/@babel-helper-validator-identifier-npm-7.22.20-18305bb306-136412784d.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/@headlessui-react-npm-1.7.17-c3f120aed0-0cdb67747e.zip
vendored
Normal file
BIN
.yarn/cache/@headlessui-react-npm-1.7.17-c3f120aed0-0cdb67747e.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/@humanwhocodes-object-schema-npm-2.0.1-c23364bbfc-24929487b1.zip
vendored
Normal file
BIN
.yarn/cache/@humanwhocodes-object-schema-npm-2.0.1-c23364bbfc-24929487b1.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/@next-env-npm-14.0.3-canary.3-9d637eb650-b54b00343a.zip
vendored
Normal file
BIN
.yarn/cache/@next-env-npm-14.0.3-canary.3-9d637eb650-b54b00343a.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/@swc-helpers-npm-0.5.2-f81ca286ad-51d7e3d8bd.zip
vendored
Normal file
BIN
.yarn/cache/@swc-helpers-npm-0.5.2-f81ca286ad-51d7e3d8bd.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/@types-node-npm-20.9.0-d5b5fa6634-bfd927da6b.zip
vendored
Normal file
BIN
.yarn/cache/@types-node-npm-20.9.0-d5b5fa6634-bfd927da6b.zip
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/@types-react-dom-npm-18.2.15-2269cd02ff-8e9631600c.zip
vendored
Normal file
BIN
.yarn/cache/@types-react-dom-npm-18.2.15-2269cd02ff-8e9631600c.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/@types-react-npm-18.2.37-3fa76216da-2d2599f1a0.zip
vendored
Normal file
BIN
.yarn/cache/@types-react-npm-18.2.37-3fa76216da-2d2599f1a0.zip
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/@ungap-structured-clone-npm-1.2.0-648f0b82e0-4f656b7b46.zip
vendored
Normal file
BIN
.yarn/cache/@ungap-structured-clone-npm-1.2.0-648f0b82e0-4f656b7b46.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/eslint-visitor-keys-npm-3.4.3-a356ac7e46-36e9ef87fc.zip
vendored
Normal file
BIN
.yarn/cache/eslint-visitor-keys-npm-3.4.3-a356ac7e46-36e9ef87fc.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/next-gtm-npm-1.0.0-00cd2464bb-7d2737c83c.zip
vendored
Normal file
BIN
.yarn/cache/next-gtm-npm-1.0.0-00cd2464bb-7d2737c83c.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/next-intl-npm-3.0.0-rc.10-e930606451-8b54c0c6d8.zip
vendored
Normal file
BIN
.yarn/cache/next-intl-npm-3.0.0-rc.10-e930606451-8b54c0c6d8.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/postcss-npm-8.4.31-385051a82b-1d8611341b.zip
vendored
Normal file
BIN
.yarn/cache/postcss-npm-8.4.31-385051a82b-1d8611341b.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/prettier-plugin-tailwindcss-npm-0.5.7-37e26935aa-616c6eabd3.zip
vendored
Normal file
BIN
.yarn/cache/prettier-plugin-tailwindcss-npm-0.5.7-37e26935aa-616c6eabd3.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/react-dom-npm-18.3.0-canary-6b3834a45-20231110-f7238e0756-b4d4a79fee.zip
vendored
Normal file
BIN
.yarn/cache/react-dom-npm-18.3.0-canary-6b3834a45-20231110-f7238e0756-b4d4a79fee.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/react-npm-18.3.0-canary-6b3834a45-20231110-ba61e1cb89-1c89235bd8.zip
vendored
Normal file
BIN
.yarn/cache/react-npm-18.3.0-canary-6b3834a45-20231110-ba61e1cb89-1c89235bd8.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/scheduler-npm-0.24.0-canary-6b3834a45-20231110-a69e0a4d60-0caa252a3b.zip
vendored
Normal file
BIN
.yarn/cache/scheduler-npm-0.24.0-canary-6b3834a45-20231110-a69e0a4d60-0caa252a3b.zip
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/undici-types-npm-5.26.5-de4f7c7bb9-3192ef6f3f.zip
vendored
Normal file
BIN
.yarn/cache/undici-types-npm-5.26.5-de4f7c7bb9-3192ef6f3f.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/use-intl-npm-3.0.0-rc.6-599d44c8b3-b81452e795.zip
vendored
Normal file
BIN
.yarn/cache/use-intl-npm-3.0.0-rc.6-599d44c8b3-b81452e795.zip
vendored
Normal file
Binary file not shown.
BIN
.yarn/cache/zod-npm-3.21.4-9f570b215c-f185ba8734.zip
vendored
BIN
.yarn/cache/zod-npm-3.21.4-9f570b215c-f185ba8734.zip
vendored
Binary file not shown.
211
README.md
211
README.md
@ -23,13 +23,26 @@ A Next.js 13 and App Router-ready ecommerce template featuring:
|
|||||||
|
|
||||||
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).
|
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).
|
||||||
|
|
||||||
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.
|
Vercel is 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.
|
||||||
|
|
||||||
- Shopify (this repository)
|
- Shopify (this repository)
|
||||||
- [BigCommerce](https://github.com/bigcommerce/nextjs-commerce) ([Demo](https://next-commerce-v2.vercel.app/))
|
- [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/))
|
- [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/))
|
- [Saleor](https://github.com/saleor/nextjs-commerce) ([Demo](https://saleor-commerce.vercel.app/))
|
||||||
|
- [Shopware](https://github.com/shopwareLabs/vercel-commerce) ([Demo](https://shopware-vercel-commerce-react.vercel.app/))
|
||||||
- [Swell](https://github.com/swellstores/verswell-commerce) ([Demo](https://verswell-commerce.vercel.app/))
|
- [Swell](https://github.com/swellstores/verswell-commerce) ([Demo](https://verswell-commerce.vercel.app/))
|
||||||
|
- [Umbraco](https://github.com/umbraco/Umbraco.VercelCommerce.Demo) ([Demo](https://vercel-commerce-demo.umbraco.com/))
|
||||||
|
- [Wix](https://github.com/wix/nextjs-commerce) ([Demo](https://wix-nextjs-commerce.vercel.app/))
|
||||||
|
|
||||||
|
> Note: Providers, if you are looking to use similar products for your demo, you can [download these assets](https://drive.google.com/file/d/1q_bKerjrwZgHwCw0ovfUMW6He9VtepO_/view?usp=sharing).
|
||||||
|
|
||||||
|
## Integrations
|
||||||
|
|
||||||
|
Integrations enable upgraded or additional functionality for Next.js Commerce
|
||||||
|
|
||||||
|
- [Orama](https://github.com/oramasearch/nextjs-commerce) ([Demo](https://vercel-commerce.oramasearch.com/))
|
||||||
|
- Upgrades search to include typeahead with dynamic re-rendering, vector-based similarity search, and JS-based configuration.
|
||||||
|
- Search runs entirely in the browser for smaller catalogs or on a CDN for larger.
|
||||||
|
|
||||||
## Running locally
|
## Running locally
|
||||||
|
|
||||||
@ -55,199 +68,9 @@ Your app should now be running on [localhost:3000](http://localhost:3000/).
|
|||||||
1. Select the `Vercel Solutions` scope.
|
1. Select the `Vercel Solutions` scope.
|
||||||
1. Connect to the existing `commerce-shopify` project.
|
1. Connect to the existing `commerce-shopify` project.
|
||||||
1. Run `vc env pull` to get environment variables.
|
1. Run `vc env pull` to get environment variables.
|
||||||
1. Run `pmpm dev` to ensure everything is working correctly.
|
1. Run `pnpm dev` to ensure everything is working correctly.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## How to configure your Shopify store for Next.js Commerce
|
## Vercel, Next.js Commerce, and Shopify Integration Guide
|
||||||
|
|
||||||
Next.js Commerce requires a [paid Shopify plan](https://www.shopify.com/pricing).
|
You can use this comprehensive [integration guide](http://vercel.com/docs/integrations/shopify) with step-by-step instructions on how to configure Shopify as a headless CMS using Next.js Commerce as your headless Shopify storefront on Vercel.
|
||||||
|
|
||||||
> 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.
|
|
||||||
|
|
||||||

|
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import { Suspense } from 'react';
|
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return <Suspense>{children}</Suspense>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
@ -4,13 +4,11 @@ import { SupportedLocale } from 'components/layout/navbar/language-control';
|
|||||||
import Navbar from 'components/layout/navbar';
|
import Navbar from 'components/layout/navbar';
|
||||||
import { getCart, getPage, getProduct } from 'lib/shopify';
|
import { getCart, getPage, getProduct } from 'lib/shopify';
|
||||||
import { Product } from 'lib/shopify/types';
|
import { Product } from 'lib/shopify/types';
|
||||||
|
import { unstable_setRequestLocale } from 'next-intl/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import AboutNaraiDetail from './about-narai-detail';
|
import AboutNaraiDetail from './about-narai-detail';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
|
||||||
export const revalidate = 43200; // 12 hours in seconds
|
|
||||||
|
|
||||||
const { SITE_NAME } = process.env;
|
const { SITE_NAME } = process.env;
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@ -22,6 +20,10 @@ export const metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ params }: { params: { locale?: SupportedLocale } }) {
|
export default async function Page({ params }: { params: { locale?: SupportedLocale } }) {
|
||||||
|
if (!!params?.locale) {
|
||||||
|
unstable_setRequestLocale(params.locale);
|
||||||
|
}
|
||||||
|
|
||||||
const cartId = cookies().get('cartId')?.value;
|
const cartId = cookies().get('cartId')?.value;
|
||||||
let cart;
|
let cart;
|
||||||
|
|
||||||
@ -39,13 +41,13 @@ export default async function Page({ params }: { params: { locale?: SupportedLoc
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Navbar cart={cart} locale={params?.locale} compact promotedItem={promotedItem} />
|
<Navbar cart={cart} locale={params?.locale} compact promotedItem={promotedItem} />
|
||||||
<div className="pt-24 md:pt-32">
|
<Suspense fallback={null}>
|
||||||
<AboutNaraiDetail awards={awardsPage.body} />
|
<div className="pt-24 md:pt-32">
|
||||||
</div>
|
<AboutNaraiDetail awards={awardsPage.body} />
|
||||||
|
</div>
|
||||||
<Suspense>
|
|
||||||
<Footer cart={cart} />
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
<Footer cart={cart} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import { Suspense } from 'react';
|
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return <Suspense>{children}</Suspense>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
@ -4,13 +4,11 @@ import { SupportedLocale } from 'components/layout/navbar/language-control';
|
|||||||
import Navbar from 'components/layout/navbar';
|
import Navbar from 'components/layout/navbar';
|
||||||
import { getCart, getProduct } from 'lib/shopify';
|
import { getCart, getProduct } from 'lib/shopify';
|
||||||
import { Product } from 'lib/shopify/types';
|
import { Product } from 'lib/shopify/types';
|
||||||
|
import { unstable_setRequestLocale } from 'next-intl/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import SagyobarDetail from './sagyobar-detail';
|
import SagyobarDetail from './sagyobar-detail';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
|
||||||
export const revalidate = 43200; // 12 hours in seconds
|
|
||||||
|
|
||||||
const { SITE_NAME } = process.env;
|
const { SITE_NAME } = process.env;
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@ -22,6 +20,10 @@ export const metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ params }: { params: { locale?: SupportedLocale } }) {
|
export default async function Page({ params }: { params: { locale?: SupportedLocale } }) {
|
||||||
|
if (!!params?.locale) {
|
||||||
|
unstable_setRequestLocale(params.locale);
|
||||||
|
}
|
||||||
|
|
||||||
const cartId = cookies().get('cartId')?.value;
|
const cartId = cookies().get('cartId')?.value;
|
||||||
let cart;
|
let cart;
|
||||||
|
|
||||||
@ -37,13 +39,13 @@ export default async function Page({ params }: { params: { locale?: SupportedLoc
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Navbar cart={cart} locale={params?.locale} compact promotedItem={promotedItem} />
|
<Navbar cart={cart} locale={params?.locale} compact promotedItem={promotedItem} />
|
||||||
<div className="pt-12">
|
<Suspense fallback={null}>
|
||||||
<SagyobarDetail />
|
<div className="pt-12">
|
||||||
</div>
|
<SagyobarDetail />
|
||||||
|
</div>
|
||||||
<Suspense>
|
|
||||||
<Footer cart={cart} />
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
<Footer cart={cart} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import { Suspense } from 'react';
|
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return <Suspense>{children}</Suspense>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
@ -4,13 +4,11 @@ import { SupportedLocale } from 'components/layout/navbar/language-control';
|
|||||||
import Navbar from 'components/layout/navbar';
|
import Navbar from 'components/layout/navbar';
|
||||||
import { getCart, getProduct } from 'lib/shopify';
|
import { getCart, getProduct } from 'lib/shopify';
|
||||||
import { Product } from 'lib/shopify/types';
|
import { Product } from 'lib/shopify/types';
|
||||||
|
import { unstable_setRequestLocale } from 'next-intl/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import CompanyDetail from './company-detail';
|
import CompanyDetail from './company-detail';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
|
||||||
export const revalidate = 43200; // 12 hours in seconds
|
|
||||||
|
|
||||||
const { SITE_NAME } = process.env;
|
const { SITE_NAME } = process.env;
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@ -22,6 +20,10 @@ export const metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ params }: { params: { locale?: SupportedLocale } }) {
|
export default async function Page({ params }: { params: { locale?: SupportedLocale } }) {
|
||||||
|
if (!!params?.locale) {
|
||||||
|
unstable_setRequestLocale(params.locale);
|
||||||
|
}
|
||||||
|
|
||||||
const cartId = cookies().get('cartId')?.value;
|
const cartId = cookies().get('cartId')?.value;
|
||||||
let cart;
|
let cart;
|
||||||
|
|
||||||
@ -37,13 +39,13 @@ export default async function Page({ params }: { params: { locale?: SupportedLoc
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Navbar cart={cart} locale={params?.locale} compact promotedItem={promotedItem} />
|
<Navbar cart={cart} locale={params?.locale} compact promotedItem={promotedItem} />
|
||||||
<div className="pt-12">
|
<Suspense fallback={null}>
|
||||||
<CompanyDetail />
|
<div className="pt-12">
|
||||||
</div>
|
<CompanyDetail />
|
||||||
|
</div>
|
||||||
<Suspense>
|
|
||||||
<Footer cart={cart} />
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
<Footer cart={cart} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import { Suspense } from 'react';
|
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return <Suspense>{children}</Suspense>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
@ -4,13 +4,11 @@ import { SupportedLocale } from 'components/layout/navbar/language-control';
|
|||||||
import Navbar from 'components/layout/navbar';
|
import Navbar from 'components/layout/navbar';
|
||||||
import { getCart, getProduct } from 'lib/shopify';
|
import { getCart, getProduct } from 'lib/shopify';
|
||||||
import { Product } from 'lib/shopify/types';
|
import { Product } from 'lib/shopify/types';
|
||||||
|
import { unstable_setRequestLocale } from 'next-intl/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import ConceptDetail from './concept-detail';
|
import ConceptDetail from './concept-detail';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
|
||||||
export const revalidate = 43200; // 12 hours in seconds
|
|
||||||
|
|
||||||
const { SITE_NAME } = process.env;
|
const { SITE_NAME } = process.env;
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@ -22,6 +20,10 @@ export const metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ params }: { params: { locale?: SupportedLocale } }) {
|
export default async function Page({ params }: { params: { locale?: SupportedLocale } }) {
|
||||||
|
if (!!params?.locale) {
|
||||||
|
unstable_setRequestLocale(params.locale);
|
||||||
|
}
|
||||||
|
|
||||||
const cartId = cookies().get('cartId')?.value;
|
const cartId = cookies().get('cartId')?.value;
|
||||||
let cart;
|
let cart;
|
||||||
|
|
||||||
@ -37,13 +39,13 @@ export default async function Page({ params }: { params: { locale?: SupportedLoc
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Navbar cart={cart} locale={params?.locale} compact promotedItem={promotedItem} />
|
<Navbar cart={cart} locale={params?.locale} compact promotedItem={promotedItem} />
|
||||||
<div className="pt-12">
|
<Suspense fallback={null}>
|
||||||
<ConceptDetail />
|
<div className="pt-12">
|
||||||
</div>
|
<ConceptDetail />
|
||||||
|
</div>
|
||||||
<Suspense>
|
|
||||||
<Footer cart={cart} />
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
<Footer cart={cart} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -4,13 +4,11 @@ import { SupportedLocale } from 'components/layout/navbar/language-control';
|
|||||||
import Navbar from 'components/layout/navbar';
|
import Navbar from 'components/layout/navbar';
|
||||||
import { getCart, getProduct } from 'lib/shopify';
|
import { getCart, getProduct } from 'lib/shopify';
|
||||||
import { Product } from 'lib/shopify/types';
|
import { Product } from 'lib/shopify/types';
|
||||||
|
import { unstable_setRequestLocale } from 'next-intl/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import Disclosures from './disclosures';
|
import Disclosures from './disclosures';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
|
||||||
export const revalidate = 43200; // 12 hours in seconds
|
|
||||||
|
|
||||||
const { SITE_NAME } = process.env;
|
const { SITE_NAME } = process.env;
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@ -26,6 +24,10 @@ export default async function DisclosuresPage({
|
|||||||
}: {
|
}: {
|
||||||
params: { locale?: SupportedLocale };
|
params: { locale?: SupportedLocale };
|
||||||
}) {
|
}) {
|
||||||
|
if (!!locale) {
|
||||||
|
unstable_setRequestLocale(locale);
|
||||||
|
}
|
||||||
|
|
||||||
const cartId = cookies().get('cartId')?.value;
|
const cartId = cookies().get('cartId')?.value;
|
||||||
let cart;
|
let cart;
|
||||||
|
|
||||||
@ -41,13 +43,13 @@ export default async function DisclosuresPage({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Navbar cart={cart} locale={locale} compact promotedItem={promotedItem} />
|
<Navbar cart={cart} locale={locale} compact promotedItem={promotedItem} />
|
||||||
<div className="py-24 md:py-48">
|
<Suspense fallback={null}>
|
||||||
<Disclosures />
|
<div className="py-24 md:py-48">
|
||||||
</div>
|
<Disclosures />
|
||||||
|
</div>
|
||||||
<Suspense>
|
|
||||||
<Footer cart={cart} />
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
<Footer cart={cart} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@ -4,6 +4,7 @@ import { ReactNode, Suspense } from 'react';
|
|||||||
|
|
||||||
import { SupportedLocale } from 'components/layout/navbar/language-control';
|
import { SupportedLocale } from 'components/layout/navbar/language-control';
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
|
import { unstable_setRequestLocale } from 'next-intl/server';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import Analytics from './analytics';
|
import Analytics from './analytics';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
@ -70,8 +71,10 @@ const noto = Noto_Serif_JP({
|
|||||||
variable: '--font-noto'
|
variable: '--font-noto'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const locales = ['en', 'ja'] as const;
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
return [{ locale: 'ja' }, { locale: 'en' }];
|
return locales.map((locale) => ({ locale }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
@ -81,13 +84,16 @@ export default async function RootLayout({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
params: { locale?: SupportedLocale };
|
params: { locale?: SupportedLocale };
|
||||||
}) {
|
}) {
|
||||||
let messages;
|
// Validate that the incoming `locale` parameter is valid
|
||||||
try {
|
const isValidLocale = locales.some((cur: string) => cur === params?.locale);
|
||||||
messages = (await import(`../../messages/${params?.locale}.json`)).default;
|
if (!isValidLocale) notFound();
|
||||||
} catch (error) {
|
|
||||||
notFound();
|
if (params?.locale) {
|
||||||
|
unstable_setRequestLocale(params.locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messages = (await import(`../../messages/${params?.locale}.json`)).default;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang={params.locale}
|
lang={params.locale}
|
||||||
@ -95,10 +101,10 @@ export default async function RootLayout({
|
|||||||
>
|
>
|
||||||
<body className="bg-dark text-white selection:bg-green-800 selection:text-green-400">
|
<body className="bg-dark text-white selection:bg-green-800 selection:text-green-400">
|
||||||
<NextIntlClientProvider locale={params?.locale} messages={messages}>
|
<NextIntlClientProvider locale={params?.locale} messages={messages}>
|
||||||
<Suspense>
|
<Suspense fallback={null}>
|
||||||
<Analytics />
|
<Analytics />
|
||||||
<main>{children}</main>
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
<main>{children}</main>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import OpengraphImage from 'components/opengraph-image';
|
import OpengraphImage from 'components/opengraph-image';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
export const runtime = 'edge';
|
||||||
export const revalidate = 300; // 5 minutes in seconds
|
|
||||||
|
|
||||||
export default async function Image() {
|
export default async function Image() {
|
||||||
return await OpengraphImage();
|
return await OpengraphImage();
|
||||||
|
@ -22,13 +22,11 @@ import StoriesPreview from 'components/layout/stories-preview';
|
|||||||
import { BLOG_HANDLE } from 'lib/constants';
|
import { BLOG_HANDLE } from 'lib/constants';
|
||||||
import { getCart, getProduct } from 'lib/shopify';
|
import { getCart, getProduct } from 'lib/shopify';
|
||||||
import { Product } from 'lib/shopify/types';
|
import { Product } from 'lib/shopify/types';
|
||||||
|
import { unstable_setRequestLocale } from 'next-intl/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
|
||||||
export const revalidate = 300; // 5 minutes in seconds
|
|
||||||
|
|
||||||
const { SITE_NAME } = process.env;
|
const { SITE_NAME } = process.env;
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@ -44,6 +42,10 @@ export default async function HomePage({
|
|||||||
}: {
|
}: {
|
||||||
params: { locale?: SupportedLocale };
|
params: { locale?: SupportedLocale };
|
||||||
}) {
|
}) {
|
||||||
|
if (!!locale) {
|
||||||
|
unstable_setRequestLocale(locale);
|
||||||
|
}
|
||||||
|
|
||||||
const cartId = cookies().get('cartId')?.value;
|
const cartId = cookies().get('cartId')?.value;
|
||||||
let cart;
|
let cart;
|
||||||
|
|
||||||
@ -63,7 +65,9 @@ export default async function HomePage({
|
|||||||
<HomepageProducts lang={locale} />
|
<HomepageProducts lang={locale} />
|
||||||
</div>
|
</div>
|
||||||
<div className="py-24 md:py-48">
|
<div className="py-24 md:py-48">
|
||||||
<NewsletterSignup />
|
<Suspense fallback={null}>
|
||||||
|
<NewsletterSignup />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative mx-auto max-w-screen-xl">
|
<div className="relative mx-auto max-w-screen-xl">
|
||||||
<Image
|
<Image
|
||||||
@ -74,7 +78,9 @@ export default async function HomePage({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="py-24">
|
<div className="py-24">
|
||||||
<Shoplist />
|
<Suspense fallback={null}>
|
||||||
|
<Shoplist />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative pb-48">
|
<div className="relative pb-48">
|
||||||
@ -152,9 +158,7 @@ export default async function HomePage({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Suspense>
|
<Footer cart={cart} promotedItem={promotedItem} />
|
||||||
<Footer cart={cart} promotedItem={promotedItem} />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -4,13 +4,11 @@ import { SupportedLocale } from 'components/layout/navbar/language-control';
|
|||||||
import Navbar from 'components/layout/navbar';
|
import Navbar from 'components/layout/navbar';
|
||||||
import { getCart, getProduct } from 'lib/shopify';
|
import { getCart, getProduct } from 'lib/shopify';
|
||||||
import { Product } from 'lib/shopify/types';
|
import { Product } from 'lib/shopify/types';
|
||||||
|
import { unstable_setRequestLocale } from 'next-intl/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import PrivacyPolicy from './privacy-policy';
|
import PrivacyPolicy from './privacy-policy';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
|
||||||
export const revalidate = 43200; // 12 hours in seconds
|
|
||||||
|
|
||||||
const { SITE_NAME } = process.env;
|
const { SITE_NAME } = process.env;
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@ -26,6 +24,10 @@ export default async function PrivacyPage({
|
|||||||
}: {
|
}: {
|
||||||
params: { locale?: SupportedLocale };
|
params: { locale?: SupportedLocale };
|
||||||
}) {
|
}) {
|
||||||
|
if (!!locale) {
|
||||||
|
unstable_setRequestLocale(locale);
|
||||||
|
}
|
||||||
|
|
||||||
const cartId = cookies().get('cartId')?.value;
|
const cartId = cookies().get('cartId')?.value;
|
||||||
let cart;
|
let cart;
|
||||||
|
|
||||||
@ -41,13 +43,13 @@ export default async function PrivacyPage({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Navbar cart={cart} locale={locale} compact promotedItem={promotedItem} />
|
<Navbar cart={cart} locale={locale} compact promotedItem={promotedItem} />
|
||||||
<div className="py-24 md:py-48">
|
<Suspense fallback={null}>
|
||||||
<PrivacyPolicy />
|
<div className="py-24 md:py-48">
|
||||||
</div>
|
<PrivacyPolicy />
|
||||||
|
</div>
|
||||||
<Suspense>
|
|
||||||
<Footer cart={cart} />
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
<Footer cart={cart} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -4,10 +4,7 @@ import { SupportedLocale } from 'components/layout/navbar/language-control';
|
|||||||
import Navbar from 'components/layout/navbar';
|
import Navbar from 'components/layout/navbar';
|
||||||
import { getCart } from 'lib/shopify';
|
import { getCart } from 'lib/shopify';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { ReactNode, Suspense } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
|
||||||
export const revalidate = 300; // 5 minutes in seconds
|
|
||||||
|
|
||||||
const { SITE_NAME } = process.env;
|
const { SITE_NAME } = process.env;
|
||||||
|
|
||||||
@ -37,9 +34,7 @@ export default async function ProductLayout({
|
|||||||
<div>
|
<div>
|
||||||
<Navbar cart={cart} locale={locale} compact />
|
<Navbar cart={cart} locale={locale} compact />
|
||||||
{children}
|
{children}
|
||||||
<Suspense>
|
<Footer cart={cart} />
|
||||||
<Footer cart={cart} />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
import { Suspense } from 'react';
|
|
||||||
|
|
||||||
import { ChevronDoubleRightIcon } from '@heroicons/react/24/outline';
|
import { ChevronDoubleRightIcon } from '@heroicons/react/24/outline';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
@ -15,11 +13,10 @@ import { VariantSelector } from 'components/product/variant-selector';
|
|||||||
import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
|
import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
|
||||||
import { getProduct, getProductRecommendations } from 'lib/shopify';
|
import { getProduct, getProductRecommendations } from 'lib/shopify';
|
||||||
import { Image as MediaImage, Product } from 'lib/shopify/types';
|
import { Image as MediaImage, Product } from 'lib/shopify/types';
|
||||||
|
import { unstable_setRequestLocale } from 'next-intl/server';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { Suspense } from 'react';
|
||||||
export const runtime = 'edge';
|
|
||||||
export const revalidate = 300; // 5 minutes in seconds
|
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params
|
params
|
||||||
@ -31,7 +28,7 @@ export async function generateMetadata({
|
|||||||
language: params?.locale?.toUpperCase()
|
language: params?.locale?.toUpperCase()
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!product) return notFound();
|
if (!product) return {};
|
||||||
|
|
||||||
const { url, width, height, altText: alt } = product.featuredImage || {};
|
const { url, width, height, altText: alt } = product.featuredImage || {};
|
||||||
const indexable = !product.tags.includes(HIDDEN_PRODUCT_TAG);
|
const indexable = !product.tags.includes(HIDDEN_PRODUCT_TAG);
|
||||||
@ -67,6 +64,10 @@ export default async function ProductPage({
|
|||||||
}: {
|
}: {
|
||||||
params: { handle: string; locale?: SupportedLocale };
|
params: { handle: string; locale?: SupportedLocale };
|
||||||
}) {
|
}) {
|
||||||
|
if (!!params?.locale) {
|
||||||
|
unstable_setRequestLocale(params.locale);
|
||||||
|
}
|
||||||
|
|
||||||
const numberOfOtherImages = 3;
|
const numberOfOtherImages = 3;
|
||||||
const product = await getProduct({
|
const product = await getProduct({
|
||||||
handle: params.handle,
|
handle: params.handle,
|
||||||
@ -80,7 +81,7 @@ export default async function ProductPage({
|
|||||||
.filter((image) => image?.url !== product.featuredImage?.url);
|
.filter((image) => image?.url !== product.featuredImage?.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!product) return notFound();
|
if (!product) return {};
|
||||||
|
|
||||||
const productJsonLd = {
|
const productJsonLd = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
@ -136,13 +137,17 @@ export default async function ProductPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-sm">
|
<div className="max-w-sm">
|
||||||
<VariantSelector options={product.options} variants={product.variants} />
|
<Suspense>
|
||||||
|
<VariantSelector options={product.options} variants={product.variants} />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
<AddManyToCart
|
<Suspense>
|
||||||
quantity={1}
|
<AddManyToCart
|
||||||
variants={product.variants}
|
quantity={1}
|
||||||
availableForSale={product.availableForSale}
|
variants={product.variants}
|
||||||
/>
|
availableForSale={product.availableForSale}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-b border-white/20 pb-6"></div>
|
<div className="border-b border-white/20 pb-6"></div>
|
||||||
@ -165,30 +170,32 @@ export default async function ProductPage({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 px-4 md:grid-cols-2 md:px-0">
|
<div className="grid grid-cols-1 gap-4 px-4 md:grid-cols-2 md:px-0">
|
||||||
{!!otherImages &&
|
<Suspense fallback={null}>
|
||||||
otherImages?.length > 0 &&
|
{!!otherImages &&
|
||||||
otherImages.map((image, index) => {
|
otherImages?.length > 0 &&
|
||||||
const isOdd = otherImages.length % 2 != 0;
|
otherImages.map((image, index) => {
|
||||||
const isLast = index === otherImages.length - 1;
|
const isOdd = otherImages.length % 2 != 0;
|
||||||
const isOddAndLast = isOdd && isLast;
|
const isLast = index === otherImages.length - 1;
|
||||||
return (
|
const isOddAndLast = isOdd && isLast;
|
||||||
<div
|
return (
|
||||||
key={image.url}
|
<div
|
||||||
className={clsx(
|
key={image.url}
|
||||||
isOddAndLast ? 'col-span-1 md:col-span-2' : 'col-span-1 aspect-square',
|
className={clsx(
|
||||||
'relative h-full w-full bg-gray-900/10'
|
isOddAndLast ? 'col-span-1 md:col-span-2' : 'col-span-1 aspect-square',
|
||||||
)}
|
'relative h-full w-full bg-gray-900/10'
|
||||||
>
|
)}
|
||||||
<Image
|
>
|
||||||
src={image.url}
|
<Image
|
||||||
alt={image.altText}
|
src={image.url}
|
||||||
height={image.height}
|
alt={image.altText}
|
||||||
width={image.width}
|
height={image.height}
|
||||||
className="h-full w-full object-cover"
|
width={image.width}
|
||||||
/>
|
className="h-full w-full object-cover"
|
||||||
</div>
|
/>
|
||||||
);
|
</div>
|
||||||
})}
|
);
|
||||||
|
})}
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!!product?.lower?.value && (
|
{!!product?.lower?.value && (
|
||||||
@ -197,9 +204,7 @@ export default async function ProductPage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Suspense>
|
<RelatedProducts id={product.id} />
|
||||||
<RelatedProducts id={product.id} />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
@ -5,12 +5,10 @@ import { ProductGrid } from 'components/grid/product-grid';
|
|||||||
import Navbar from 'components/layout/navbar';
|
import Navbar from 'components/layout/navbar';
|
||||||
import { getCart, getProduct } from 'lib/shopify';
|
import { getCart, getProduct } from 'lib/shopify';
|
||||||
import { Product } from 'lib/shopify/types';
|
import { Product } from 'lib/shopify/types';
|
||||||
|
import { unstable_setRequestLocale } from 'next-intl/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
|
||||||
export const revalidate = 300; // 5 minutes in seconds
|
|
||||||
|
|
||||||
const { SITE_NAME } = process.env;
|
const { SITE_NAME } = process.env;
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@ -26,6 +24,10 @@ export default async function ProductPage({
|
|||||||
}: {
|
}: {
|
||||||
params: { locale?: SupportedLocale };
|
params: { locale?: SupportedLocale };
|
||||||
}) {
|
}) {
|
||||||
|
if (!!locale) {
|
||||||
|
unstable_setRequestLocale(locale);
|
||||||
|
}
|
||||||
|
|
||||||
const cartId = cookies().get('cartId')?.value;
|
const cartId = cookies().get('cartId')?.value;
|
||||||
let cart;
|
let cart;
|
||||||
|
|
||||||
@ -41,13 +43,13 @@ export default async function ProductPage({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Navbar cart={cart} locale={locale} compact promotedItem={promotedItem} />
|
<Navbar cart={cart} locale={locale} compact promotedItem={promotedItem} />
|
||||||
<div className="py-24 md:py-48">
|
<Suspense fallback={null}>
|
||||||
<ProductGrid lang={locale} />
|
<div className="py-24 md:py-48">
|
||||||
</div>
|
<ProductGrid lang={locale} />
|
||||||
|
</div>
|
||||||
<Suspense>
|
|
||||||
<Footer cart={cart} />
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
<Footer cart={cart} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,3 @@
|
|||||||
import { Suspense } from 'react';
|
|
||||||
|
|
||||||
export const revalidate = 300; // 5 minutes in seconds
|
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return <Suspense>{children}</Suspense>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,16 @@
|
|||||||
|
import { SupportedLocale } from 'components/layout/navbar/language-control';
|
||||||
import OpengraphImage from 'components/opengraph-image';
|
import OpengraphImage from 'components/opengraph-image';
|
||||||
import { getPage } from 'lib/shopify';
|
import { getPage } from 'lib/shopify';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
export default async function Image({
|
||||||
export const revalidate = 300; // 5 minutes in seconds
|
params
|
||||||
|
}: {
|
||||||
export default async function Image({ params }: { params: { page: string } }) {
|
params: { page: string; locale?: SupportedLocale };
|
||||||
const page = await getPage({ handle: params.page });
|
}) {
|
||||||
|
const page = await getPage({
|
||||||
|
handle: params.page,
|
||||||
|
language: params?.locale?.toUpperCase() || 'JA'
|
||||||
|
});
|
||||||
const title = page.seo?.title || page.title;
|
const title = page.seo?.title || page.title;
|
||||||
|
|
||||||
return await OpengraphImage({ title });
|
return await OpengraphImage({ title });
|
||||||
|
@ -3,24 +3,27 @@ import type { Metadata } from 'next';
|
|||||||
import Footer from 'components/layout/footer';
|
import Footer from 'components/layout/footer';
|
||||||
import Navbar from 'components/layout/navbar';
|
import Navbar from 'components/layout/navbar';
|
||||||
import { SupportedLocale } from 'components/layout/navbar/language-control';
|
import { SupportedLocale } from 'components/layout/navbar/language-control';
|
||||||
import Prose from 'components/prose';
|
|
||||||
import { getCart, getPage, getProduct } from 'lib/shopify';
|
import { getCart, getPage, getProduct } from 'lib/shopify';
|
||||||
import { Product } from 'lib/shopify/types';
|
import { Product } from 'lib/shopify/types';
|
||||||
|
import { unstable_setRequestLocale } from 'next-intl/server';
|
||||||
|
import { unstable_noStore } from 'next/cache';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
|
import ShopListDetail from './shop-list-detail';
|
||||||
import ShopsNav from './shops-nav';
|
import ShopsNav from './shops-nav';
|
||||||
|
|
||||||
export const revalidate = 300; // 5 minutes in seconds
|
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params
|
params
|
||||||
}: {
|
}: {
|
||||||
params: { locale?: SupportedLocale };
|
params: { locale?: SupportedLocale };
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const page = await getPage({ handle: 'shop-list', language: params?.locale?.toUpperCase() });
|
unstable_noStore(); // opt out from partial prerendering
|
||||||
|
const page = await getPage({
|
||||||
|
handle: 'shop-list',
|
||||||
|
language: params?.locale?.toUpperCase() || 'JA'
|
||||||
|
});
|
||||||
|
|
||||||
if (!page) return notFound();
|
if (!page) return {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: page.seo?.title || page.title,
|
title: page.seo?.title || page.title,
|
||||||
@ -34,6 +37,10 @@ export async function generateMetadata({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function Page({ params }: { params: { locale?: SupportedLocale } }) {
|
export default async function Page({ params }: { params: { locale?: SupportedLocale } }) {
|
||||||
|
if (!!params?.locale) {
|
||||||
|
unstable_setRequestLocale(params.locale);
|
||||||
|
}
|
||||||
|
|
||||||
const cartId = cookies().get('cartId')?.value;
|
const cartId = cookies().get('cartId')?.value;
|
||||||
let cart;
|
let cart;
|
||||||
|
|
||||||
@ -41,13 +48,9 @@ export default async function Page({ params }: { params: { locale?: SupportedLoc
|
|||||||
cart = await getCart(cartId);
|
cart = await getCart(cartId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = await getPage({ handle: 'shop-list', language: params?.locale?.toUpperCase() });
|
|
||||||
|
|
||||||
if (!page) return notFound();
|
|
||||||
|
|
||||||
const promotedItem: Product | undefined = await getProduct({
|
const promotedItem: Product | undefined = await getProduct({
|
||||||
handle: 'gift-bag-and-postcard-set',
|
handle: 'gift-bag-and-postcard-set',
|
||||||
language: params?.locale?.toUpperCase()
|
language: params?.locale?.toUpperCase() || 'JA'
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -57,13 +60,12 @@ export default async function Page({ params }: { params: { locale?: SupportedLoc
|
|||||||
<div className="pb-12">
|
<div className="pb-12">
|
||||||
<ShopsNav />
|
<ShopsNav />
|
||||||
</div>
|
</div>
|
||||||
{/* <h2 className="font-multilingual mb-8 text-3xl font-medium">{page.title}</h2> */}
|
<Suspense fallback={null}>
|
||||||
<Prose html={page.body as string} />
|
<ShopListDetail language={params?.locale?.toUpperCase()} />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Suspense>
|
<Footer cart={cart} />
|
||||||
<Footer cart={cart} />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
13
app/[locale]/shop-list/shop-list-detail.tsx
Normal file
13
app/[locale]/shop-list/shop-list-detail.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import Prose from 'components/prose';
|
||||||
|
import { getPage } from 'lib/shopify';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
export default async function ShopListDetail({ language }: { language?: string }) {
|
||||||
|
const page = await getPage({ handle: 'shop-list', language });
|
||||||
|
|
||||||
|
if (!page) notFound();
|
||||||
|
|
||||||
|
return <Prose html={page.body as string} />;
|
||||||
|
}
|
@ -5,10 +5,7 @@ import Navbar from 'components/layout/navbar';
|
|||||||
import { getCart, getProduct } from 'lib/shopify';
|
import { getCart, getProduct } from 'lib/shopify';
|
||||||
import { Product } from 'lib/shopify/types';
|
import { Product } from 'lib/shopify/types';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { ReactNode, Suspense } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
|
||||||
export const revalidate = 300; // 5 minutes in seconds
|
|
||||||
|
|
||||||
const { SITE_NAME } = process.env;
|
const { SITE_NAME } = process.env;
|
||||||
|
|
||||||
@ -43,9 +40,7 @@ export default async function BlogLayout({
|
|||||||
<div>
|
<div>
|
||||||
<Navbar cart={cart} locale={locale} compact promotedItem={promotedItem} />
|
<Navbar cart={cart} locale={locale} compact promotedItem={promotedItem} />
|
||||||
{children}
|
{children}
|
||||||
<Suspense>
|
<Footer cart={cart} />
|
||||||
<Footer cart={cart} />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -6,11 +6,9 @@ import Prose from 'components/prose';
|
|||||||
import { BLOG_HANDLE, HIDDEN_ARTICLE_TAG } from 'lib/constants';
|
import { BLOG_HANDLE, HIDDEN_ARTICLE_TAG } from 'lib/constants';
|
||||||
import { getBlogArticle } from 'lib/shopify';
|
import { getBlogArticle } from 'lib/shopify';
|
||||||
import { BlogArticle } from 'lib/shopify/types';
|
import { BlogArticle } from 'lib/shopify/types';
|
||||||
|
import { unstable_setRequestLocale } from 'next-intl/server';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
|
||||||
export const revalidate = 300; // 5 minutes in seconds
|
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params
|
params
|
||||||
}: {
|
}: {
|
||||||
@ -22,7 +20,7 @@ export async function generateMetadata({
|
|||||||
language: params?.locale?.toUpperCase()
|
language: params?.locale?.toUpperCase()
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!article) return notFound();
|
if (!article) return {};
|
||||||
|
|
||||||
const { url, width, height, altText: alt } = article.image || {};
|
const { url, width, height, altText: alt } = article.image || {};
|
||||||
const indexable = !article?.tags?.includes(HIDDEN_ARTICLE_TAG);
|
const indexable = !article?.tags?.includes(HIDDEN_ARTICLE_TAG);
|
||||||
@ -58,13 +56,17 @@ export default async function BlogArticlePage({
|
|||||||
}: {
|
}: {
|
||||||
params: { handle: string; locale?: SupportedLocale };
|
params: { handle: string; locale?: SupportedLocale };
|
||||||
}) {
|
}) {
|
||||||
|
if (!!params?.locale) {
|
||||||
|
unstable_setRequestLocale(params.locale);
|
||||||
|
}
|
||||||
|
|
||||||
const article: BlogArticle | undefined = await getBlogArticle({
|
const article: BlogArticle | undefined = await getBlogArticle({
|
||||||
handle: BLOG_HANDLE,
|
handle: BLOG_HANDLE,
|
||||||
articleHandle: params.handle,
|
articleHandle: params.handle,
|
||||||
language: params?.locale?.toUpperCase()
|
language: params?.locale?.toUpperCase()
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!article) return notFound();
|
if (!article) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -9,9 +9,6 @@ import { Product } from 'lib/shopify/types';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
|
||||||
export const revalidate = 300; // 5 minutes in seconds
|
|
||||||
|
|
||||||
const { SITE_NAME } = process.env;
|
const { SITE_NAME } = process.env;
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@ -42,13 +39,13 @@ export default async function StoriesPage({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Navbar cart={cart} locale={locale} compact promotedItem={promotedItem} />
|
<Navbar cart={cart} locale={locale} compact promotedItem={promotedItem} />
|
||||||
<div className="py-24 md:py-48">
|
<Suspense fallback={null}>
|
||||||
<StoriesDetail handle={BLOG_HANDLE} locale={locale} />
|
<div className="py-24 md:py-48">
|
||||||
</div>
|
<StoriesDetail handle={BLOG_HANDLE} locale={locale} />
|
||||||
|
</div>
|
||||||
<Suspense>
|
|
||||||
<Footer cart={cart} />
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
<Footer cart={cart} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -4,13 +4,11 @@ import { SupportedLocale } from 'components/layout/navbar/language-control';
|
|||||||
import Navbar from 'components/layout/navbar';
|
import Navbar from 'components/layout/navbar';
|
||||||
import { getCart, getProduct } from 'lib/shopify';
|
import { getCart, getProduct } from 'lib/shopify';
|
||||||
import { Product } from 'lib/shopify/types';
|
import { Product } from 'lib/shopify/types';
|
||||||
|
import { unstable_setRequestLocale } from 'next-intl/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import TermsOfUse from './terms-of-use';
|
import TermsOfUse from './terms-of-use';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
|
||||||
export const revalidate = 43200; // 12 hours in seconds
|
|
||||||
|
|
||||||
const { SITE_NAME } = process.env;
|
const { SITE_NAME } = process.env;
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@ -26,6 +24,10 @@ export default async function TermsPage({
|
|||||||
}: {
|
}: {
|
||||||
params: { locale?: SupportedLocale };
|
params: { locale?: SupportedLocale };
|
||||||
}) {
|
}) {
|
||||||
|
if (!!locale) {
|
||||||
|
unstable_setRequestLocale(locale);
|
||||||
|
}
|
||||||
|
|
||||||
const cartId = cookies().get('cartId')?.value;
|
const cartId = cookies().get('cartId')?.value;
|
||||||
let cart;
|
let cart;
|
||||||
|
|
||||||
@ -41,13 +43,13 @@ export default async function TermsPage({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Navbar cart={cart} locale={locale} compact promotedItem={promotedItem} />
|
<Navbar cart={cart} locale={locale} compact promotedItem={promotedItem} />
|
||||||
<div className="py-24 md:py-48">
|
<Suspense fallback={null}>
|
||||||
<TermsOfUse />
|
<div className="py-24 md:py-48">
|
||||||
</div>
|
<TermsOfUse />
|
||||||
|
</div>
|
||||||
<Suspense>
|
|
||||||
<Footer cart={cart} />
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
<Footer cart={cart} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
|
import { TAGS } from 'lib/constants';
|
||||||
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
|
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
|
||||||
|
import { revalidateTag } from 'next/cache';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
export const addItem = async (variantId: string | undefined): Promise<String | undefined> => {
|
export async function addItem(selectedVariantId: string | undefined) {
|
||||||
let cartId = cookies().get('cartId')?.value;
|
let cartId = cookies().get('cartId')?.value;
|
||||||
let cart;
|
let cart;
|
||||||
|
|
||||||
@ -17,16 +19,17 @@ export const addItem = async (variantId: string | undefined): Promise<String | u
|
|||||||
cookies().set('cartId', cartId);
|
cookies().set('cartId', cartId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!variantId) {
|
if (!selectedVariantId) {
|
||||||
return 'Missing product variant ID';
|
return 'Missing product variant ID';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await addToCart(cartId, [{ merchandiseId: variantId, quantity: 1 }]);
|
await addToCart(cartId, [{ merchandiseId: selectedVariantId, quantity: 1 }]);
|
||||||
|
revalidateTag(TAGS.cart);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return 'Error adding item to cart';
|
return 'Error adding item to cart';
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
export const addItems = async ({
|
export const addItems = async ({
|
||||||
variantId,
|
variantId,
|
||||||
@ -59,34 +62,44 @@ export const addItems = async ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const removeItem = async (lineId: string): Promise<String | undefined> => {
|
export async function removeItem(prevState: any, lineId: string) {
|
||||||
const cartId = cookies().get('cartId')?.value;
|
const cartId = cookies().get('cartId')?.value;
|
||||||
|
|
||||||
if (!cartId) {
|
if (!cartId) {
|
||||||
return 'Missing cart ID';
|
return 'Missing cart ID';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await removeFromCart(cartId, [lineId]);
|
await removeFromCart(cartId, [lineId]);
|
||||||
|
revalidateTag(TAGS.cart);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return 'Error removing item from cart';
|
return 'Error removing item from cart';
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
export const updateItemQuantity = async ({
|
export async function updateItemQuantity(
|
||||||
lineId,
|
prevState: any,
|
||||||
variantId,
|
payload: {
|
||||||
quantity
|
lineId: string;
|
||||||
}: {
|
variantId: string;
|
||||||
lineId: string;
|
quantity: number;
|
||||||
variantId: string;
|
}
|
||||||
quantity: number;
|
) {
|
||||||
}): Promise<String | undefined> => {
|
|
||||||
const cartId = cookies().get('cartId')?.value;
|
const cartId = cookies().get('cartId')?.value;
|
||||||
|
|
||||||
if (!cartId) {
|
if (!cartId) {
|
||||||
return 'Missing cart ID';
|
return 'Missing cart ID';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { lineId, variantId, quantity } = payload;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (quantity === 0) {
|
||||||
|
await removeFromCart(cartId, [lineId]);
|
||||||
|
revalidateTag(TAGS.cart);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await updateCart(cartId, [
|
await updateCart(cartId, [
|
||||||
{
|
{
|
||||||
id: lineId,
|
id: lineId,
|
||||||
@ -94,7 +107,8 @@ export const updateItemQuantity = async ({
|
|||||||
quantity
|
quantity
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
revalidateTag(TAGS.cart);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return 'Error updating item quantity';
|
return 'Error updating item quantity';
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
@ -1,71 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import { addItem } from 'components/cart/actions';
|
|
||||||
import LoadingDots from 'components/loading-dots';
|
|
||||||
import { ProductVariant } from 'lib/shopify/types';
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
import { useTransition } from 'react';
|
|
||||||
|
|
||||||
export function AddToCart({
|
|
||||||
variants,
|
|
||||||
availableForSale
|
|
||||||
}: {
|
|
||||||
variants: ProductVariant[];
|
|
||||||
availableForSale: boolean;
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const t = useTranslations('Index');
|
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
|
|
||||||
const variant = variants.find((variant: ProductVariant) =>
|
|
||||||
variant.selectedOptions.every(
|
|
||||||
(option) => option.value === searchParams.get(option.name.toLowerCase())
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const selectedVariantId = variant?.id || defaultVariantId;
|
|
||||||
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) {
|
|
||||||
// Trigger the error boundary in the root error.js
|
|
||||||
throw new Error(error.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className={clsx(
|
|
||||||
'relative flex w-full items-center justify-center bg-white p-4 font-serif text-xl tracking-wide text-black hover:opacity-90',
|
|
||||||
{
|
|
||||||
'cursor-not-allowed opacity-60 hover:opacity-60': !availableForSale || !selectedVariantId,
|
|
||||||
'cursor-not-allowed': isPending
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{!isPending ? (
|
|
||||||
<span>{availableForSale ? t('cart.add') : t('cart.out-of-stock')}</span>
|
|
||||||
) : (
|
|
||||||
<LoadingDots className="my-3 bg-black" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,40 +1,31 @@
|
|||||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
'use client';
|
||||||
import LoadingDots from 'components/loading-dots';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
|
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { removeItem } from 'components/cart/actions';
|
import { removeItem } from 'components/cart/actions';
|
||||||
|
import LoadingDots from 'components/loading-dots';
|
||||||
import type { CartItem } from 'lib/shopify/types';
|
import type { CartItem } from 'lib/shopify/types';
|
||||||
import { useTransition } from 'react';
|
import { useFormState, useFormStatus } from 'react-dom';
|
||||||
|
|
||||||
export default function DeleteItemButton({ item }: { item: CartItem }) {
|
function SubmitButton() {
|
||||||
const router = useRouter();
|
const { pending } = useFormStatus();
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
aria-label="Remove cart item"
|
type="submit"
|
||||||
onClick={() => {
|
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
|
||||||
startTransition(async () => {
|
if (pending) e.preventDefault();
|
||||||
const error = await removeItem(item.id);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
// Trigger the error boundary in the root error.js
|
|
||||||
throw new Error(error.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
disabled={isPending}
|
aria-label="Remove cart item"
|
||||||
|
aria-disabled={pending}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'ease flex h-[17px] w-[17px] items-center justify-center rounded-full bg-dark transition-all duration-200',
|
'ease flex h-[17px] w-[17px] items-center justify-center rounded-full bg-dark transition-all duration-200',
|
||||||
{
|
{
|
||||||
'cursor-not-allowed px-0': isPending
|
'cursor-not-allowed px-0': pending
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{pending ? (
|
||||||
<LoadingDots className="bg-white" />
|
<LoadingDots className="bg-white" />
|
||||||
) : (
|
) : (
|
||||||
<XMarkIcon className="mx-[1px] h-4 w-4 text-white transition-opacity duration-150 hover:opacity-60 dark:text-black" />
|
<XMarkIcon className="mx-[1px] h-4 w-4 text-white transition-opacity duration-150 hover:opacity-60 dark:text-black" />
|
||||||
@ -42,3 +33,18 @@ export default function DeleteItemButton({ item }: { item: CartItem }) {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DeleteItemButton({ item }: { item: CartItem }) {
|
||||||
|
const [message, formAction] = useFormState(removeItem, null);
|
||||||
|
const itemId = item.id;
|
||||||
|
const actionWithVariant = formAction.bind(null, itemId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={actionWithVariant}>
|
||||||
|
<SubmitButton />
|
||||||
|
<p aria-live="polite" className="sr-only" role="status">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -1,54 +1,32 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
'use client';
|
||||||
import { useTransition } from 'react';
|
|
||||||
|
|
||||||
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
|
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { removeItem, updateItemQuantity } from 'components/cart/actions';
|
import { updateItemQuantity } from 'components/cart/actions';
|
||||||
import LoadingDots from 'components/loading-dots';
|
import LoadingDots from 'components/loading-dots';
|
||||||
import type { CartItem } from 'lib/shopify/types';
|
import type { CartItem } from 'lib/shopify/types';
|
||||||
|
import { useFormState, useFormStatus } from 'react-dom';
|
||||||
|
|
||||||
export default function EditItemQuantityButton({
|
function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
|
||||||
item,
|
const { pending } = useFormStatus();
|
||||||
type
|
|
||||||
}: {
|
|
||||||
item: CartItem;
|
|
||||||
type: 'plus' | 'minus';
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
|
type="submit"
|
||||||
onClick={() => {
|
onClick={(e: React.FormEvent<HTMLButtonElement>) => {
|
||||||
startTransition(async () => {
|
if (pending) e.preventDefault();
|
||||||
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) {
|
|
||||||
// Trigger the error boundary in the root error.js
|
|
||||||
throw new Error(error.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
disabled={isPending}
|
aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
|
||||||
|
aria-disabled={pending}
|
||||||
className={clsx(
|
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',
|
'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,
|
'cursor-not-allowed': pending,
|
||||||
'ml-auto': type === 'minus'
|
'ml-auto': type === 'minus'
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{pending ? (
|
||||||
<LoadingDots className="bg-black dark:bg-white" />
|
<LoadingDots className="bg-black dark:bg-white" />
|
||||||
) : type === 'plus' ? (
|
) : type === 'plus' ? (
|
||||||
<PlusIcon className="h-4 w-4 dark:text-neutral-500" />
|
<PlusIcon className="h-4 w-4 dark:text-neutral-500" />
|
||||||
@ -58,3 +36,22 @@ export default function EditItemQuantityButton({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function EditItemQuantityButton({ item, type }: { item: CartItem; type: 'plus' | 'minus' }) {
|
||||||
|
const [message, formAction] = useFormState(updateItemQuantity, null);
|
||||||
|
const payload = {
|
||||||
|
lineId: item.id,
|
||||||
|
variantId: item.merchandise.id,
|
||||||
|
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
|
||||||
|
};
|
||||||
|
const actionWithVariant = formAction.bind(null, payload);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={actionWithVariant}>
|
||||||
|
<SubmitButton type={type} />
|
||||||
|
<p aria-live="polite" className="sr-only" role="status">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -13,8 +13,8 @@ import Image from 'next/image';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||||
import CloseCart from './close-cart';
|
import CloseCart from './close-cart';
|
||||||
import DeleteItemButton from './delete-item-button';
|
import { DeleteItemButton } from './delete-item-button';
|
||||||
import EditItemQuantityButton from './edit-item-quantity-button';
|
import { EditItemQuantityButton } from './edit-item-quantity-button';
|
||||||
import OpenCart from './open-cart';
|
import OpenCart from './open-cart';
|
||||||
import { PromotedCartItem } from './promoted-cart-item';
|
import { PromotedCartItem } from './promoted-cart-item';
|
||||||
|
|
||||||
|
@ -8,9 +8,6 @@ import Link from 'next/link';
|
|||||||
import Label from '../label';
|
import Label from '../label';
|
||||||
import { GridTileImage } from './tile';
|
import { GridTileImage } from './tile';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
|
||||||
export const revalidate = 300; // 5 minutes in seconds
|
|
||||||
|
|
||||||
function HomepageProductsItem({ item, priority }: { item: Product; priority?: boolean }) {
|
function HomepageProductsItem({ item, priority }: { item: Product; priority?: boolean }) {
|
||||||
const size = item?.variants?.[0]?.selectedOptions?.find((option) => option.name === 'Size');
|
const size = item?.variants?.[0]?.selectedOptions?.find((option) => option.name === 'Size');
|
||||||
const image = item?.variants?.[0]?.image;
|
const image = item?.variants?.[0]?.image;
|
||||||
|
@ -5,6 +5,7 @@ import InstagramIcon from 'components/icons/instagram';
|
|||||||
import KanjiLogo from 'components/icons/kanji';
|
import KanjiLogo from 'components/icons/kanji';
|
||||||
import { Cart, Product } from 'lib/shopify/types';
|
import { Cart, Product } from 'lib/shopify/types';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { Suspense } from 'react';
|
||||||
import FooterMenu from './footer-menu';
|
import FooterMenu from './footer-menu';
|
||||||
import NewsletterFooter from './newsletter-footer';
|
import NewsletterFooter from './newsletter-footer';
|
||||||
|
|
||||||
@ -95,7 +96,9 @@ export default async function Footer({
|
|||||||
|
|
||||||
<div className="flex flex-col space-y-2 pt-24">
|
<div className="flex flex-col space-y-2 pt-24">
|
||||||
<div className="flex flex-row justify-between space-x-4">
|
<div className="flex flex-row justify-between space-x-4">
|
||||||
<CartModal cart={cart} promotedItem={promotedItem} />
|
<Suspense>
|
||||||
|
<CartModal cart={cart} promotedItem={promotedItem} />
|
||||||
|
</Suspense>
|
||||||
<div className="flex flex-row items-center space-x-6">
|
<div className="flex flex-row items-center space-x-6">
|
||||||
<Link
|
<Link
|
||||||
href="https://www.instagram.com/narai.sake/"
|
href="https://www.instagram.com/narai.sake/"
|
||||||
|
@ -7,7 +7,7 @@ import Logo from 'components/icons/logo';
|
|||||||
import MenuIcon from 'components/icons/menu';
|
import MenuIcon from 'components/icons/menu';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Fragment, useRef, useState } from 'react';
|
import { Fragment, Suspense, useRef, useState } from 'react';
|
||||||
import { LanguageControl, SupportedLocale } from '../navbar/language-control';
|
import { LanguageControl, SupportedLocale } from '../navbar/language-control';
|
||||||
|
|
||||||
export function MenuModal({ scrolled }: { scrolled: boolean }) {
|
export function MenuModal({ scrolled }: { scrolled: boolean }) {
|
||||||
@ -72,55 +72,56 @@ export function MenuModal({ scrolled }: { scrolled: boolean }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid h-[calc(100vh-124px)] grid-cols-1 place-content-center">
|
<Suspense fallback={null}>
|
||||||
<div className="flex flex-row justify-end">
|
<div className="grid h-[calc(100vh-124px)] grid-cols-1 place-content-center">
|
||||||
<nav className="flex flex-col space-y-4 px-4 text-right">
|
<div className="flex flex-row justify-end">
|
||||||
<div>
|
<nav className="flex flex-col space-y-4 px-4 text-right">
|
||||||
<Link
|
<div>
|
||||||
href="/products"
|
<Link
|
||||||
className="font-serif text-4xl font-normal transition-opacity duration-150 hover:opacity-50"
|
href="/products"
|
||||||
>
|
className="font-serif text-4xl font-normal transition-opacity duration-150 hover:opacity-50"
|
||||||
{t('menu.products')}
|
>
|
||||||
</Link>
|
{t('menu.products')}
|
||||||
</div>
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<Link
|
||||||
href="/shop-list"
|
href="/shop-list"
|
||||||
className="font-serif text-4xl font-normal transition-opacity duration-150 hover:opacity-50"
|
className="font-serif text-4xl font-normal transition-opacity duration-150 hover:opacity-50"
|
||||||
>
|
>
|
||||||
{t('menu.shops')}
|
{t('menu.shops')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<Link
|
||||||
href="/about"
|
href="/about"
|
||||||
className="font-serif text-4xl font-normal transition-opacity duration-150 hover:opacity-50"
|
className="font-serif text-4xl font-normal transition-opacity duration-150 hover:opacity-50"
|
||||||
>
|
>
|
||||||
{t('menu.about')}
|
{t('menu.about')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<Link
|
||||||
href="/bar"
|
href="/bar"
|
||||||
className="font-serif text-4xl font-normal transition-opacity duration-150 hover:opacity-50"
|
className="font-serif text-4xl font-normal transition-opacity duration-150 hover:opacity-50"
|
||||||
>
|
>
|
||||||
{t('menu.bar')}
|
{t('menu.bar')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<Link
|
||||||
href="/concept"
|
href="/concept"
|
||||||
className="font-serif text-4xl font-normal transition-opacity duration-150 hover:opacity-50"
|
className="font-serif text-4xl font-normal transition-opacity duration-150 hover:opacity-50"
|
||||||
>
|
>
|
||||||
{t('menu.concept')}
|
{t('menu.concept')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* <div>
|
{/* <div>
|
||||||
<Link
|
<Link
|
||||||
href="/stories"
|
href="/stories"
|
||||||
className="font-serif text-4xl font-normal transition-opacity duration-150 hover:opacity-50"
|
className="font-serif text-4xl font-normal transition-opacity duration-150 hover:opacity-50"
|
||||||
@ -129,26 +130,27 @@ export function MenuModal({ scrolled }: { scrolled: boolean }) {
|
|||||||
</Link>
|
</Link>
|
||||||
</div> */}
|
</div> */}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<Link
|
||||||
href="/company"
|
href="/company"
|
||||||
className="font-serif text-4xl font-normal transition-opacity duration-150 hover:opacity-50"
|
className="font-serif text-4xl font-normal transition-opacity duration-150 hover:opacity-50"
|
||||||
>
|
>
|
||||||
{t('menu.company')}
|
{t('menu.company')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-12">
|
<div className="pt-12">
|
||||||
<Link
|
<Link
|
||||||
href={`mailto:${t('email-address.support')}`}
|
href={`mailto:${t('email-address.support')}`}
|
||||||
className="font-serif text-2xl font-extralight transition-opacity duration-150 hover:opacity-50"
|
className="font-serif text-2xl font-extralight transition-opacity duration-150 hover:opacity-50"
|
||||||
>
|
>
|
||||||
{t('email-address.support')}
|
{t('email-address.support')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</div>
|
</div>
|
||||||
|
@ -7,6 +7,7 @@ import CartModal from 'components/cart/modal';
|
|||||||
import LogoNamemark from 'components/icons/namemark';
|
import LogoNamemark from 'components/icons/namemark';
|
||||||
import { Cart, Product } from 'lib/shopify/types';
|
import { Cart, Product } from 'lib/shopify/types';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { Suspense } from 'react';
|
||||||
import { useInView } from 'react-intersection-observer';
|
import { useInView } from 'react-intersection-observer';
|
||||||
import { MenuModal } from '../menu/modal';
|
import { MenuModal } from '../menu/modal';
|
||||||
import { LanguageControl, SupportedLocale } from './language-control';
|
import { LanguageControl, SupportedLocale } from './language-control';
|
||||||
@ -62,8 +63,12 @@ export default function Navbar({
|
|||||||
</div>
|
</div>
|
||||||
<nav className="flex flex-row items-center space-x-4 px-6">
|
<nav className="flex flex-row items-center space-x-4 px-6">
|
||||||
<div className="flex flex-col-reverse items-center justify-center space-y-2 px-2 md:flex-row md:space-x-6">
|
<div className="flex flex-col-reverse items-center justify-center space-y-2 px-2 md:flex-row md:space-x-6">
|
||||||
<CartModal cart={cart} promotedItem={promotedItem} />
|
<Suspense>
|
||||||
<MenuModal scrolled={!inView} />
|
<CartModal cart={cart} promotedItem={promotedItem} />
|
||||||
|
</Suspense>
|
||||||
|
<Suspense>
|
||||||
|
<MenuModal scrolled={!inView} />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@ -84,13 +89,19 @@ export default function Navbar({
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex flex-row items-center space-x-4 px-2 md:pt-6">
|
<nav className="flex flex-row items-center space-x-4 px-2 md:pt-6">
|
||||||
<div className="hidden md:block">
|
<Suspense fallback={null}>
|
||||||
<LanguageControl lang={locale} />
|
<div className="hidden md:block">
|
||||||
</div>
|
<LanguageControl lang={locale} />
|
||||||
<div className="flex flex-col-reverse items-center justify-center space-y-2 rounded md:flex-row md:space-x-6 md:space-y-0">
|
</div>
|
||||||
<CartModal cart={cart} promotedItem={promotedItem} />
|
<div className="flex flex-col-reverse items-center justify-center space-y-2 rounded md:flex-row md:space-x-6 md:space-y-0">
|
||||||
<MenuModal scrolled={!inView} />
|
<Suspense>
|
||||||
</div>
|
<CartModal cart={cart} promotedItem={promotedItem} />
|
||||||
|
</Suspense>
|
||||||
|
<Suspense>
|
||||||
|
<MenuModal scrolled={!inView} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import Link from 'next-intl/link';
|
import { createSharedPathnamesNavigation } from 'next-intl/navigation';
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
|
|
||||||
export type SupportedLocale = 'en' | 'ja';
|
export type SupportedLocale = 'en' | 'ja';
|
||||||
|
|
||||||
|
const locales = ['en', 'ja'] as const;
|
||||||
|
|
||||||
function removeItem<T>(arr: Array<T>, value: T): Array<T> {
|
function removeItem<T>(arr: Array<T>, value: T): Array<T> {
|
||||||
const index = arr.indexOf(value);
|
const index = arr.indexOf(value);
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
@ -15,6 +16,7 @@ function removeItem<T>(arr: Array<T>, value: T): Array<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const LanguageControl = ({ lang }: { lang?: SupportedLocale }) => {
|
export const LanguageControl = ({ lang }: { lang?: SupportedLocale }) => {
|
||||||
|
const { Link, usePathname } = createSharedPathnamesNavigation({ locales });
|
||||||
const pathName = usePathname();
|
const pathName = usePathname();
|
||||||
|
|
||||||
const basePathName = () => {
|
const basePathName = () => {
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
import { Dialog, Transition } from '@headlessui/react';
|
import { Dialog, Transition } from '@headlessui/react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname, useSearchParams } from 'next/navigation';
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
import { Fragment, useEffect, useState } from 'react';
|
import { Fragment, Suspense, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
|
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
import { Menu } from 'lib/shopify/types';
|
import { Menu } from 'lib/shopify/types';
|
||||||
@ -72,7 +72,9 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="mb-4 w-full">
|
<div className="mb-4 w-full">
|
||||||
<Search />
|
<Suspense fallback={null}>
|
||||||
|
<Search />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
{menu.length ? (
|
{menu.length ? (
|
||||||
<ul className="flex w-full flex-col">
|
<ul className="flex w-full flex-col">
|
||||||
|
@ -1,19 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||||
import { createUrl } from 'lib/utils';
|
import { createUrl } from 'lib/utils';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
export default function Search() {
|
export default function Search() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [searchValue, setSearchValue] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSearchValue(searchParams?.get('q') || '');
|
|
||||||
}, [searchParams, setSearchValue]);
|
|
||||||
|
|
||||||
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -34,12 +27,12 @@ export default function Search() {
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={onSubmit} className="relative w-full max-w-[550px] lg:w-80 xl:w-full">
|
<form onSubmit={onSubmit} className="relative w-full max-w-[550px] lg:w-80 xl:w-full">
|
||||||
<input
|
<input
|
||||||
|
key={searchParams?.get('q')}
|
||||||
type="text"
|
type="text"
|
||||||
name="search"
|
name="search"
|
||||||
placeholder="Search for products..."
|
placeholder="Search for products..."
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
value={searchValue}
|
defaultValue={searchParams?.get('q') || ''}
|
||||||
onChange={(e) => setSearchValue(e.target.value)}
|
|
||||||
className="w-full rounded-lg border bg-white px-4 py-2 text-sm text-black placeholder:text-neutral-500 dark:border-neutral-800 dark:bg-transparent dark:text-white dark:placeholder:text-neutral-400"
|
className="w-full rounded-lg border bg-white px-4 py-2 text-sm text-black placeholder:text-neutral-500 dark:border-neutral-800 dark:bg-transparent dark:text-white dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-0 top-0 mr-3 flex h-full items-center">
|
<div className="absolute right-0 top-0 mr-3 flex h-full items-center">
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { SortFilterItem } from 'lib/constants';
|
import { SortFilterItem } from 'lib/constants';
|
||||||
|
import { Suspense } from 'react';
|
||||||
import FilterItemDropdown from './dropdown';
|
import FilterItemDropdown from './dropdown';
|
||||||
import { FilterItem } from './item';
|
import { FilterItem } from './item';
|
||||||
|
|
||||||
@ -19,12 +20,20 @@ export default function FilterList({ list, title }: { list: ListItem[]; title?:
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav>
|
<nav>
|
||||||
{title ? <h3 className="hidden text-xs text-neutral-500 md:block">{title}</h3> : null}
|
{title ? (
|
||||||
|
<h3 className="hidden text-xs text-neutral-500 dark:text-neutral-400 md:block">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
) : null}
|
||||||
<ul className="hidden md:block">
|
<ul className="hidden md:block">
|
||||||
<FilterItemList list={list} />
|
<Suspense fallback={null}>
|
||||||
|
<FilterItemList list={list} />
|
||||||
|
</Suspense>
|
||||||
</ul>
|
</ul>
|
||||||
<ul className="md:hidden">
|
<ul className="md:hidden">
|
||||||
<FilterItemDropdown list={list} />
|
<Suspense fallback={null}>
|
||||||
|
<FilterItemDropdown list={list} />
|
||||||
|
</Suspense>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</>
|
</>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { ImageResponse } from 'next/server';
|
import { ImageResponse } from 'next/og';
|
||||||
import Logo from './icons/logo';
|
import Logo from './icons/logo';
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
|
@ -3,8 +3,7 @@
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { ProductOption, ProductVariant } from 'lib/shopify/types';
|
import { ProductOption, ProductVariant } from 'lib/shopify/types';
|
||||||
import { createUrl } from 'lib/utils';
|
import { createUrl } from 'lib/utils';
|
||||||
import Link from 'next/link';
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { usePathname, useSearchParams } from 'next/navigation';
|
|
||||||
|
|
||||||
type Combination = {
|
type Combination = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -19,6 +18,7 @@ export function VariantSelector({
|
|||||||
options: ProductOption[];
|
options: ProductOption[];
|
||||||
variants: ProductVariant[];
|
variants: ProductVariant[];
|
||||||
}) {
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const hasNoOptionsOrJustOneOption =
|
const hasNoOptionsOrJustOneOption =
|
||||||
@ -76,17 +76,14 @@ export function VariantSelector({
|
|||||||
// The option is active if it's in the url params.
|
// The option is active if it's in the url params.
|
||||||
const isActive = searchParams.get(optionNameLowerCase) === value;
|
const isActive = searchParams.get(optionNameLowerCase) === value;
|
||||||
|
|
||||||
// You can't disable a link, so we need to render something that isn't clickable.
|
|
||||||
const DynamicTag = isAvailableForSale ? Link : 'p';
|
|
||||||
const dynamicProps = {
|
|
||||||
...(isAvailableForSale && { scroll: false })
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicTag
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
aria-disabled={!isAvailableForSale}
|
aria-disabled={!isAvailableForSale}
|
||||||
href={optionUrl}
|
disabled={!isAvailableForSale}
|
||||||
|
onClick={() => {
|
||||||
|
router.replace(optionUrl, { scroll: false });
|
||||||
|
}}
|
||||||
title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`}
|
title={`${option.name} ${value}${!isAvailableForSale ? ' (Out of Stock)' : ''}`}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex min-w-[48px] items-center justify-center rounded-full border bg-white px-2 py-1 text-sm text-black',
|
'flex min-w-[48px] items-center justify-center rounded-full border bg-white px-2 py-1 text-sm text-black',
|
||||||
@ -98,10 +95,9 @@ export function VariantSelector({
|
|||||||
!isAvailableForSale
|
!isAvailableForSale
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
{...dynamicProps}
|
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</DynamicTag>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</dd>
|
</dd>
|
||||||
|
@ -1,15 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,12 +0,0 @@
|
|||||||
import OpengraphImage from 'components/opengraph-image';
|
|
||||||
import { getPage } from 'lib/shopify';
|
|
||||||
|
|
||||||
export const runtime = 'edge';
|
|
||||||
export const revalidate = 300; // 5 minutes in seconds
|
|
||||||
|
|
||||||
export default async function Image({ params }: { params: { page: string } }) {
|
|
||||||
const page = await getPage({ handle: params.page });
|
|
||||||
const title = page.seo?.title || page.title;
|
|
||||||
|
|
||||||
return await OpengraphImage({ title });
|
|
||||||
}
|
|
@ -1,53 +0,0 @@
|
|||||||
import type { Metadata } from 'next';
|
|
||||||
|
|
||||||
import { SupportedLocale } from 'components/layout/navbar/language-control';
|
|
||||||
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; locale?: SupportedLocale };
|
|
||||||
}): Promise<Metadata> {
|
|
||||||
const page = await getPage({ handle: params.page, language: params?.locale?.toUpperCase() });
|
|
||||||
|
|
||||||
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; locale?: SupportedLocale };
|
|
||||||
}) {
|
|
||||||
const page = await getPage({ handle: params.page, language: params?.locale?.toUpperCase() });
|
|
||||||
|
|
||||||
if (!page) return notFound();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="text-white">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,12 +0,0 @@
|
|||||||
import OpengraphImage from 'components/opengraph-image';
|
|
||||||
import { getCollection } from 'lib/shopify';
|
|
||||||
|
|
||||||
export const runtime = 'edge';
|
|
||||||
export const revalidate = 300; // 5 minutes in seconds
|
|
||||||
|
|
||||||
export default async function Image({ params }: { params: { collection: string } }) {
|
|
||||||
const collection = await getCollection({ handle: params.collection });
|
|
||||||
const title = collection?.seo?.title || collection?.title;
|
|
||||||
|
|
||||||
return await OpengraphImage({ title });
|
|
||||||
}
|
|
@ -1,50 +0,0 @@
|
|||||||
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 const revalidate = 300; // 5 minutes in seconds
|
|
||||||
|
|
||||||
export async function generateMetadata({
|
|
||||||
params
|
|
||||||
}: {
|
|
||||||
params: { collection: string };
|
|
||||||
}): Promise<Metadata> {
|
|
||||||
const collection = await getCollection({ handle: 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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,22 +0,0 @@
|
|||||||
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-xl 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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,15 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,42 +0,0 @@
|
|||||||
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 revalidate = 300; // 5 minutes in seconds
|
|
||||||
|
|
||||||
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 className="mb-4">
|
|
||||||
{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}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,49 +0,0 @@
|
|||||||
import { getCollections, getPages, getProducts } from 'lib/shopify';
|
|
||||||
import { MetadataRoute } from 'next';
|
|
||||||
|
|
||||||
type Route = {
|
|
||||||
url: string;
|
|
||||||
lastModified: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
|
|
||||||
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
|
|
||||||
: 'http://localhost:3000';
|
|
||||||
|
|
||||||
export default async function sitemap(): 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
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
let fetchedRoutes: Route[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
fetchedRoutes = (await Promise.all([collectionsPromise, productsPromise, pagesPromise])).flat();
|
|
||||||
} catch (error) {
|
|
||||||
throw JSON.stringify(error, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...routesMap, ...fetchedRoutes];
|
|
||||||
}
|
|
5
i18n.ts
Normal file
5
i18n.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { getRequestConfig } from 'next-intl/server';
|
||||||
|
|
||||||
|
export default getRequestConfig(async ({ locale }) => ({
|
||||||
|
messages: (await import(`./messages/${locale}.json`)).default
|
||||||
|
}));
|
@ -22,7 +22,8 @@ export const sorting: SortFilterItem[] = [
|
|||||||
|
|
||||||
export const TAGS = {
|
export const TAGS = {
|
||||||
collections: 'collections',
|
collections: 'collections',
|
||||||
products: 'products'
|
products: 'products',
|
||||||
|
cart: 'cart'
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
|
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT, TAGS } from 'lib/constants';
|
import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT, TAGS } from 'lib/constants';
|
||||||
import { isShopifyError } from 'lib/type-guards';
|
import { isShopifyError } from 'lib/type-guards';
|
||||||
|
import { ensureStartsWith } from 'lib/utils';
|
||||||
import { revalidateTag } from 'next/cache';
|
import { revalidateTag } from 'next/cache';
|
||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
@ -55,7 +56,9 @@ import {
|
|||||||
ShopifyUpdateCartOperation
|
ShopifyUpdateCartOperation
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const domain = `https://${process.env.SHOPIFY_STORE_DOMAIN!}`;
|
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||||
|
? ensureStartsWith(process.env.SHOPIFY_STORE_DOMAIN, 'https://')
|
||||||
|
: '';
|
||||||
const endpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`;
|
const endpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`;
|
||||||
const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
|
const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
|
||||||
|
|
||||||
@ -103,6 +106,7 @@ export async function shopifyFetch<T>({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isShopifyError(e)) {
|
if (isShopifyError(e)) {
|
||||||
throw {
|
throw {
|
||||||
|
cause: e.cause?.toString() || 'unknown',
|
||||||
status: e.status || 500,
|
status: e.status || 500,
|
||||||
message: e.message,
|
message: e.message,
|
||||||
query
|
query
|
||||||
@ -273,6 +277,7 @@ export async function getCart(cartId: string): Promise<Cart | undefined> {
|
|||||||
const res = await shopifyFetch<ShopifyCartOperation>({
|
const res = await shopifyFetch<ShopifyCartOperation>({
|
||||||
query: getCartQuery,
|
query: getCartQuery,
|
||||||
variables: { cartId },
|
variables: { cartId },
|
||||||
|
tags: [TAGS.cart],
|
||||||
cache: 'no-store'
|
cache: 'no-store'
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -395,6 +400,7 @@ export async function getPage({
|
|||||||
}): Promise<Page> {
|
}): Promise<Page> {
|
||||||
const res = await shopifyFetch<ShopifyPageOperation>({
|
const res = await shopifyFetch<ShopifyPageOperation>({
|
||||||
query: getPageQuery,
|
query: getPageQuery,
|
||||||
|
cache: 'no-store',
|
||||||
variables: { handle, language, country }
|
variables: { handle, language, country }
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -410,6 +416,7 @@ export async function getPages({
|
|||||||
}): Promise<Page[]> {
|
}): Promise<Page[]> {
|
||||||
const res = await shopifyFetch<ShopifyPagesOperation>({
|
const res = await shopifyFetch<ShopifyPagesOperation>({
|
||||||
query: getPagesQuery,
|
query: getPagesQuery,
|
||||||
|
cache: 'no-store',
|
||||||
variables: { language, country }
|
variables: { language, country }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
export interface ShopifyErrorLike {
|
export interface ShopifyErrorLike {
|
||||||
status: number;
|
status: number;
|
||||||
message: Error;
|
message: Error;
|
||||||
|
cause?: Error;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isObject = (object: unknown): object is Record<string, unknown> => {
|
export const isObject = (object: unknown): object is Record<string, unknown> => {
|
||||||
|
31
lib/utils.ts
31
lib/utils.ts
@ -6,3 +6,34 @@ export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyUR
|
|||||||
|
|
||||||
return `${pathname}${queryString}`;
|
return `${pathname}${queryString}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ensureStartsWith = (stringToCheck: string, startsWith: string) =>
|
||||||
|
stringToCheck.startsWith(startsWith) ? stringToCheck : `${startsWith}${stringToCheck}`;
|
||||||
|
|
||||||
|
export const validateEnvironmentVariables = () => {
|
||||||
|
const requiredEnvironmentVariables = ['SHOPIFY_STORE_DOMAIN', 'SHOPIFY_STOREFRONT_ACCESS_TOKEN'];
|
||||||
|
const missingEnvironmentVariables = [] as string[];
|
||||||
|
|
||||||
|
requiredEnvironmentVariables.forEach((envVar) => {
|
||||||
|
if (!process.env[envVar]) {
|
||||||
|
missingEnvironmentVariables.push(envVar);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (missingEnvironmentVariables.length) {
|
||||||
|
throw new Error(
|
||||||
|
`The following environment variables are missing. Your site will not work without them. Read more: https://vercel.com/docs/integrations/shopify#configure-environment-variables\n\n${missingEnvironmentVariables.join(
|
||||||
|
'\n'
|
||||||
|
)}\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.env.SHOPIFY_STORE_DOMAIN?.includes('[') ||
|
||||||
|
process.env.SHOPIFY_STORE_DOMAIN?.includes(']')
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Your `SHOPIFY_STORE_DOMAIN` environment variable includes brackets (ie. `[` and / or `]`). Your site will not work with them there. Please remove them.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
module.exports = {
|
|
||||||
|
const withNextIntl = require('next-intl/plugin')('./i18n.ts');
|
||||||
|
|
||||||
|
module.exports = withNextIntl({
|
||||||
eslint: {
|
eslint: {
|
||||||
// Disabling on production builds because we're running checks on PRs via GitHub Actions.
|
// Disabling on production builds because we're running checks on PRs via GitHub Actions.
|
||||||
ignoreDuringBuilds: true
|
ignoreDuringBuilds: true
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
serverActions: true
|
ppr: true
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
formats: ['image/avif', 'image/webp'],
|
formats: ['image/avif', 'image/webp'],
|
||||||
@ -16,5 +19,14 @@ module.exports = {
|
|||||||
pathname: '/s/files/**'
|
pathname: '/s/files/**'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
async redirects() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/password',
|
||||||
|
destination: '/',
|
||||||
|
permanent: true
|
||||||
|
}
|
||||||
|
];
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
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