Merge pull request #1 from medusajs/feat/medusa-template

feat: add medusa to template
This commit is contained in:
Victor Gerbrands 2023-05-08 14:04:22 +02:00 committed by GitHub
commit 9f9ffe4ac9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 4354 additions and 980 deletions

197
README.md
View File

@ -1,10 +1,12 @@
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fcommerce&project-name=commerce&repo-name=commerce&demo-title=Next.js%20Commerce&demo-url=https%3A%2F%2Fdemo.vercel.store&demo-image=https%3A%2F%2Fbigcommerce-demo-asset-ksvtgfvnd.vercel.app%2Fbigcommerce.png&env=SHOPIFY_STOREFRONT_ACCESS_TOKEN,SHOPIFY_STORE_DOMAIN,SITE_NAME,TWITTER_CREATOR,TWITTER_SITE)
# Next.js Commerce
_Todo: update deploy url_
# Next.js Commerce x Medusa
> Note: Looking for Next.js Commerce v1? View the [code](https://github.com/vercel/commerce/tree/v1), [demo](https://commerce-v1.vercel.store), and [release notes](https://github.com/vercel/commerce/releases/tag/v1)
A Next.js 13 and App Router-ready ecommerce template, built with Shopify, featuring:
A Next.js 13 and App Router-ready ecommerce template, built with [Medusa](https://github.com/medusajs/medusa), featuring:
- Next.js App Router
- Optimized for SEO using Next.js's Metadata
@ -14,9 +16,14 @@ A Next.js 13 and App Router-ready ecommerce template, built with Shopify, featur
- New fetching and caching paradigms
- Dynamic OG images
- Styling with Tailwind CSS
- Checkout and payments with Shopify
- Automatic light/dark mode based on system settings
## What is Medusa?
Medusa is a set of commerce modules and tools that allow you to build rich, reliable, and performant commerce applications without reinventing core commerce logic. The modules can be customized and used to build advanced ecommerce stores, marketplaces, or any product that needs foundational commerce primitives. All modules are open-source and freely available on npm.
Learn more about [Medusas architecture](https://docs.medusajs.com/development/fundamentals/architecture-overview) and [commerce modules](https://docs.medusajs.com/modules/overview) in the Docs.
## Running locally
You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js Commerce. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/concepts/projects/environment-variables) for this, but a `.env` file is all that is necessary.
@ -34,179 +41,37 @@ pnpm dev
Your app should now be running on [localhost:3000](http://localhost:3000/).
## How to configure your Shopify store for Next.js Commerce
## How to configure your Medusa server for Next.js Commerce
Next.js Commerce requires a [paid Shopify plan](https://www.shopify.com/pricing). It will not work with a Shopify Starter plan.
Next.js Commerce x Medusa requires a running [Medusa](https://github.com/medusajs/medusa) server.
### Add Shopify domain to an environment variable
### Create a Medusa Backend[](https://docs.medusajs.com/development/backend/install#create-a-medusa-backend 'Direct link to Create a Medusa Backend')
Create a `SHOPIFY_STORE_DOMAIN` environment variable and use your Shopify domain as the the value (ie. `SHOPIFY_STORE_SUBDOMAIN.myshopify.com`).
It is recommended to use [Yarn](https://yarnpkg.com/getting-started/install) for the installation process as it's much faster than using NPM.
> Note: Do not include the `https://`.
#### 1. Install Medusa CLI[](https://docs.medusajs.com/development/backend/install#1-install-medusa-cli 'Direct link to 1. Install Medusa CLI')
### Accessing the Shopify Storefront API
- npm
- Yarn
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.
```
yarn global add @medusajs/medusa-cli
```
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.
If you run into any errors while installing the CLI tool, check out the [troubleshooting guide](https://docs.medusajs.com/troubleshooting/cli-installation-errors).
Once installed, you'll need to create a `SHOPIFY_STOREFRONT_ACCESS_TOKEN` environment variable and use the public access token as the value
#### 2. Create a new Medusa project[](https://docs.medusajs.com/development/backend/install#2-create-a-new-medusa-project 'Direct link to 2. Create a new Medusa project')
> 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.
```
medusa new my-medusa-store --seed
```
<details>
<summary>Expand to view detailed walkthrough</summary>
#### 3. Start your Medusa backend[](https://docs.medusajs.com/development/backend/install#3-start-your-medusa-backend 'Direct link to 3. Start your Medusa backend')
1. Navigate to `https://SHOPIFY_STORE_SUBDOMAIN.myshopify.com/admin/settings/apps`.
1. Click the green `Shopify App Store` button.
![Shopify App Store](https://user-images.githubusercontent.com/446260/233220545-cb4c1461-ebc5-424e-a421-bf0d32044027.jpg)
1. Search for `Headless` and click on the `Headless` app.
![Headless](https://user-images.githubusercontent.com/446260/233220547-6d93b5ef-16c7-45db-99e7-13ae7e18eb39.jpg)
1. Click the black `Add app` button.
![Add app](https://user-images.githubusercontent.com/446260/233220550-a34c8bda-75a8-437a-9673-125f3794ff35.jpg)
1. Click the green `Add sales channel` button.
![Add sales channel](https://user-images.githubusercontent.com/446260/233220553-42d94a74-421d-4f8a-99ab-a95936b707a3.jpg)
1. Click the green `Create storefront` button.
![Create storefront](https://user-images.githubusercontent.com/446260/233220556-1eee15c4-a45d-446e-9f73-2e7c9f56b29c.jpg)
1. Copy and paste the public access token and assign it to a `SHOPIFY_STOREFRONT_ACCESS_TOKEN` environment variable.
![Pubic access token](https://user-images.githubusercontent.com/446260/233220558-5db04ff9-b894-40fe-bfba-0e92f26b8e1f.jpg)
1. If you ever need to reference the public access token again, you can navigate to `https://SHOPIFY_STORE_SUBDOMAIN.myshopify.com/admin/headless_storefronts`.
</details>
```
cd my-medusa-store medusa develop
```
### Install a headless theme
#### 4. Add Medusa backend domain to an environment variable
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).
![Download Shoify Headless Theme](https://user-images.githubusercontent.com/446260/233220560-9f3f5ab0-ffb4-4305-b4ee-2c9d33eea90f.jpg)
1. Navigate to `https://SHOPIFY_STORE_SUBDOMAIN.myshopify.com/admin/themes`.
1. Click `Add theme`, then `Upload zip file`.
![Upload zip file](https://user-images.githubusercontent.com/446260/233220561-7a53809e-0d95-45eb-b52f-3a52e3663a9c.jpg)
1. Select the downloaded zip file from above, and click the green `Upload file` button.
![Select and upload file](https://user-images.githubusercontent.com/446260/233220563-135fb9f7-2921-4189-8f17-3b1cc15c0ea6.jpg)
1. Click `Customize`.
![Customize theme](https://user-images.githubusercontent.com/446260/233220565-24b9c954-c18a-46f1-9db5-3d2a00040e48.jpg)
1. Click `Theme settings` (ie. the paintbrush icon), expand the `STOREFRONT` section, enter your headless store domain, click the gray `Publish` button.
![Set headless domain in theme settings](https://user-images.githubusercontent.com/446260/233220566-acaee14d-03f8-400d-a2a2-28e85eb5ecdc.jpg)
1. Confirm the theme change by clicking the green `Save and publish` button.
![Confirm save and publish](https://user-images.githubusercontent.com/446260/233220567-504d5bde-cfb9-426d-a264-f9a12d02af13.jpg)
1. The headless theme should now be your current active theme.
![Headless theme is current and active](https://user-images.githubusercontent.com/446260/233220569-63cab2b4-241b-4bf1-9b5b-451daaeceb91.jpg)
</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://SHOPIFY_STORE_SUBDOMAIN.myshopify.com/admin/settings/checkout`.
1. Click the green `Customize` button.
![Customize](https://user-images.githubusercontent.com/446260/233220530-9beda4b4-5008-440a-b923-9d196b722539.jpg)
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.
![Branding](https://user-images.githubusercontent.com/446260/233220534-e884d9fd-1a39-4f4d-9d09-163dde47c2e8.jpg)
1. Navigate to `https://SHOPIFY_STORE_SUBDOMAIN.myshopify.com/admin/settings/branding`.
1. Customize settings to match your brand.
![Branding](https://user-images.githubusercontent.com/446260/233220536-452b8802-9a1e-40f0-9a12-52b3dace84a5.jpg)
#### Emails
1. Navigate to `https://SHOPIFY_STORE_SUBDOMAIN.myshopify.com/admin/settings/email_settings`.
1. Customize settings to match your brand.
![Branding](https://user-images.githubusercontent.com/446260/233220538-13c83a9e-55f8-41e6-9b34-a39ee0848a8a.jpg)
#### Favicon
1. Navigate to `https://SHOPIFY_STORE_SUBDOMAIN.myshopify.com/admin/themes`.
1. Click the green `Customize` button.
![Customize theme](https://user-images.githubusercontent.com/446260/233220539-4869a6cd-f59f-4de6-8091-95ed81d2302d.jpg)
1. Click `Theme settings` (ie. the paintbrush icon), expand the `FAVICON` section, upload favicon, then click the `Save` button.
![Favicon](https://user-images.githubusercontent.com/446260/233220542-ac81b674-d86e-4172-ab38-c79d1ad1ff36.jpg)
</details>
### Configure webhooks for on-demand incremental static regeneration (ISR)
Coming soon.
### Using Shopify as a CMS
Next.js Commerce is fully powered by Shopify in a truly headless and data driven way.
#### Products
`https://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://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.
![Shopify collections](https://user-images.githubusercontent.com/446260/233220543-81896a2b-7085-4abc-a4f1-ce321e08b953.jpg)
![Shopify collection detail](https://user-images.githubusercontent.com/446260/233220544-ecd4c069-49fc-4a0b-8378-aa5e1b4b5257.jpg)
#### Pages
`https://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.
![Shopify pages](https://user-images.githubusercontent.com/446260/233221142-4dc3fa56-5256-4d84-b0a3-331ffb7d79b2.jpg)
![Shopify page detail](https://user-images.githubusercontent.com/446260/233247700-cbeaf917-fb67-49e9-b9b9-5ee8cb188639.jpg)
#### Navigation menus
`https://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.
![Shopify navigation menus](https://user-images.githubusercontent.com/446260/233220571-33f9d5a8-1206-4ab4-ad79-83b4ca954331.jpg)
![Shopify navigation menu detail](https://user-images.githubusercontent.com/446260/233220573-5f03a51f-4100-461f-a696-f085856e391b.jpg)
#### 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.
![Shopify SEO](https://user-images.githubusercontent.com/446260/233247701-0ff2a560-7949-4e6c-b3a8-8168ed6341f8.jpg)
Create a `MEDUSA_BACKEND_API` environment variable in you Next.js Commerce project and use your Medusa backend domain as the the value (default: `http://localhost:9000`).

View File

@ -12,7 +12,6 @@ export async function generateMetadata({
}: {
params: { page: string };
}): Promise<Metadata> {
console.log(params);
const page: any = null;
if (!page) return notFound();
@ -36,7 +35,6 @@ export async function generateMetadata({
}
export default async function Page({ params }: { params: { page: string } }) {
console.log(params);
const page: any = null;
if (!page) return notFound();

View File

@ -1,8 +1,8 @@
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import { addToCart, removeFromCart, updateCart } from 'lib/shopify';
import { isShopifyError } from 'lib/type-guards';
import { addToCart, removeFromCart, updateCart } from 'lib/medusa';
import { isMedusaError } from 'lib/type-guards';
function formatErrorMessage(err: Error): string {
return JSON.stringify(err, Object.getOwnPropertyNames(err));
@ -10,16 +10,16 @@ function formatErrorMessage(err: Error): string {
export async function POST(req: NextRequest): Promise<Response> {
const cartId = cookies().get('cartId')?.value;
const { merchandiseId } = await req.json();
const { variantId } = await req.json();
if (!cartId?.length || !merchandiseId?.length) {
if (!cartId?.length || !variantId?.length) {
return NextResponse.json({ error: 'Missing cartId or variantId' }, { status: 400 });
}
try {
await addToCart(cartId, [{ merchandiseId, quantity: 1 }]);
await addToCart(cartId, { variantId, quantity: 1 });
return NextResponse.json({ status: 204 });
} catch (e) {
if (isShopifyError(e)) {
if (isMedusaError(e)) {
return NextResponse.json({ message: formatErrorMessage(e.message) }, { status: e.status });
}
@ -29,25 +29,22 @@ export async function POST(req: NextRequest): Promise<Response> {
export async function PUT(req: NextRequest): Promise<Response> {
const cartId = cookies().get('cartId')?.value;
const { variantId, quantity, lineId } = await req.json();
const { lineItemId, quantity } = await req.json();
if (!cartId || !variantId || !quantity || !lineId) {
if (!cartId || !quantity || !lineItemId) {
return NextResponse.json(
{ error: 'Missing cartId, variantId, lineId, or quantity' },
{ error: 'Missing cartId, variantId, lineItemId, or quantity' },
{ status: 400 }
);
}
try {
await updateCart(cartId, [
{
id: lineId,
merchandiseId: variantId,
quantity
}
]);
await updateCart(cartId, {
lineItemId,
quantity
});
return NextResponse.json({ status: 204 });
} catch (e) {
if (isShopifyError(e)) {
if (isMedusaError(e)) {
return NextResponse.json({ message: formatErrorMessage(e.message) }, { status: e.status });
}
@ -57,16 +54,16 @@ export async function PUT(req: NextRequest): Promise<Response> {
export async function DELETE(req: NextRequest): Promise<Response> {
const cartId = cookies().get('cartId')?.value;
const { lineId } = await req.json();
const lineItemId = req.nextUrl.searchParams.get('lineItemId');
if (!cartId || !lineId) {
return NextResponse.json({ error: 'Missing cartId or lineId' }, { status: 400 });
if (!cartId || !lineItemId) {
return NextResponse.json({ error: 'Missing cartId or lineItemId' }, { status: 400 });
}
try {
await removeFromCart(cartId, [lineId]);
await removeFromCart(cartId, lineItemId);
return NextResponse.json({ status: 204 });
} catch (e) {
if (isShopifyError(e)) {
if (isMedusaError(e)) {
return NextResponse.json({ message: formatErrorMessage(e.message) }, { status: e.status });
}

View File

@ -10,8 +10,8 @@ import { Gallery } from 'components/product/gallery';
import { VariantSelector } from 'components/product/variant-selector';
import Prose from 'components/prose';
import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
import { getProduct } from 'lib/shopify';
import { Image } from 'lib/shopify/types';
import { getProduct } from 'lib/medusa';
import { Image } from 'lib/medusa/types';
export const runtime = 'edge';
@ -28,8 +28,8 @@ export async function generateMetadata({
const hide = !product.tags.includes(HIDDEN_PRODUCT_TAG);
return {
title: product.seo.title || product.title,
description: product.seo.description || product.description,
title: product.title,
description: product.description,
robots: {
index: hide,
follow: hide,
@ -61,17 +61,19 @@ export default async function ProductPage({ params }: { params: { handle: string
return (
<div>
<div className="lg:grid lg:grid-cols-6">
<div className="lg:col-span-4">
<Gallery
title={product.title}
amount={product.priceRange.maxVariantPrice.amount}
currencyCode={product.priceRange.maxVariantPrice.currencyCode}
images={product.images.map((image: Image) => ({
src: image.url,
altText: image.altText
}))}
/>
</div>
{product.images && (
<div className="lg:col-span-4">
<Gallery
title={product.title}
amount={product.priceRange.maxVariantPrice.amount}
currencyCode={product.priceRange.maxVariantPrice.currencyCode}
images={product.images.map((image: Image) => ({
src: image.url,
altText: image.altText ?? ''
}))}
/>
</div>
)}
<div className="p-6 lg:col-span-2">
{/* @ts-expect-error Server Component */}
@ -97,7 +99,6 @@ export default async function ProductPage({ params }: { params: { handle: string
}
async function RelatedProducts({ id }: { id: string }) {
console.log(id);
// const relatedProducts = await getProductRecommendations(id);
const relatedProducts: any[] = [];

View File

@ -1,4 +1,4 @@
import { getCollection, getCollectionProducts } from 'lib/shopify';
import { getCollection, getCollectionProducts } from 'lib/medusa';
import { Metadata } from 'next';
import { notFound } from 'next/navigation';

View File

@ -1,7 +1,7 @@
import Grid from 'components/grid';
import ProductGridItems from 'components/layout/product-grid-items';
import { defaultSort, sorting } from 'lib/constants';
import { getProducts } from 'lib/shopify';
import { getProducts } from 'lib/medusa';
export const runtime = 'edge';

View File

@ -1,4 +1,4 @@
import { getCollections, getProducts } from 'lib/shopify';
import { getCollections, getProducts } from 'lib/medusa';
import { MetadataRoute } from 'next';
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL

View File

@ -1,4 +1,4 @@
import { getCollectionProducts } from 'lib/shopify';
import { getCollectionProducts } from 'lib/medusa';
import Image from 'next/image';
import Link from 'next/link';

View File

@ -6,7 +6,7 @@ import { useCookies } from 'react-cookie';
import CartIcon from 'components/icons/cart';
import CartModal from './modal';
import type { Cart } from 'lib/shopify/types';
import type { Cart } from 'lib/medusa/types';
export default function CartButton({
cart,

View File

@ -4,7 +4,7 @@ import { useRouter } from 'next/navigation';
import { startTransition, useState } from 'react';
import clsx from 'clsx';
import type { CartItem } from 'lib/shopify/types';
import type { CartItem } from 'lib/medusa/types';
export default function DeleteItemButton({ item }: { item: CartItem }) {
const router = useRouter();
@ -13,12 +13,10 @@ export default function DeleteItemButton({ item }: { item: CartItem }) {
async function handleRemove() {
setRemoving(true);
const response = await fetch(`/api/cart`, {
method: 'DELETE',
body: JSON.stringify({
lineId: item.id
})
const response = await fetch(`/api/cart?lineItemId=${item.id}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.error) {

View File

@ -4,7 +4,7 @@ import { startTransition, useState } from 'react';
import clsx from 'clsx';
import MinusIcon from 'components/icons/minus';
import PlusIcon from 'components/icons/plus';
import type { CartItem } from 'lib/shopify/types';
import type { CartItem } from 'lib/medusa/types';
import LoadingDots from '../loading-dots';
export default function EditItemQuantityButton({
@ -20,13 +20,18 @@ export default function EditItemQuantityButton({
async function handleEdit() {
setEditing(true);
const response = await fetch(`/api/cart`, {
method: type === 'minus' && item.quantity - 1 === 0 ? 'DELETE' : 'PUT',
body: JSON.stringify({
lineId: item.id,
variantId: item.merchandise.id,
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
})
const method = type === 'minus' && item.quantity - 1 === 0 ? 'DELETE' : 'PUT';
const url = method === 'PUT' ? '/api/cart' : `/api/cart?lineItemId=${item.id}`;
const response = await fetch(url, {
method,
body:
method === 'PUT'
? JSON.stringify({
lineItemId: item.id,
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
})
: null
});
const data = await response.json();

View File

@ -1,4 +1,4 @@
import { createCart, getCart } from 'lib/shopify';
import { createCart, getCart } from 'lib/medusa';
import { cookies } from 'next/headers';
import CartButton from './button';

View File

@ -7,7 +7,7 @@ import CloseIcon from 'components/icons/close';
import ShoppingBagIcon from 'components/icons/shopping-bag';
import Price from 'components/price';
import { DEFAULT_OPTION } from 'lib/constants';
import type { Cart } from 'lib/shopify/types';
import type { Cart } from 'lib/medusa/types';
import { createUrl } from 'lib/utils';
import DeleteItemButton from './delete-item-button';
import EditItemQuantityButton from './edit-item-quantity-button';
@ -70,13 +70,13 @@ export default function CartModal({
</button>
</div>
{cart.lines.length === 0 ? (
{cart.lines?.length === 0 ? (
<div className="mt-20 flex w-full flex-col items-center justify-center overflow-hidden">
<ShoppingBagIcon className="h-16" />
<p className="mt-6 text-center text-2xl font-bold">Your cart is empty.</p>
</div>
) : null}
{cart.lines.length !== 0 ? (
{cart.lines?.length !== 0 ? (
<div className="flex h-full flex-col justify-between overflow-hidden">
<ul className="flex-grow overflow-auto p-6">
{cart.lines.map((item, i) => {
@ -107,7 +107,8 @@ export default function CartModal({
height={64}
alt={
item.merchandise.product.featuredImage.altText ||
item.merchandise.product.title
item.merchandise.product.title ||
''
}
src={item.merchandise.product.featuredImage.url}
/>

View File

@ -1,6 +1,6 @@
import { GridTileImage } from 'components/grid/tile';
import { getCollectionProducts } from 'lib/shopify';
import type { Product } from 'lib/shopify/types';
import { getCollectionProducts } from 'lib/medusa';
import type { Product } from 'lib/medusa/types';
import Link from 'next/link';
function ThreeItemGridItem({
@ -41,13 +41,13 @@ export async function ThreeItemGrid() {
if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null;
// const [firstProduct, secondProduct, thirdProduct] = homepageItems;
const [firstProduct, secondProduct, thirdProduct] = homepageItems;
return (
<section className="lg:grid lg:grid-cols-6 lg:grid-rows-2" data-testid="homepage-products">
{/* <ThreeItemGridItem size="full" item={firstProduct} background="purple" />
<ThreeItemGridItem size="full" item={firstProduct} background="purple" />
<ThreeItemGridItem size="half" item={secondProduct} background="black" />
<ThreeItemGridItem size="half" item={thirdProduct} background="pink" /> */}
<ThreeItemGridItem size="half" item={thirdProduct} background="pink" />
</section>
);
}

View File

@ -3,7 +3,7 @@ import Link from 'next/link';
import GitHubIcon from 'components/icons/github';
import LogoIcon from 'components/icons/logo';
import VercelIcon from 'components/icons/vercel';
import { Menu } from 'lib/shopify/types';
import { Menu } from 'lib/medusa/types';
const { SITE_NAME } = process.env;

View File

@ -1,10 +1,10 @@
import Link from 'next/link';
import { Suspense } from 'react';
import Cart from 'components/cart';
import CartIcon from 'components/icons/cart';
import LogoIcon from 'components/icons/logo';
import { Menu } from 'lib/shopify/types';
import { Menu } from 'lib/medusa/types';
import { Suspense } from 'react';
import MobileMenu from './mobile-menu';
import Search from './search';

View File

@ -8,7 +8,7 @@ import { useEffect, useState } from 'react';
import CloseIcon from 'components/icons/close';
import MenuIcon from 'components/icons/menu';
import { Menu } from 'lib/shopify/types';
import { Menu } from 'lib/medusa/types';
import Search from './search';
export default function MobileMenu({ menu }: { menu: Menu[] }) {

View File

@ -1,6 +1,6 @@
import Grid from 'components/grid';
import { GridTileImage } from 'components/grid/tile';
import { Product } from 'lib/shopify/types';
import { Product } from 'lib/medusa/types';
import Link from 'next/link';
export default function ProductGridItems({ products }: { products: Product[] }) {

View File

@ -1,7 +1,7 @@
import clsx from 'clsx';
import { Suspense } from 'react';
import { getCollections } from 'lib/shopify';
import { getCollections } from 'lib/medusa';
import FilterList from './filter';
async function CollectionList() {

View File

@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState, useTransition } from 'react';
import LoadingDots from 'components/loading-dots';
import { ProductVariant } from 'lib/shopify/types';
import { ProductVariant } from 'lib/medusa/types';
export function AddToCart({
variants,
@ -42,7 +42,7 @@ export function AddToCart({
const response = await fetch(`/api/cart`, {
method: 'POST',
body: JSON.stringify({
merchandiseId: selectedVariantId
variantId: selectedVariantId
})
});

View File

@ -1,10 +1,11 @@
'use client';
import clsx from 'clsx';
import { ProductOption, ProductVariant } from 'lib/shopify/types';
import { ProductOption, ProductVariant } from 'lib/medusa/types';
import { createUrl } from 'lib/utils';
import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useEffect } from 'react';
type ParamsMap = {
[key: string]: string; // ie. { color: 'Red', size: 'Large', ... }
@ -79,9 +80,11 @@ export function VariantSelector({
const currentUrl = createUrl(pathname, currentParams);
const selectedVariantUrl = createUrl(pathname, selectedVariantParams);
if (currentUrl !== selectedVariantUrl) {
router.replace(selectedVariantUrl);
}
useEffect(() => {
if (currentUrl !== selectedVariantUrl) {
router.replace(selectedVariantUrl);
}
}, [currentUrl, router, selectedVariantUrl]);
return options.map((option) => (
<dl className="mb-8" key={option.id}>
@ -90,6 +93,7 @@ export function VariantSelector({
{option.values.map((value) => {
// Base option params on selected variant params.
const optionParams = new URLSearchParams(selectedVariantParams);
// Update the params using the current option to reflect how the url would change.
optionParams.set(option.name.toLowerCase(), value);

46
lib/medusa/helpers.ts Normal file
View File

@ -0,0 +1,46 @@
import { isEmpty } from '../utils';
import { MedusaProductVariant, Money, RegionInfo } from './types';
type ComputeAmountParams = {
amount: number;
region: RegionInfo;
includeTaxes?: boolean;
};
/**
* Takes an amount, a region, and returns the amount as a decimal including or excluding taxes
*/
export const computeAmount = ({ amount, region, includeTaxes = true }: ComputeAmountParams) => {
const toDecimal = convertToDecimal(amount, region.currency_code);
const taxRate = includeTaxes ? getTaxRate(region) : 0;
const amountWithTaxes = toDecimal * (1 + taxRate);
return amountWithTaxes;
};
/**
* Takes a product variant, and returns the amount as a decimal including or excluding taxes and the currency code
*/
export const calculateVariantAmount = (variant: MedusaProductVariant): Money => {
const currencyCode = variant.prices?.[0]?.currency_code ?? 'USD';
const amount = convertToDecimal(variant.prices?.[0]?.amount || 0, currencyCode).toString();
return {
amount,
currencyCode
};
};
// we should probably add a more extensive list
const noDivisionCurrencies = ['krw', 'jpy', 'vnd'];
export const convertToDecimal = (amount: number, currencyCode = 'USD') => {
const divisor = noDivisionCurrencies.includes(currencyCode.toLowerCase()) ? 1 : 100;
return Math.floor(amount) / divisor;
};
const getTaxRate = (region?: RegionInfo) => {
return region && !isEmpty(region) ? region?.tax_rate / 100 : 0;
};

357
lib/medusa/index.ts Normal file
View File

@ -0,0 +1,357 @@
import { isMedusaError } from 'lib/type-guards';
import { mapOptionIds } from 'lib/utils';
import { calculateVariantAmount, computeAmount, convertToDecimal } from './helpers';
import {
Cart,
CartItem,
MedusaCart,
MedusaLineItem,
MedusaProduct,
MedusaProductCollection,
MedusaProductOption,
MedusaProductVariant,
Product,
ProductCollection,
ProductOption,
ProductVariant,
SelectedOption
} from './types';
// const endpoint = `${process.env.MEDUSA_BACKEND_API!}`;
const endpoint = `http://localhost:9000/store`;
export default async function medusaRequest(
method: string,
path = '',
payload?: Record<string, unknown> | undefined
) {
const options: RequestInit = {
method,
headers: {
'Content-Type': 'application/json'
}
};
if (payload) {
options.body = JSON.stringify(payload);
}
try {
const result = await fetch(`${endpoint}/${path}`, options);
const body = await result.json();
if (body.errors) {
throw body.errors[0];
}
return {
status: result.status,
body
};
} catch (e) {
if (isMedusaError(e)) {
throw {
status: e.status || 500,
message: e.message
};
}
throw {
error: e
};
}
}
const reshapeCart = (cart: MedusaCart): Cart => {
const lines = cart?.items?.map((item) => reshapeLineItem(item)) || [];
const totalQuantity = lines.length;
const checkoutUrl = '/';
const currencyCode = cart.region?.currency_code || 'USD';
let subtotalAmount = '0';
if (cart.subtotal && cart.region) {
subtotalAmount = computeAmount({ amount: cart.subtotal, region: cart.region }).toString();
}
let totalAmount = '0';
if (cart.total && cart.region) {
totalAmount = computeAmount({ amount: cart.total, region: cart.region }).toString();
}
let totalTaxAmount = '0';
if (cart.tax_total && cart.region) {
totalTaxAmount = computeAmount({ amount: cart.tax_total, region: cart.region }).toString();
}
const cost = {
subtotalAmount: {
amount: subtotalAmount,
currencyCode: currencyCode
},
totalAmount: {
amount: totalAmount,
currencyCode: currencyCode
},
totalTaxAmount: {
amount: totalTaxAmount,
currencyCode: currencyCode
}
};
return {
...cart,
totalQuantity,
checkoutUrl,
lines,
cost
};
};
const reshapeLineItem = (lineItem: MedusaLineItem): CartItem => {
const product = {
title: lineItem.title,
priceRange: {
maxVariantPrice: calculateVariantAmount(lineItem.variant)
},
updatedAt: lineItem.updated_at,
tags: [],
descriptionHtml: lineItem.description ?? '',
featuredImage: {
url: lineItem.thumbnail ?? '',
altText: lineItem.title ?? ''
},
availableForSale: true,
variants: [lineItem.variant && reshapeProductVariant(lineItem.variant)],
handle: lineItem.variant?.product?.handle ?? ''
};
const selectedOptions =
lineItem.variant?.options?.map((option) => ({
name: option.option?.title ?? '',
value: option.value
})) || [];
const merchandise = {
id: lineItem.variant_id || lineItem.id,
selectedOptions,
product,
title: lineItem.title
};
const cost = {
totalAmount: {
amount: convertToDecimal(
lineItem.total,
lineItem.variant.prices?.[0]?.currency_code
).toString(),
currencyCode: 'EUR'
}
};
const quantity = lineItem.quantity;
return {
...lineItem,
merchandise,
cost,
quantity
};
};
const reshapeProduct = (product: MedusaProduct): Product => {
const variant = product.variants?.[0];
let amount = '0';
let currencyCode = 'USD';
if (variant && variant.prices?.[0]?.amount) {
currencyCode = variant.prices?.[0]?.currency_code ?? 'USD';
amount = convertToDecimal(variant.prices[0].amount, currencyCode).toString();
}
const priceRange = {
maxVariantPrice: {
amount,
currencyCode: product.variants?.[0]?.prices?.[0]?.currency_code ?? ''
}
};
const updatedAt = product.updated_at;
const tags = product.tags?.map((tag) => tag.value) || [];
const descriptionHtml = product.description ?? '';
const featuredImage = {
url: product.images?.[0]?.url ?? '',
altText: product.images?.[0]?.id ?? ''
};
const availableForSale = product.variants?.[0]?.purchasable || true;
const variants = product.variants.map((variant) =>
reshapeProductVariant(variant, product.options)
);
let options;
product.options && (options = product.options.map((option) => reshapeProductOption(option)));
return {
...product,
featuredImage,
priceRange,
updatedAt,
tags,
descriptionHtml,
availableForSale,
options,
variants
};
};
const reshapeProductOption = (productOption: MedusaProductOption): ProductOption => {
const availableForSale = productOption.product?.variants?.[0]?.purchasable || true;
const name = productOption.title;
let values = productOption.values?.map((option) => option.value) || [];
values = [...new Set(values)];
return {
...productOption,
availableForSale,
name,
values
};
};
const reshapeProductVariant = (
productVariant: MedusaProductVariant,
productOptions?: MedusaProductOption[]
): ProductVariant => {
let selectedOptions: SelectedOption[] = [];
if (productOptions && productVariant.options) {
const optionIdMap = mapOptionIds(productOptions);
selectedOptions = productVariant.options.map((option) => ({
name: optionIdMap[option.option_id] ?? '',
value: option.value
}));
}
const availableForSale = productVariant.purchasable || true;
const price = calculateVariantAmount(productVariant);
return {
...productVariant,
availableForSale,
selectedOptions,
price
};
};
const reshapeCollection = (collection: MedusaProductCollection): ProductCollection => {
const description = collection.metadata?.description?.toString() ?? '';
const seo = {
title: collection?.metadata?.seo_title?.toString() ?? '',
description: collection?.metadata?.seo_description?.toString() ?? ''
};
const path = `/${collection.handle}`;
const updatedAt = collection.updated_at;
return {
...collection,
description,
seo,
path,
updatedAt
};
};
export async function createCart(): Promise<Cart> {
const res = await medusaRequest('POST', '/carts', {});
return reshapeCart(res.body.cart);
}
export async function addToCart(
cartId: string,
lineItem: { variantId: string; quantity: number }
): Promise<Cart> {
const res = await medusaRequest('POST', `/carts/${cartId}/line-items`, {
variant_id: lineItem?.variantId,
quantity: lineItem?.quantity
});
return reshapeCart(res.body.cart);
}
export async function removeFromCart(cartId: string, lineItemId: string): Promise<Cart> {
const res = await medusaRequest('DELETE', `/carts/${cartId}/line-items/${lineItemId}`);
return reshapeCart(res.body.cart);
}
export async function updateCart(
cartId: string,
{ lineItemId, quantity }: { lineItemId: string; quantity: number }
): Promise<Cart> {
const res = await medusaRequest('POST', `/carts/${cartId}/line-items/${lineItemId}`, {
quantity
});
return reshapeCart(res.body.cart);
}
export async function getCart(cartId: string): Promise<Cart | null> {
const res = await medusaRequest('GET', `/carts/${cartId}`);
const cart = res.body.cart;
if (!cart) {
return null;
}
return reshapeCart(cart);
}
export async function getCollection(handle: string): Promise<ProductCollection | undefined> {
const res = await medusaRequest('GET', `/collections?handle[]=${handle}&limit=1`);
return res.body.collections[0];
}
export async function getCollectionProducts(handle: string): Promise<Product[]> {
const collection = await getCollection(handle);
if (!collection) {
return [];
}
const res = await medusaRequest('GET', `/products?collection_id[]=${collection.id}`);
if (!res.body?.products) {
return [];
}
const products: Product[] = res.body.products.map((product: MedusaProduct) =>
reshapeProduct(product)
);
return products;
}
export async function getCollections(): Promise<ProductCollection[]> {
const res = await medusaRequest('GET', '/collections');
// Reshape collections and hide collections starting with 'hidden'
const collections = res.body.collections
.map((collection: MedusaProductCollection) => reshapeCollection(collection))
.filter((collection: MedusaProductCollection) => !collection.handle.startsWith('hidden'));
return collections;
}
export async function getProduct(handle: string): Promise<Product> {
const res = await medusaRequest('GET', `/products?handle=${handle}&limit=1`);
const product = res.body.products[0];
return reshapeProduct(product);
}
export async function getProducts({
query
}: {
query?: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
const res = await medusaRequest('get', `/products?q=${query}&limit=20`);
const products: Product[] = res.body.products.map((product: MedusaProduct) =>
reshapeProduct(product)
);
return products;
}

428
lib/medusa/types.ts Normal file
View File

@ -0,0 +1,428 @@
export type MedusaProductCollection = {
id: string;
title: string;
handle: string;
products?: Product[];
created_at: string; // format: date-time
updated_at: string; // format: date-time
deleted_at?: string | null; // format: date-time
metadata?: Record<string, unknown> | null;
};
export type ProductCollection = MedusaProductCollection & {
description?: string;
seo?: {
title?: string;
description?: string;
};
path: string;
updatedAt: string;
};
export type MedusaProduct = {
id: string;
title: string;
subtitle?: string | null;
description?: string | null;
handle?: string | null;
is_giftcard: boolean;
status?: 'draft' | 'proposed' | 'published' | 'rejected';
images?: Array<MedusaImage>;
thumbnail?: string | null;
options?: Array<MedusaProductOption>;
variants: Array<MedusaProductVariant>;
categories?: Array<ProductCategory>;
profile_id?: string | null;
profile?: ShippingProfile | null;
weight?: number | null;
length?: number | null;
height?: number | null;
width?: number | null;
hs_code?: string | null;
origin_country?: string | null;
mid_code?: string | null;
metadata?: Record<string, any>;
discountable?: boolean;
collection_id: string;
created_at: Date;
updated_at: Date;
deleted_at: Date | null;
type_id: string;
material?: string | null;
tags?: ProductTag[];
};
export type Product = Partial<Omit<MedusaProduct, 'tags' | 'options' | 'variants'>> & {
title: string;
featuredImage: FeaturedImage;
seo?: {
title?: string;
description?: string;
};
priceRange: {
maxVariantPrice: Money;
};
updatedAt: Date;
descriptionHtml: string;
tags: Array<string>;
availableForSale: boolean;
options?: Array<ProductOption>;
variants: Array<ProductVariant>;
};
export type FeaturedImage = {
url: string;
width?: number;
height?: number;
altText: string;
};
export type ProductTag = {
id: string;
value: string;
created_at: string;
updated_at: string;
deleted_at: string | null;
metadata?: Record<string, unknown> | null;
};
export type MedusaImage = {
id: string;
url: string;
created_at: string;
updated_at: string;
deleted_at?: string | null;
metadata?: { [key: string]: string } | null;
};
export type Image = MedusaImage & {
altText?: string;
};
export type ShippingProfile = {
id: string;
name: string;
type: 'default' | 'gift_card' | 'custom';
products?: Product[];
shipping_options?: ShippingOption[];
created_at: string;
updated_at: string;
deleted_at?: string | null;
metadata?: { [key: string]: any } | null;
};
export type ProductCategory = {
id: string;
name: string;
handle: string;
mpath: string | null;
is_internal?: boolean;
is_active?: boolean;
rank?: number;
category_children?: ProductCategory[];
parent_category_id?: string | null;
parent_category?: ProductCategory | null;
products?: Product[];
created_at: string;
updated_at: string;
};
export type MedusaProductVariant = {
id: string;
title?: string;
product_id: string;
product?: Product;
prices?: MoneyAmount[];
sku?: string;
barcode?: string | null;
ean?: string | null;
upc?: string | null;
variant_rank?: number | null;
inventory_quantity: number;
allow_backorder: boolean;
manage_inventory: boolean;
hs_code?: string | null;
origin_country?: string | null;
mid_code?: string | null;
material?: string | null;
weight?: number | null;
length?: number | null;
height?: number | null;
width?: number | null;
options?: ProductOptionValue[];
created_at: string;
updated_at: string;
deleted_at: string | null;
purchasable?: boolean;
};
export type ProductVariant = MedusaProductVariant & {
availableForSale: boolean;
selectedOptions: SelectedOption[];
price: Money;
};
export type SelectedOption = {
name: string;
value: string;
};
export type MedusaProductOption = {
id: string;
title: string;
values?: ProductOptionValue[];
product_id: string;
product?: Product | null;
created_at: string;
updated_at: string;
deleted_at?: string | null;
metadata?: Record<string, any> | null;
};
export type ProductOption = Omit<MedusaProductOption, 'values'> & {
availableForSale: boolean;
name: string;
values: string[];
};
export type ProductOptionValue = {
id: string;
value: string;
option_id: string;
option?: MedusaProductOption | null;
variant_id: string;
variant?: MedusaProductVariant | null;
created_at: string;
updated_at: string;
deleted_at?: string | null;
metadata?: Record<string, any> | null;
};
export type Money = {
amount: string;
currencyCode: string;
};
export type MoneyAmount = {
id: string;
currency_code: string;
currency?: Currency | null;
amount: number;
min_quantity?: number | null;
max_quantity?: number | null;
price_list_id?: string | null;
price_list?: PriceList | null;
variant_id?: string | null;
variant?: ProductVariant | null;
region_id?: string | null;
region?: Region | null;
created_at: string;
updated_at: string;
deleted_at?: string | null;
};
export type Currency = {
code: string;
symbol: string;
symbol_native: string;
name: string;
includes_tax?: boolean;
};
export type PriceList = {
id: string;
name: string;
description: string;
type?: 'sale' | 'override';
status?: 'active' | 'draft';
starts_at?: string | null;
ends_at?: string | null;
customer_groups?: CustomerGroup[];
prices?: MoneyAmount[];
includes_tax?: boolean;
created_at: string;
updated_at: string;
deleted_at?: string | null;
};
export type CustomerGroup = {
id: string;
name: string;
};
type ShippingOption = {
id: string;
name: string;
region_id: string;
region?: Region | null;
profile_id: string;
profile?: ShippingProfile | null;
provider_id: string;
provider?: FulfillmentProvider | null;
price_type: 'flat_rate' | 'calculated';
amount?: number | null;
is_return: boolean;
admin_only: boolean;
requirements?: ShippingOptionRequirement[];
data: Record<string, unknown>;
includes_tax: boolean;
created_at: string;
updated_at: string;
deleted_at?: string | null;
metadata?: Record<string, unknown> | null;
};
export type Region = {
id: string;
name: string;
country_code: string;
created_at: string;
updated_at: string;
deleted_at?: string | null;
metadata?: Record<string, unknown> | null;
currency_code: string;
tax_code: string;
tax_rate: number;
};
export type FulfillmentProvider = {
id: string;
name: string;
created_at: string;
updated_at: string;
deleted_at?: string | null;
metadata?: Record<string, unknown> | null;
};
export type ShippingOptionRequirement = {
id: string;
requirement_id: string;
value: string;
created_at: string;
updated_at: string;
deleted_at?: string | null;
metadata?: Record<string, unknown> | null;
};
export type MedusaCart = {
id: string;
email?: string;
billing_address_id: string;
// billing_address?: Address;
// shipping_address_id?: string;
// shipping_address?: Address;
items?: MedusaLineItem[];
region_id: string;
region?: Region;
// discounts?: Discount[];
// gift_cards?: GiftCard[];
customer_id?: string;
// customer?: Customer;
// payment_session?: PaymentSession;
// payment_sessions?: PaymentSession[];
payment_id?: string;
// payment?: Payment;
// shipping_methods?: ShippingMethod[];
type: 'default' | 'swap' | 'draft_order' | 'payment_link' | 'claim';
completed_at?: string;
payment_authorized_at?: string;
idempotency_key?: string;
context?: Record<string, any>;
sales_channel_id?: string;
// sales_channel?: SalesChannel;
created_at: string;
updated_at: string;
deleted_at?: string;
metadata?: Record<string, any>;
shipping_total?: number;
discount_total?: number;
raw_discount_total?: number;
item_tax_total?: number;
shipping_tax_total?: number;
tax_total?: number;
refunded_total?: number;
total?: number;
subtotal?: number;
};
export type Cart = Partial<MedusaCart> & {
lines: CartItem[];
checkoutUrl: string;
totalQuantity: number;
cost: {
subtotalAmount: Money;
totalAmount: Money;
totalTaxAmount: Money;
};
};
export type Menu = {
title: string;
path: string;
};
export type MedusaLineItem = {
id: string;
cart_id?: string;
cart?: Cart;
order_id?: string;
// order?: Order;
swap_id?: string | null;
// swap?: Swap;
claim_order_id?: string | null;
// claim_order?: ClaimOrder;
// tax_lines?: LineItemTaxLine[];
// adjustments?: LineItemAdjustment[];
original_item_id?: string | null;
order_edit_id?: string | null;
// order_edit?: OrderEdit;
title: string;
description?: string | null;
thumbnail?: string | null;
is_return: boolean;
is_giftcard: boolean;
should_merge: boolean;
allow_discounts: boolean;
has_shipping?: boolean | null;
unit_price: number;
variant_id?: string | null;
variant: MedusaProductVariant;
quantity: number;
fulfilled_quantity?: number | null;
returned_quantity?: number | null;
shipped_quantity?: number | null;
refundable: number;
subtotal: number;
tax_total: number;
total: number;
original_total: number;
original_tax_total: number;
discount_total: number;
raw_discount_total: number;
gift_card_total: number;
includes_tax: boolean;
created_at: Date;
updated_at: Date;
metadata?: { [key: string]: string } | null;
};
export type CartItem = MedusaLineItem & {
merchandise: {
id: string;
selectedOptions: SelectedOption[];
product: Product;
title: string;
};
cost: {
totalAmount: Money;
};
quantity: number;
};
export type RegionInfo = Pick<Region, 'currency_code' | 'tax_code' | 'tax_rate'>;
export type ProductVariantEntity = ConvertDateToString<Omit<ProductVariant, 'beforeInsert'>>;
export type ProductVariantInfo = Pick<ProductVariantEntity, 'prices'>;
type ConvertDateToString<T extends {}> = {
[P in keyof T]: T[P] extends Date ? Date | string : T[P];
};

View File

@ -1,53 +0,0 @@
import productFragment from './product';
const cartFragment = /* GraphQL */ `
fragment cart on Cart {
id
checkoutUrl
cost {
subtotalAmount {
amount
currencyCode
}
totalAmount {
amount
currencyCode
}
totalTaxAmount {
amount
currencyCode
}
}
lines(first: 100) {
edges {
node {
id
quantity
cost {
totalAmount {
amount
currencyCode
}
}
merchandise {
... on ProductVariant {
id
title
selectedOptions {
name
value
}
product {
...product
}
}
}
}
}
}
totalQuantity
}
${productFragment}
`;
export default cartFragment;

View File

@ -1,10 +0,0 @@
const imageFragment = /* GraphQL */ `
fragment image on Image {
url
altText
width
height
}
`;
export default imageFragment;

View File

@ -1,64 +0,0 @@
import imageFragment from './image';
import seoFragment from './seo';
const productFragment = /* GraphQL */ `
fragment product on Product {
id
handle
availableForSale
title
description
descriptionHtml
options {
id
name
values
}
priceRange {
maxVariantPrice {
amount
currencyCode
}
minVariantPrice {
amount
currencyCode
}
}
variants(first: 250) {
edges {
node {
id
title
availableForSale
selectedOptions {
name
value
}
price {
amount
currencyCode
}
}
}
}
featuredImage {
...image
}
images(first: 20) {
edges {
node {
...image
}
}
}
seo {
...seo
}
tags
updatedAt
}
${imageFragment}
${seoFragment}
`;
export default productFragment;

View File

@ -1,8 +0,0 @@
const seoFragment = /* GraphQL */ `
fragment seo on SEO {
description
title
}
`;
export default seoFragment;

View File

@ -1,142 +0,0 @@
import { isShopifyError } from 'lib/type-guards';
import { Cart, Collection, Product } from './types';
// const endpoint = `${process.env.MEDUSA_BACKEND_API!}`;
const endpoint = `http://localhost:9000/store`;
export default async function medusaRequest(
method: string,
path = '',
payload?: Record<string, unknown> | undefined
) {
const options: RequestInit = {
method,
headers: {
'Content-Type': 'application/json'
}
};
if (payload) {
options.body = JSON.stringify(payload);
}
try {
const result = await fetch(`${endpoint}/${path}`, options);
const body = await result.json();
if (body.errors) {
throw body.errors[0];
}
return {
status: result.status,
body
};
} catch (e) {
if (isShopifyError(e)) {
throw {
status: e.status || 500,
message: e.message
};
}
throw {
error: e
};
}
}
export async function createCart(): Promise<Cart> {
const res = await medusaRequest('POST', '/carts', {});
return res.body.cart;
}
export async function addToCart(
cartId: string,
lines: { merchandiseId: string; quantity: number }[]
): Promise<Cart> {
console.log(lines);
// TODO: transform lines into Medusa line items
const res = await medusaRequest('POST', `/carts/${cartId}/line-items`, {
variant_id: 'something',
quantity: 1
});
return res.body.data.cart;
}
export async function removeFromCart(cartId: string, lineIds: string[]): Promise<Cart> {
// TODO: We only allow you to pass a single line item to delete
const res = await medusaRequest('DELETE', `/carts/${cartId}/line-items/${lineIds[0]}`);
return res.body.data.cart;
}
export async function updateCart(
cartId: string,
lines: { id: string; merchandiseId: string; quantity: number }[]
): Promise<Cart> {
console.log(lines);
// TODO: transform lines into Medusa line items
const res = await medusaRequest('POST', `/carts/${cartId}`, {});
return res.body.data.cart;
}
export async function getCart(cartId: string): Promise<Cart | null> {
const res = await medusaRequest('GET', `/carts/${cartId}`);
if (!res.body.cart) {
return null;
}
return res.body.cart;
}
export async function getCollection(handle: string): Promise<Collection | undefined> {
const res = await medusaRequest('get', `/collections?handle[]=${handle}&limit=1`);
return res.body.collection;
}
export async function getCollectionProducts(handle: string): Promise<Product[]> {
const res = await medusaRequest('get', `/collections?handle[]=${handle}&expand=products`);
if (!res.body?.collection?.products) {
return [];
}
return res.body.collection.products;
}
export async function getCollections(): Promise<Collection[]> {
const collections = [
{
handle: '',
title: 'All',
description: 'All products',
seo: {
title: 'All',
description: 'All products'
},
path: '/search',
updatedAt: new Date().toISOString()
}
];
return collections;
}
export async function getProduct(handle: string): Promise<Product | undefined> {
const res = await medusaRequest('get', `/products?handle=${handle}&limit=1`);
return res.body.product;
}
export async function getProducts({
query
}: {
query?: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
const res = await medusaRequest('get', `/products?q=${query}&limit=20`);
return res.body.products;
}

View File

@ -1,45 +0,0 @@
import cartFragment from '../fragments/cart';
export const addToCartMutation = /* GraphQL */ `
mutation addToCart($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const createCartMutation = /* GraphQL */ `
mutation createCart($lineItems: [CartLineInput!]) {
cartCreate(input: { lines: $lineItems }) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const editCartItemsMutation = /* GraphQL */ `
mutation editCartItems($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
cartLinesUpdate(cartId: $cartId, lines: $lines) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const removeFromCartMutation = /* GraphQL */ `
mutation removeFromCart($cartId: ID!, $lineIds: [ID!]!) {
cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
cart {
...cart
}
}
}
${cartFragment}
`;

View File

@ -1,10 +0,0 @@
import cartFragment from '../fragments/cart';
export const getCartQuery = /* GraphQL */ `
query getCart($cartId: ID!) {
cart(id: $cartId) {
...cart
}
}
${cartFragment}
`;

View File

@ -1,52 +0,0 @@
import productFragment from '../fragments/product';
import seoFragment from '../fragments/seo';
const collectionFragment = /* GraphQL */ `
fragment collection on Collection {
handle
title
description
seo {
...seo
}
updatedAt
}
${seoFragment}
`;
export const getCollectionQuery = /* GraphQL */ `
query getCollection($handle: String!) {
collection(handle: $handle) {
...collection
}
}
${collectionFragment}
`;
export const getCollectionsQuery = /* GraphQL */ `
query getCollections {
collections(first: 100, sortKey: TITLE) {
edges {
node {
...collection
}
}
}
}
${collectionFragment}
`;
export const getCollectionProductsQuery = /* GraphQL */ `
query getCollectionProducts($handle: String!) {
collection(handle: $handle) {
products(first: 100) {
edges {
node {
...product
}
}
}
}
}
${productFragment}
`;

View File

@ -1,10 +0,0 @@
export const getMenuQuery = /* GraphQL */ `
query getMenu($handle: String!) {
menu(handle: $handle) {
items {
title
url
}
}
}
`;

View File

@ -1,41 +0,0 @@
import seoFragment from '../fragments/seo';
const pageFragment = /* GraphQL */ `
fragment page on Page {
... on Page {
id
title
handle
body
bodySummary
seo {
...seo
}
createdAt
updatedAt
}
}
${seoFragment}
`;
export const getPageQuery = /* GraphQL */ `
query getPage($handle: String!) {
pageByHandle(handle: $handle) {
...page
}
}
${pageFragment}
`;
export const getPagesQuery = /* GraphQL */ `
query getPages {
pages(first: 100) {
edges {
node {
...page
}
}
}
}
${pageFragment}
`;

View File

@ -1,32 +0,0 @@
import productFragment from '../fragments/product';
export const getProductQuery = /* GraphQL */ `
query getProduct($handle: String!) {
product(handle: $handle) {
...product
}
}
${productFragment}
`;
export const getProductsQuery = /* GraphQL */ `
query getProducts($sortKey: ProductSortKeys, $reverse: Boolean, $query: String) {
products(sortKey: $sortKey, reverse: $reverse, query: $query, first: 100) {
edges {
node {
...product
}
}
}
}
${productFragment}
`;
export const getProductRecommendationsQuery = /* GraphQL */ `
query getProductRecommendations($productId: ID!) {
productRecommendations(productId: $productId) {
...product
}
}
${productFragment}
`;

View File

@ -1,263 +0,0 @@
export type Maybe<T> = T | null;
export type Connection<T> = {
edges: Array<Edge<T>>;
};
export type Edge<T> = {
node: T;
};
export type Cart = Omit<ShopifyCart, 'lines'> & {
lines: CartItem[];
};
export type CartItem = {
id: string;
quantity: number;
cost: {
totalAmount: Money;
};
merchandise: {
id: string;
title: string;
selectedOptions: {
name: string;
value: string;
}[];
product: Product;
};
};
export type Collection = ShopifyCollection & {
path: string;
};
export type Image = {
url: string;
altText: string;
width: number;
height: number;
};
export type Menu = {
title: string;
path: string;
};
export type Money = {
amount: string;
currencyCode: string;
};
export type Page = {
id: string;
title: string;
handle: string;
body: string;
bodySummary: string;
seo?: SEO;
createdAt: string;
updatedAt: string;
};
export type Product = Omit<ShopifyProduct, 'variants' | 'images'> & {
variants: ProductVariant[];
images: Image[];
};
export type ProductOption = {
id: string;
name: string;
values: string[];
};
export type ProductVariant = {
id: string;
title: string;
availableForSale: boolean;
selectedOptions: {
name: string;
value: string;
}[];
price: Money;
};
export type SEO = {
title: string;
description: string;
};
export type ShopifyCart = {
id: string;
checkoutUrl: string;
cost: {
subtotalAmount: Money;
totalAmount: Money;
totalTaxAmount: Money;
};
lines: Connection<CartItem>;
totalQuantity: number;
};
export type ShopifyCollection = {
handle: string;
title: string;
description: string;
seo: SEO;
updatedAt: string;
};
export type ShopifyProduct = {
id: string;
handle: string;
availableForSale: boolean;
title: string;
description: string;
descriptionHtml: string;
options: ProductOption[];
priceRange: {
maxVariantPrice: Money;
minVariantPrice: Money;
};
variants: Connection<ProductVariant>;
featuredImage: Image;
images: Connection<Image>;
seo: SEO;
tags: string[];
updatedAt: string;
};
export type ShopifyCartOperation = {
data: {
cart: ShopifyCart;
};
variables: {
cartId: string;
};
};
export type ShopifyCreateCartOperation = {
data: { cartCreate: { cart: ShopifyCart } };
};
export type ShopifyAddToCartOperation = {
data: {
cartLinesAdd: {
cart: ShopifyCart;
};
};
variables: {
cartId: string;
lines: {
merchandiseId: string;
quantity: number;
}[];
};
};
export type ShopifyRemoveFromCartOperation = {
data: {
cartLinesRemove: {
cart: ShopifyCart;
};
};
variables: {
cartId: string;
lineIds: string[];
};
};
export type ShopifyUpdateCartOperation = {
data: {
cartLinesUpdate: {
cart: ShopifyCart;
};
};
variables: {
cartId: string;
lines: {
id: string;
merchandiseId: string;
quantity: number;
}[];
};
};
export type ShopifyCollectionOperation = {
data: {
collection: ShopifyCollection;
};
variables: {
handle: string;
};
};
export type ShopifyCollectionProductsOperation = {
data: {
collection: {
products: Connection<ShopifyProduct>;
};
};
variables: {
handle: string;
};
};
export type ShopifyCollectionsOperation = {
data: {
collections: Connection<ShopifyCollection>;
};
};
export type ShopifyMenuOperation = {
data: {
menu?: {
items: {
title: string;
url: string;
}[];
};
};
variables: {
handle: string;
};
};
export type ShopifyPageOperation = {
data: { pageByHandle: Page };
variables: { handle: string };
};
export type ShopifyPagesOperation = {
data: {
pages: Connection<Page>;
};
};
export type ShopifyProductOperation = {
data: { product: ShopifyProduct };
variables: {
handle: string;
};
};
export type ShopifyProductRecommendationsOperation = {
data: {
productRecommendations: ShopifyProduct[];
};
variables: {
productId: string;
};
};
export type ShopifyProductsOperation = {
data: {
products: Connection<ShopifyProduct>;
};
variables: {
query?: string;
reverse?: boolean;
sortKey?: string;
};
};

View File

@ -1,4 +1,4 @@
export interface ShopifyErrorLike {
export interface MedusaErrorLike {
status: number;
message: Error;
}
@ -7,7 +7,7 @@ export const isObject = (object: unknown): object is Record<string, unknown> =>
return typeof object === 'object' && object !== null && !Array.isArray(object);
};
export const isShopifyError = (error: unknown): error is ShopifyErrorLike => {
export const isMedusaError = (error: unknown): error is MedusaErrorLike => {
if (!isObject(error)) return false;
if (error instanceof Error) return true;

View File

@ -1,6 +1,30 @@
import { MedusaProductOption } from './medusa/types';
export const createUrl = (pathname: string, params: URLSearchParams) => {
const paramsString = params.toString();
const queryString = `${paramsString.length ? '?' : ''}${paramsString}`;
return `${pathname}${queryString}`;
};
export const mapOptionIds = (productOptions: MedusaProductOption[]) => {
// Maps the option titles to their respective ids
const map: Record<string, string> = {};
productOptions.forEach((option) => {
map[option.id] = option.title;
});
return map;
};
export const isObject = (input: any) => input instanceof Object;
export const isArray = (input: any) => Array.isArray(input);
export const isEmpty = (input: any) => {
return (
input === null ||
input === undefined ||
(isObject(input) && Object.keys(input).length === 0) ||
(isArray(input) && (input as any[]).length === 0) ||
(typeof input === 'string' && input.trim().length === 0)
);
};

View File

@ -14,6 +14,11 @@ module.exports = {
protocol: 'https',
hostname: 'cdn.shopify.com',
pathname: '/s/files/**'
},
{
protocol: 'https',
hostname: 'medusa-public-images.s3.eu-west-1.amazonaws.com',
pathname: '/**'
}
]
}

View File

@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es5",
"target": "es6",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,

3375
yarn.lock Normal file

File diff suppressed because it is too large Load Diff