Use Fourthwall Headless APIs (#1)

This commit is contained in:
Jieren Chen 2024-09-20 07:40:06 -07:00 committed by GitHub
parent 694c5c17ba
commit a4d2102933
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 765 additions and 1557 deletions

View File

@ -1,7 +1,7 @@
COMPANY_NAME="Vercel Inc."
TWITTER_CREATOR="@vercel"
TWITTER_SITE="https://nextjs.org/commerce"
SITE_NAME="Next.js Commerce"
SHOPIFY_REVALIDATION_SECRET=""
SHOPIFY_STOREFRONT_ACCESS_TOKEN=""
SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com"
# API specifics
NEXT_PUBLIC_FW_API_URL="https://api.staging.fourthwall.com"
# Site specifics
NEXT_PUBLIC_FW_COLLECTION="launch"
NEXT_PUBLIC_FW_PUBLIC_TOKEN=""
NEXT_PUBLIC_FW_CHECKOUT="https://jieren-shop.staging.fourthwall.com"

View File

@ -1,12 +0,0 @@
import Footer from 'components/layout/footer';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<div className="w-full">
<div className="mx-8 max-w-2xl py-20 sm:mx-auto">{children}</div>
</div>
<Footer />
</>
);
}

View File

@ -1,11 +0,0 @@
import OpengraphImage from 'components/opengraph-image';
import { getPage } from 'lib/shopify';
export const runtime = 'edge';
export default async function Image({ params }: { params: { page: string } }) {
const page = await getPage(params.page);
const title = page.seo?.title || page.title;
return await OpengraphImage({ title });
}

View File

@ -1,45 +0,0 @@
import type { Metadata } from 'next';
import Prose from 'components/prose';
import { getPage } from 'lib/shopify';
import { notFound } from 'next/navigation';
export async function generateMetadata({
params
}: {
params: { page: string };
}): Promise<Metadata> {
const page = await getPage(params.page);
if (!page) return notFound();
return {
title: page.seo?.title || page.title,
description: page.seo?.description || page.bodySummary,
openGraph: {
publishedTime: page.createdAt,
modifiedTime: page.updatedAt,
type: 'article'
}
};
}
export default async function Page({ params }: { params: { page: string } }) {
const page = await getPage(params.page);
if (!page) return notFound();
return (
<>
<h1 className="mb-8 text-5xl font-bold">{page.title}</h1>
<Prose className="mb-8" html={page.body as string} />
<p className="text-sm italic">
{`This document was last updated on ${new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(new Date(page.updatedAt))}.`}
</p>
</>
);
}

View File

@ -1,12 +1,6 @@
import { CartProvider } from 'components/cart/cart-context';
import { Navbar } from 'components/layout/navbar';
import { WelcomeToast } from 'components/welcome-toast';
import { GeistSans } from 'geist/font/sans';
import { getCart } from 'lib/shopify';
import { ensureStartsWith } from 'lib/utils';
import { cookies } from 'next/headers';
import { ReactNode } from 'react';
import { Toaster } from 'sonner';
import './globals.css';
const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env;
@ -37,21 +31,11 @@ export const metadata = {
};
export default async function RootLayout({ children }: { children: ReactNode }) {
const cartId = cookies().get('cartId')?.value;
// Don't await the fetch, pass the Promise to the context provider
const cart = getCart(cartId);
return (
<html lang="en" className={GeistSans.variable}>
<body className="bg-neutral-50 text-black selection:bg-teal-300 dark:bg-neutral-900 dark:text-white dark:selection:bg-pink-500 dark:selection:text-white">
<CartProvider cartPromise={cart}>
<Navbar />
<main>
{children}
<Toaster closeButton />
<WelcomeToast />
</main>
</CartProvider>
</body>
</html>
);

View File

@ -1,6 +1,9 @@
import { Carousel } from 'components/carousel';
import { ThreeItemGrid } from 'components/grid/three-items';
import Footer from 'components/layout/footer';
import { Wrapper } from 'components/wrapper';
import { getCart } from 'lib/fourthwall';
import { cookies } from 'next/headers';
export const metadata = {
description: 'High-performance ecommerce store built with Next.js, Vercel, and Shopify.',
@ -9,12 +12,17 @@ export const metadata = {
}
};
export default function HomePage() {
export default async function HomePage({ searchParams }: { searchParams: { currency?: string } }) {
const cartId = cookies().get('cartId')?.value;
const currency = searchParams.currency || 'USD';
// Don't await the fetch, pass the Promise to the context provider
const cart = getCart(cartId, currency);
return (
<>
<ThreeItemGrid />
<Carousel />
<Wrapper currency={currency} cart={cart}>
<ThreeItemGrid currency={currency} />
<Carousel currency={currency} />
<Footer />
</>
</Wrapper>
);
}

View File

@ -6,9 +6,10 @@ import Footer from 'components/layout/footer';
import { Gallery } from 'components/product/gallery';
import { ProductProvider } from 'components/product/product-context';
import { ProductDescription } from 'components/product/product-description';
import { Wrapper } from 'components/wrapper';
import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
import { getProduct, getProductRecommendations } from 'lib/shopify';
import { Image } from 'lib/shopify/types';
import { getCart, getProduct, getProductRecommendations } from 'lib/fourthwall';
import { cookies } from 'next/headers';
import Link from 'next/link';
import { Suspense } from 'react';
@ -17,7 +18,7 @@ export async function generateMetadata({
}: {
params: { handle: string };
}): Promise<Metadata> {
const product = await getProduct(params.handle);
const product = await getProduct({ handle: params.handle, currency: 'USD' });
if (!product) return notFound();
@ -50,8 +51,16 @@ export async function generateMetadata({
};
}
export default async function ProductPage({ params }: { params: { handle: string } }) {
const product = await getProduct(params.handle);
export default async function ProductPage({ params, searchParams }: { params: { handle: string }, searchParams: { currency?: string } }) {
const currency = searchParams.currency || 'USD';
const cartId = cookies().get('cartId')?.value;
const cart = getCart(cartId, currency)
const product = await getProduct({
handle: params.handle,
currency,
});
if (!product) return notFound();
@ -73,6 +82,7 @@ export default async function ProductPage({ params }: { params: { handle: string
};
return (
<Wrapper currency={currency} cart={cart}>
<ProductProvider>
<script
type="application/ld+json"
@ -89,10 +99,7 @@ export default async function ProductPage({ params }: { params: { handle: string
}
>
<Gallery
images={product.images.slice(0, 5).map((image: Image) => ({
src: image.url,
altText: image.altText
}))}
product={product}
/>
</Suspense>
</div>
@ -107,6 +114,7 @@ export default async function ProductPage({ params }: { params: { handle: string
</div>
<Footer />
</ProductProvider>
</Wrapper>
);
}

View File

@ -1,11 +0,0 @@
import OpengraphImage from 'components/opengraph-image';
import { getCollection } from 'lib/shopify';
export const runtime = 'edge';
export default async function Image({ params }: { params: { collection: string } }) {
const collection = await getCollection(params.collection);
const title = collection?.seo?.title || collection?.title;
return await OpengraphImage({ title });
}

View File

@ -1,47 +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 async function generateMetadata({
params
}: {
params: { collection: string };
}): Promise<Metadata> {
const collection = await getCollection(params.collection);
if (!collection) return notFound();
return {
title: collection.seo?.title || collection.title,
description:
collection.seo?.description || collection.description || `${collection.title} products`
};
}
export default async function CategoryPage({
params,
searchParams
}: {
params: { collection: string };
searchParams?: { [key: string]: string | string[] | undefined };
}) {
const { sort } = searchParams as { [key: string]: string };
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
const products = await getCollectionProducts({ collection: params.collection, sortKey, reverse });
return (
<section>
{products.length === 0 ? (
<p className="py-3 text-lg">{`No products found in this collection`}</p>
) : (
<Grid className="grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<ProductGridItems products={products} />
</Grid>
)}
</section>
);
}

View File

@ -1,10 +0,0 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { Fragment } from 'react';
// Ensure children are re-rendered when the search query changes
export default function ChildrenWrapper({ children }: { children: React.ReactNode }) {
const searchParams = useSearchParams();
return <Fragment key={searchParams.get('q')}>{children}</Fragment>;
}

View File

@ -1,24 +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 ChildrenWrapper from './children-wrapper';
export default function SearchLayout({ children }: { children: React.ReactNode }) {
return (
<>
<div className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-4 pb-4 text-black md:flex-row dark:text-white">
<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">
<ChildrenWrapper>{children}</ChildrenWrapper>
</div>
<div className="order-none flex-none md:order-last md:w-[125px]">
<FilterList list={sorting} title="Sort by" />
</div>
</div>
<Footer />
</>
);
}

View File

@ -1,18 +0,0 @@
import Grid from 'components/grid';
export default function Loading() {
return (
<>
<div className="mb-4 h-6" />
<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-800" />
);
})}
</Grid>
</>
);
}

View File

@ -1,39 +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 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">&quot;{searchValue}&quot;</span>
</p>
) : null}
{products.length > 0 ? (
<Grid className="grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<ProductGridItems products={products} />
</Grid>
) : null}
</>
);
}

View File

@ -1,10 +1,13 @@
import { getCollectionProducts } from 'lib/shopify';
import { getCollectionProducts } from 'lib/fourthwall';
import Link from 'next/link';
import { GridTileImage } from './grid/tile';
export async function Carousel() {
export async function Carousel({currency}: {currency: string}) {
// Collections that start with `hidden-*` are hidden from the search page.
const products = await getCollectionProducts({ collection: 'hidden-homepage-carousel' });
const products = await getCollectionProducts({
collection: process.env.NEXT_PUBLIC_FW_COLLECTION || '',
currency,
});
if (!products?.length) return null;
@ -19,7 +22,7 @@ export async function Carousel() {
key={`${product.handle}${i}`}
className="relative aspect-square h-[30vh] max-h-[275px] w-2/3 max-w-[475px] flex-none md:w-1/3"
>
<Link href={`/product/${product.handle}`} className="relative h-full w-full">
<Link href={`/product/${product.handle}?currency=${currency}`} className="relative h-full w-full">
<GridTileImage
alt={product.title}
label={{

View File

@ -1,7 +1,7 @@
'use server';
import { TAGS } from 'lib/constants';
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
import { addToCart, createCart, createCheckout, getCart, removeFromCart, updateCart } from 'lib/fourthwall';
import { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
@ -29,7 +29,7 @@ export async function removeItem(prevState: any, merchandiseId: string) {
}
try {
const cart = await getCart(cartId);
const cart = await getCart(cartId, 'USD');
if (!cart) {
return 'Error fetching cart';
@ -64,7 +64,7 @@ export async function updateItemQuantity(
const { merchandiseId, quantity } = payload;
try {
const cart = await getCart(cartId);
const cart = await getCart(cartId, 'USD');
if (!cart) {
return 'Error fetching cart';
@ -96,20 +96,22 @@ export async function updateItemQuantity(
}
}
export async function redirectToCheckout() {
export async function redirectToCheckout(currency: string) {
let cartId = cookies().get('cartId')?.value;
if (!cartId) {
return 'Missing cart ID';
}
let cart = await getCart(cartId);
let cart = await getCart(cartId, 'USD');
if (!cart) {
return 'Error fetching cart';
}
redirect(cart.checkoutUrl);
const { id } = await createCheckout(cartId, currency);
redirect(`${process.env.FW_CHECKOUT}/checkout/${id}`);
}
export async function createCartAndSetCookie() {

View File

@ -4,7 +4,7 @@ import { PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { addItem } from 'components/cart/actions';
import { useProduct } from 'components/product/product-context';
import { Product, ProductVariant } from 'lib/shopify/types';
import { Product, ProductVariant } from 'lib/types';
import { useFormState } from 'react-dom';
import { useCart } from './cart-context';
@ -27,7 +27,6 @@ function SubmitButton({
);
}
console.log(selectedVariantId);
if (!selectedVariantId) {
return (
<button

View File

@ -1,6 +1,6 @@
'use client';
import type { Cart, CartItem, Product, ProductVariant } from 'lib/shopify/types';
import type { Cart, CartItem, Product, ProductVariant } from 'lib/types';
import React, { createContext, use, useContext, useMemo, useOptimistic } from 'react';
type UpdateType = 'plus' | 'minus' | 'delete';
@ -84,7 +84,6 @@ function updateCartTotals(lines: CartItem[]): Pick<Cart, 'totalQuantity' | 'cost
cost: {
subtotalAmount: { amount: totalAmount.toString(), currencyCode },
totalAmount: { amount: totalAmount.toString(), currencyCode },
totalTaxAmount: { amount: '0', currencyCode }
}
};
}
@ -95,10 +94,10 @@ function createEmptyCart(): Cart {
checkoutUrl: '',
totalQuantity: 0,
lines: [],
currency: 'USD',
cost: {
subtotalAmount: { amount: '0', currencyCode: 'USD' },
totalAmount: { amount: '0', currencyCode: 'USD' },
totalTaxAmount: { amount: '0', currencyCode: 'USD' }
}
};
}
@ -147,7 +146,7 @@ function cartReducer(state: Cart | undefined, action: CartAction): Cart {
export function CartProvider({
children,
cartPromise
cartPromise,
}: {
children: React.ReactNode;
cartPromise: Promise<Cart | undefined>;

View File

@ -2,7 +2,7 @@
import { XMarkIcon } from '@heroicons/react/24/outline';
import { removeItem } from 'components/cart/actions';
import type { CartItem } from 'lib/shopify/types';
import type { CartItem } from 'lib/types';
import { useFormState } from 'react-dom';
export function DeleteItemButton({

View File

@ -3,7 +3,7 @@
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { updateItemQuantity } from 'components/cart/actions';
import type { CartItem } from 'lib/shopify/types';
import type { CartItem } from 'lib/types';
import { useFormState } from 'react-dom';
function SubmitButton({ type }: { type: 'plus' | 'minus' }) {

View File

@ -177,11 +177,7 @@ export default function CartModal() {
<div className="py-4 text-sm text-neutral-500 dark:text-neutral-400">
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 dark:border-neutral-700">
<p>Taxes</p>
<Price
className="text-right text-base text-black dark:text-white"
amount={cart.cost.totalTaxAmount.amount}
currencyCode={cart.cost.totalTaxAmount.currencyCode}
/>
<p className="text-right">Calculated at checkout</p>
</div>
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 pt-1 dark:border-neutral-700">
<p>Shipping</p>
@ -196,7 +192,7 @@ export default function CartModal() {
/>
</div>
</div>
<form action={redirectToCheckout}>
<form action={() => redirectToCheckout(cart.currency)}>
<CheckoutButton />
</form>
</div>

View File

@ -9,7 +9,7 @@ export default function OpenCart({
quantity?: number;
}) {
return (
<div className="relative flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white">
<div className="relative flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors hover:bg-gray-50 dark:border-neutral-700 dark:text-white">
<ShoppingCartIcon
className={clsx('h-4 transition-all ease-in-out hover:scale-110', className)}
/>

View File

@ -1,14 +1,16 @@
import { GridTileImage } from 'components/grid/tile';
import { getCollectionProducts } from 'lib/shopify';
import type { Product } from 'lib/shopify/types';
import { getCollectionProducts } from 'lib/fourthwall';
import type { Product } from 'lib/types';
import Link from 'next/link';
function ThreeItemGridItem({
item,
currency,
size,
priority
}: {
item: Product;
currency: string;
size: 'full' | 'half';
priority?: boolean;
}) {
@ -18,7 +20,7 @@ function ThreeItemGridItem({
>
<Link
className="relative block aspect-square h-full w-full"
href={`/product/${item.handle}`}
href={`/product/${item.handle}?currency=${currency}`}
prefetch={true}
>
<GridTileImage
@ -41,21 +43,22 @@ function ThreeItemGridItem({
);
}
export async function ThreeItemGrid() {
// Collections that start with `hidden-*` are hidden from the search page.
export async function ThreeItemGrid({currency}: { currency: string}) {
const homepageItems = await getCollectionProducts({
collection: 'hidden-homepage-featured-items'
collection: process.env.NEXT_PUBLIC_FW_COLLECTION || '',
currency,
});
if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null;
const [firstProduct, secondProduct, thirdProduct] = homepageItems;
return (
<section className="mx-auto grid max-w-screen-2xl gap-4 px-4 pb-4 md:grid-cols-6 md:grid-rows-2 lg:max-h-[calc(100vh-200px)]">
<ThreeItemGridItem size="full" item={firstProduct} priority={true} />
<ThreeItemGridItem size="half" item={secondProduct} priority={true} />
<ThreeItemGridItem size="half" item={thirdProduct} />
<ThreeItemGridItem size="full" item={firstProduct} priority={true} currency={currency}/>
<ThreeItemGridItem size="half" item={secondProduct} priority={true} currency={currency}/>
<ThreeItemGridItem size="half" item={thirdProduct} currency={currency}/>
</section>
);
}

View File

@ -1,7 +1,7 @@
'use client';
import clsx from 'clsx';
import { Menu } from 'lib/shopify/types';
import { Menu } from 'lib/types';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';

View File

@ -2,7 +2,7 @@ import Link from 'next/link';
import FooterMenu from 'components/layout/footer-menu';
import LogoSquare from 'components/logo-square';
import { getMenu } from 'lib/shopify';
import { getMenu } from 'lib/fourthwall';
import { Suspense } from 'react';
const { COMPANY_NAME, SITE_NAME } = process.env;

View File

@ -0,0 +1,61 @@
'use client';
import { useState } from "react";
const Currencies = [
'USD',
'EUR',
'GBP',
'CAD',
'AUD',
'JPY',
];
export function CurrencySelector({ currency }: { currency: string; }) {
const selectedCurrency = currency;
const [isOpen, setIsOpen] = useState(false);
const handleSelect = (currency: string) => {
// navigate to the current page with the new currency as query param
const newParams = new URLSearchParams(window.location.search);
newParams.set('currency', currency);
window.history.pushState({}, '', `${window.location.pathname}?${newParams}`);
window.location.reload();
setIsOpen(false);
}
return (
<div className="relative inline-block text-left">
<div>
<button
type="button"
className="inline-flex h-11 justify-center items-center w-full rounded-md border border-neutral-200 px-4 py-2 bg-white text-sm font-medium text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-indigo-500"
onClick={() => setIsOpen(!isOpen)}
>
{selectedCurrency}
<svg className="-mr-1 ml-2 h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
{isOpen && (
<div className="origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-10">
<div className="py-1" role="menu" aria-orientation="vertical" aria-labelledby="options-menu">
{Currencies.map((currency) => (
<button
key={currency}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900"
role="menuitem"
onClick={() => handleSelect(currency)}
>
{currency}
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@ -1,24 +1,11 @@
import CartModal from 'components/cart/modal';
import LogoSquare from 'components/logo-square';
import { getMenu } from 'lib/shopify';
import { Menu } from 'lib/shopify/types';
import Link from 'next/link';
import { Suspense } from 'react';
import MobileMenu from './mobile-menu';
import Search, { SearchSkeleton } from './search';
const { SITE_NAME } = process.env;
export async function Navbar() {
const menu = await getMenu('next-js-frontend-header-menu');
import { CurrencySelector } from './currency';
export function Navbar({currency}: {currency: string}) {
return (
<nav className="relative flex items-center justify-between p-4 lg:px-6">
<div className="block flex-none md:hidden">
<Suspense fallback={null}>
<MobileMenu menu={menu} />
</Suspense>
</div>
<div className="flex w-full items-center">
<div className="flex w-full md:w-1/3">
<Link
@ -28,31 +15,14 @@ export async function Navbar() {
>
<LogoSquare />
<div className="ml-2 flex-none text-sm font-medium uppercase md:hidden lg:block">
{SITE_NAME}
Launch on Fourthwall!
</div>
</Link>
{menu.length ? (
<ul className="hidden gap-6 text-sm md:flex md:items-center">
{menu.map((item: Menu) => (
<li key={item.title}>
<Link
href={item.path}
prefetch={true}
className="text-neutral-500 underline-offset-4 hover:text-black hover:underline dark:text-neutral-400 dark:hover:text-neutral-300"
>
{item.title}
</Link>
</li>
))}
</ul>
) : null}
</div>
<div className="hidden justify-center md:flex md:w-1/3">
<Suspense fallback={<SearchSkeleton />}>
<Search />
</Suspense>
</div>
<div className="flex justify-end md:w-1/3">
<div className="flex justify-end md:w-1/3 gap-4">
<CurrencySelector currency={currency} />
<CartModal />
</div>
</div>

View File

@ -1,100 +0,0 @@
'use client';
import { Dialog, Transition } from '@headlessui/react';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
import { Fragment, Suspense, useEffect, useState } from 'react';
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
import { Menu } from 'lib/shopify/types';
import Search, { SearchSkeleton } from './search';
export default function MobileMenu({ menu }: { menu: Menu[] }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const [isOpen, setIsOpen] = useState(false);
const openMobileMenu = () => setIsOpen(true);
const closeMobileMenu = () => setIsOpen(false);
useEffect(() => {
const handleResize = () => {
if (window.innerWidth > 768) {
setIsOpen(false);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [isOpen]);
useEffect(() => {
setIsOpen(false);
}, [pathname, searchParams]);
return (
<>
<button
onClick={openMobileMenu}
aria-label="Open mobile menu"
className="flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors md:hidden dark:border-neutral-700 dark:text-white"
>
<Bars3Icon className="h-4" />
</button>
<Transition show={isOpen}>
<Dialog onClose={closeMobileMenu} className="relative z-50">
<Transition.Child
as={Fragment}
enter="transition-all ease-in-out duration-300"
enterFrom="opacity-0 backdrop-blur-none"
enterTo="opacity-100 backdrop-blur-[.5px]"
leave="transition-all ease-in-out duration-200"
leaveFrom="opacity-100 backdrop-blur-[.5px]"
leaveTo="opacity-0 backdrop-blur-none"
>
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
</Transition.Child>
<Transition.Child
as={Fragment}
enter="transition-all ease-in-out duration-300"
enterFrom="translate-x-[-100%]"
enterTo="translate-x-0"
leave="transition-all ease-in-out duration-200"
leaveFrom="translate-x-0"
leaveTo="translate-x-[-100%]"
>
<Dialog.Panel className="fixed bottom-0 left-0 right-0 top-0 flex h-full w-full flex-col bg-white pb-6 dark:bg-black">
<div className="p-4">
<button
className="mb-4 flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white"
onClick={closeMobileMenu}
aria-label="Close mobile menu"
>
<XMarkIcon className="h-6" />
</button>
<div className="mb-4 w-full">
<Suspense fallback={<SearchSkeleton />}>
<Search />
</Suspense>
</div>
{menu.length ? (
<ul className="flex w-full flex-col">
{menu.map((item: Menu) => (
<li
className="py-2 text-xl text-black transition-colors hover:text-neutral-500 dark:text-white"
key={item.title}
>
<Link href={item.path} prefetch={true} onClick={closeMobileMenu}>
{item.title}
</Link>
</li>
))}
</ul>
) : null}
</div>
</Dialog.Panel>
</Transition.Child>
</Dialog>
</Transition>
</>
);
}

View File

@ -1,40 +0,0 @@
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import Form from 'next/form';
import { useSearchParams } from 'next/navigation';
export default function Search() {
const searchParams = useSearchParams();
return (
<Form action="/search" className="w-max-[550px] relative w-full lg:w-80 xl:w-full">
<input
key={searchParams?.get('q')}
type="text"
name="q"
placeholder="Search for products..."
autoComplete="off"
defaultValue={searchParams?.get('q') || ''}
className="text-md w-full rounded-lg border bg-white px-4 py-2 text-black placeholder:text-neutral-500 md:text-sm 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">
<MagnifyingGlassIcon className="h-4" />
</div>
</Form>
);
}
export function SearchSkeleton() {
return (
<form className="w-max-[550px] relative w-full lg:w-80 xl:w-full">
<input
placeholder="Search for products..."
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">
<MagnifyingGlassIcon className="h-4" />
</div>
</form>
);
}

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/types';
import Link from 'next/link';
export default function ProductGridItems({ products }: { products: Product[] }) {

View File

@ -3,13 +3,20 @@
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
import { GridTileImage } from 'components/grid/tile';
import { useProduct, useUpdateURL } from 'components/product/product-context';
import { Product } from 'lib/types';
import Image from 'next/image';
export function Gallery({ images }: { images: { src: string; altText: string }[] }) {
export function Gallery({ product }: { product: Product }) {
const { state, updateImage } = useProduct();
const updateURL = useUpdateURL();
const imageIndex = state.image ? parseInt(state.image) : 0;
const selectedVariant = product.variants.find((variant) => {
return variant.selectedOptions.find((option) => option.name === 'Color' && option.value === state['color']);
});
const images = selectedVariant?.images || product.images.slice(0, 5);
const nextImageIndex = imageIndex + 1 < images.length ? imageIndex + 1 : 0;
const previousImageIndex = imageIndex === 0 ? images.length - 1 : imageIndex - 1;
@ -25,7 +32,7 @@ export function Gallery({ images }: { images: { src: string; altText: string }[]
fill
sizes="(min-width: 1024px) 66vw, 100vw"
alt={images[imageIndex]?.altText as string}
src={images[imageIndex]?.src as string}
src={images[imageIndex]?.url as string}
priority={true}
/>
)}
@ -65,7 +72,7 @@ export function Gallery({ images }: { images: { src: string; altText: string }[]
const isActive = index === imageIndex;
return (
<li key={image.src} className="h-20 w-20">
<li key={image.url} className="h-20 w-20">
<button
formAction={() => {
const newState = updateImage(index.toString());
@ -76,7 +83,7 @@ export function Gallery({ images }: { images: { src: string; altText: string }[]
>
<GridTileImage
alt={image.altText}
src={image.src}
src={image.url}
width={80}
height={80}
active={isActive}

View File

@ -1,7 +1,7 @@
import { AddToCart } from 'components/cart/add-to-cart';
import Price from 'components/price';
import Prose from 'components/prose';
import { Product } from 'lib/shopify/types';
import { Product } from 'lib/types';
import { VariantSelector } from './variant-selector';
export function ProductDescription({ product }: { product: Product }) {

View File

@ -2,7 +2,7 @@
import clsx from 'clsx';
import { useProduct, useUpdateURL } from 'components/product/product-context';
import { ProductOption, ProductVariant } from 'lib/shopify/types';
import { ProductOption, ProductVariant } from 'lib/types';
type Combination = {
id: string;

17
components/wrapper.tsx Normal file
View File

@ -0,0 +1,17 @@
import { Cart } from "lib/types";
import { ReactNode } from "react";
import { Toaster } from "sonner";
import { CartProvider } from "./cart/cart-context";
import { Navbar } from "./layout/navbar";
import { WelcomeToast } from "./welcome-toast";
export function Wrapper({ children, currency, cart }: { children: ReactNode, currency: string, cart: Promise<Cart | undefined> }) {
return <CartProvider cartPromise={cart}>
<Navbar currency={currency} />
<main>
{children}
<Toaster closeButton />
<WelcomeToast />
</main>
</CartProvider>
}

203
lib/fourthwall/index.ts Normal file
View File

@ -0,0 +1,203 @@
import { Cart, Menu, Product } from "lib/types";
import { reshapeCart, reshapeProduct, reshapeProducts } from "./reshape";
import { FourthwallCart, FourthwallCheckout, FourthwallProduct } from "./types";
const API_URL = process.env.NEXT_PUBLIC_FW_API_URL;
const FW_PUBLIC_TOKEN = process.env.NEXT_PUBLIC_FW_PUBLIC_TOKEN;
/**
* Helpers
*/
async function fourthwallGet<T>(url: string, options: RequestInit = {}): Promise<{ status: number; body: T }> {
try {
const result = await fetch(
url,
{
method: 'GET',
...options,
headers: {
'Content-Type': 'application/json',
'X-FW-Public-Token': FW_PUBLIC_TOKEN || '',
...options.headers
}
}
);
const body = await result.json();
return {
status: result.status,
body
};
} catch (e) {
throw {
error: e,
url
};
}
}
async function fourthwallPost<T>(url: string, data: any, options: RequestInit = {}): Promise<{ status: number; body: T }> {
try {
const result = await fetch(url, {
method: 'POST',
...options,
headers: {
'Content-Type': 'application/json',
'X-FW-Public-Token': FW_PUBLIC_TOKEN || '',
...options.headers
},
body: JSON.stringify(data)
});
const body = await result.json();
return {
status: result.status,
body
};
} catch (e) {
throw {
error: e,
url,
data
};
}
}
/**
* Collection operations
*/
export async function getCollectionProducts({
collection,
currency,
reverse,
sortKey
}: {
collection: string;
currency: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
const res = await fourthwallGet<{results: FourthwallProduct[]}>(`${API_URL}/api/public/v1.0/collections/${collection}/products?&currency=${currency}`, {
headers: {
'X-ShopId': process.env.FW_SHOPID || ''
}
});
if (!res.body.results) {
console.warn(`No collection found for \`${collection}\``);
return [];
}
return reshapeProducts(res.body.results);
}
/**
* Product operations
*/
export async function getProduct({ handle, currency } : { handle: string, currency: string }): Promise<Product | undefined> {
// TODO: replace with real URL
const res = await fourthwallGet<FourthwallProduct>(`${API_URL}/api/public/v1.0/products/${handle}?&currency=${currency}`);
return reshapeProduct(res.body);
}
export async function getProductRecommendations(productId: string): Promise<Product[]> {
// TODO: replace with real URL
return [];
}
/**
* Cart operations
*/
export async function getCart(cartId: string | undefined, currency: string): Promise<Cart | undefined> {
if (!cartId) {
return undefined;
}
const res = await fourthwallGet<FourthwallCart>(`${API_URL}/api/public/v1.0/carts/${cartId}?currency=${currency}`, {
cache: 'no-store'
});
return reshapeCart(res.body);
}
export async function createCart(): Promise<Cart> {
const res = await fourthwallPost<FourthwallCart>(`https://api.staging.fourthwall.com/api/public/v1.0/carts`, {
items: []
});
return reshapeCart(res.body);
}
export async function addToCart(
cartId: string,
lines: { merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const items = lines.map((line) => ({
variantId: line.merchandiseId,
quantity: line.quantity
}));
const res = await fourthwallPost<FourthwallCart>(`${API_URL}/api/public/v1.0/carts/${cartId}/add`, {
items,
}, {
cache: 'no-store'
});
return reshapeCart(res.body);
}
export async function removeFromCart(cartId: string, lineIds: string[]): Promise<Cart> {
const items = lineIds.map((id) => ({
variantId: id
}));
const res = await fourthwallPost<FourthwallCart>(`${API_URL}/api/public/v1.0/carts/${cartId}/remove`, {
items,
}, {
cache: 'no-store'
});
return reshapeCart(res.body);
}
export async function updateCart(
cartId: string,
lines: { id: string; merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const items = lines.map((line) => ({
variantId: line.merchandiseId,
quantity: line.quantity
}));
const res = await fourthwallPost<FourthwallCart>(`${API_URL}/api/public/v1.0/carts/${cartId}/change`, {
items,
}, {
cache: 'no-store'
});
return reshapeCart(res.body);
}
export async function createCheckout(
cartId: string,
cartCurrency: string
): Promise<FourthwallCheckout> {
const res = await fourthwallPost<{ id: string }>(`${API_URL}/api/public/v1.0/checkouts`, {
cartId,
cartCurrency
});
return res.body;
}
/**
* TODO: Stubbed out
*/
export async function getMenu(handle: string): Promise<Menu[]> {
return [];
}

175
lib/fourthwall/reshape.ts Normal file
View File

@ -0,0 +1,175 @@
import { Cart, CartItem, Image, Money, Product, ProductVariant } from "lib/types";
import { FourthwallCart, FourthwallCartItem, FourthwallMoney, FourthwallProduct, FourthwallProductImage, FourthwallProductVariant } from "./types";
/**
* Utils
*/
const DEFAULT_IMAGE: Image = {
url: '',
altText: '',
width: 0,
height: 0
}
const reshapeMoney = (money: FourthwallMoney): Money => {
return {
amount: money.value.toString(),
currencyCode: money.currency
};
}
/**
* Products
*/
export const reshapeProducts = (products: FourthwallProduct[]) => {
const reshapedProducts = [];
for (const product of products) {
if (product) {
const reshapedProduct = reshapeProduct(product);
if (reshapedProduct) {
reshapedProducts.push(reshapedProduct);
}
}
}
return reshapedProducts;
};
export const reshapeProduct = (product: FourthwallProduct): Product | undefined => {
if (!product) {
return undefined;
}
const { images, variants, ...rest } = product;
const minPrice = Math.min(...variants.map((v) => v.unitPrice.value));
const maxPrice = Math.max(...variants.map((v) => v.unitPrice.value));
const currencyCode = variants[0]?.unitPrice.currency || 'USD';
const sizes = new Set(variants.map((v) => v.attributes.size.name));
const colors = new Set(variants.map((v) => v.attributes.color.name));
return {
...rest,
handle: product.slug,
title: product.name,
descriptionHtml: product.description,
description: product.description,
images: reshapeImages(images, product.name),
variants: reshapeVariants(variants),
priceRange: {
minVariantPrice: {
amount: minPrice.toString(),
currencyCode,
},
maxVariantPrice: {
amount: maxPrice.toString(),
currencyCode,
}
},
featuredImage: reshapeImages(images, product.name)[0] || DEFAULT_IMAGE,
options: [{
id: 'color',
name: 'Color',
values: [...colors]
}, {
id: 'size',
name: 'Size',
values: [...sizes]
}],
// TODO: stubbed out
availableForSale: true,
seo: {
title: product.name,
description: product.description,
},
tags: [],
updatedAt: new Date().toISOString(),
};
};
const reshapeImages = (images: FourthwallProductImage[], productTitle: string): Image[] => {
return images.map((image) => {
const filename = image.url.match(/.*\/(.*)\..*/)?.[1];
return {
...image,
altText: `${productTitle} - ${filename}`
};
});
};
const reshapeVariants = (variants: FourthwallProductVariant[]): ProductVariant[] => {
return variants.map((v) => ({
id: v.id,
title: v.name,
availableForSale: true,
images: reshapeImages(v.images, v.name),
selectedOptions: [{
name: 'Size',
value: v.attributes.size.name
}, {
name: 'Color',
value: v.attributes.color.name
}],
price: reshapeMoney(v.unitPrice),
}))
}
/**
* Cart
*/
const reshapeCartItem = (item: FourthwallCartItem): CartItem => {
return {
id: item.variant.id,
quantity: item.quantity,
cost: {
totalAmount: reshapeMoney(item.variant.unitPrice)
},
merchandise: {
id: item.variant.id,
title: item.variant.name,
// TODO: Stubbed out
selectedOptions: [],
product: {
// TODO: need this product info in model
id: 'TT',
handle: 'TT',
title: 'TT',
featuredImage: {
url: item.variant.images[0]?.url || 'TT',
altText: 'TT',
width: item.variant.images[0]?.width || 100,
height: item.variant.images[0]?.height || 100
}
}
}
};
}
export const reshapeCart = (cart: FourthwallCart): Cart => {
const totalValue = cart.items.map((item) => item.quantity * item.variant.unitPrice.value).reduce((a, b) => a + b, 0);
const currencyCode = cart.items[0]?.variant.unitPrice.currency || 'USD';
return {
...cart,
cost: {
totalAmount: {
amount: totalValue.toString(),
currencyCode,
},
subtotalAmount: {
amount: totalValue.toString(),
currencyCode,
},
},
lines: cart.items.map(reshapeCartItem),
currency: currencyCode,
// TODO: Stubbed out
checkoutUrl: 'TT',
totalQuantity: cart.items.map((item) => item.quantity).reduce((a, b) => a + b, 0)
};
};

56
lib/fourthwall/types.ts Normal file
View File

@ -0,0 +1,56 @@
export type FourthwallMoney = {
value: number;
currency: string;
}
export type FourthwallProduct = {
id: string;
name: string;
slug: string;
description: string;
images: FourthwallProductImage[];
variants: FourthwallProductVariant[];
};
export type FourthwallProductImage = {
id: string;
url: string;
width: number;
height: number;
};
export type FourthwallProductVariant = {
id: string;
name: string;
sku: string;
unitPrice: FourthwallMoney;
images: FourthwallProductImage[];
// other attr
attributes: {
description: string;
color: {
name: string;
swatch: string;
},
size: {
name: string;
};
}
};
export type FourthwallCart = {
id: string | undefined;
items: FourthwallCartItem[];
};
export type FourthwallCartItem = {
variant: FourthwallProductVariant;
quantity: number;
};
export type FourthwallCheckout = {
id: string
};

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,455 +0,0 @@
import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT, TAGS } from 'lib/constants';
import { isShopifyError } from 'lib/type-guards';
import { ensureStartsWith } from 'lib/utils';
import { revalidateTag } from 'next/cache';
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import {
addToCartMutation,
createCartMutation,
editCartItemsMutation,
removeFromCartMutation
} from './mutations/cart';
import { getCartQuery } from './queries/cart';
import {
getCollectionProductsQuery,
getCollectionQuery,
getCollectionsQuery
} from './queries/collection';
import { getMenuQuery } from './queries/menu';
import { getPageQuery, getPagesQuery } from './queries/page';
import {
getProductQuery,
getProductRecommendationsQuery,
getProductsQuery
} from './queries/product';
import {
Cart,
Collection,
Connection,
Image,
Menu,
Page,
Product,
ShopifyAddToCartOperation,
ShopifyCart,
ShopifyCartOperation,
ShopifyCollection,
ShopifyCollectionOperation,
ShopifyCollectionProductsOperation,
ShopifyCollectionsOperation,
ShopifyCreateCartOperation,
ShopifyMenuOperation,
ShopifyPageOperation,
ShopifyPagesOperation,
ShopifyProduct,
ShopifyProductOperation,
ShopifyProductRecommendationsOperation,
ShopifyProductsOperation,
ShopifyRemoveFromCartOperation,
ShopifyUpdateCartOperation
} from './types';
const domain = process.env.SHOPIFY_STORE_DOMAIN
? ensureStartsWith(process.env.SHOPIFY_STORE_DOMAIN, 'https://')
: '';
const endpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`;
const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
type ExtractVariables<T> = T extends { variables: object } ? T['variables'] : never;
export async function shopifyFetch<T>({
cache = 'force-cache',
headers,
query,
tags,
variables
}: {
cache?: RequestCache;
headers?: HeadersInit;
query: string;
tags?: string[];
variables?: ExtractVariables<T>;
}): Promise<{ status: number; body: T } | never> {
try {
const result = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': key,
...headers
},
body: JSON.stringify({
...(query && { query }),
...(variables && { variables })
}),
cache,
...(tags && { next: { tags } })
});
const body = await result.json();
if (body.errors) {
throw body.errors[0];
}
return {
status: result.status,
body
};
} catch (e) {
if (isShopifyError(e)) {
throw {
cause: e.cause?.toString() || 'unknown',
status: e.status || 500,
message: e.message,
query
};
}
throw {
error: e,
query
};
}
}
const removeEdgesAndNodes = <T>(array: Connection<T>): T[] => {
return array.edges.map((edge) => edge?.node);
};
const reshapeCart = (cart: ShopifyCart): Cart => {
if (!cart.cost?.totalTaxAmount) {
cart.cost.totalTaxAmount = {
amount: '0.0',
currencyCode: 'USD'
};
}
return {
...cart,
lines: removeEdgesAndNodes(cart.lines)
};
};
const reshapeCollection = (collection: ShopifyCollection): Collection | undefined => {
if (!collection) {
return undefined;
}
return {
...collection,
path: `/search/${collection.handle}`
};
};
const reshapeCollections = (collections: ShopifyCollection[]) => {
const reshapedCollections = [];
for (const collection of collections) {
if (collection) {
const reshapedCollection = reshapeCollection(collection);
if (reshapedCollection) {
reshapedCollections.push(reshapedCollection);
}
}
}
return reshapedCollections;
};
const reshapeImages = (images: Connection<Image>, productTitle: string) => {
const flattened = removeEdgesAndNodes(images);
return flattened.map((image) => {
const filename = image.url.match(/.*\/(.*)\..*/)?.[1];
return {
...image,
altText: image.altText || `${productTitle} - ${filename}`
};
});
};
const reshapeProduct = (product: ShopifyProduct, filterHiddenProducts: boolean = true) => {
if (!product || (filterHiddenProducts && product.tags.includes(HIDDEN_PRODUCT_TAG))) {
return undefined;
}
const { images, variants, ...rest } = product;
return {
...rest,
images: reshapeImages(images, product.title),
variants: removeEdgesAndNodes(variants)
};
};
const reshapeProducts = (products: ShopifyProduct[]) => {
const reshapedProducts = [];
for (const product of products) {
if (product) {
const reshapedProduct = reshapeProduct(product);
if (reshapedProduct) {
reshapedProducts.push(reshapedProduct);
}
}
}
return reshapedProducts;
};
export async function createCart(): Promise<Cart> {
const res = await shopifyFetch<ShopifyCreateCartOperation>({
query: createCartMutation,
cache: 'no-store'
});
return reshapeCart(res.body.data.cartCreate.cart);
}
export async function addToCart(
cartId: string,
lines: { merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const res = await shopifyFetch<ShopifyAddToCartOperation>({
query: addToCartMutation,
variables: {
cartId,
lines
},
cache: 'no-store'
});
return reshapeCart(res.body.data.cartLinesAdd.cart);
}
export async function removeFromCart(cartId: string, lineIds: string[]): Promise<Cart> {
const res = await shopifyFetch<ShopifyRemoveFromCartOperation>({
query: removeFromCartMutation,
variables: {
cartId,
lineIds
},
cache: 'no-store'
});
return reshapeCart(res.body.data.cartLinesRemove.cart);
}
export async function updateCart(
cartId: string,
lines: { id: string; merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const res = await shopifyFetch<ShopifyUpdateCartOperation>({
query: editCartItemsMutation,
variables: {
cartId,
lines
},
cache: 'no-store'
});
return reshapeCart(res.body.data.cartLinesUpdate.cart);
}
export async function getCart(cartId: string | undefined): Promise<Cart | undefined> {
if (!cartId) {
return undefined;
}
const res = await shopifyFetch<ShopifyCartOperation>({
query: getCartQuery,
variables: { cartId },
tags: [TAGS.cart]
});
// Old carts becomes `null` when you checkout.
if (!res.body.data.cart) {
return undefined;
}
return reshapeCart(res.body.data.cart);
}
export async function getCollection(handle: string): Promise<Collection | undefined> {
const res = await shopifyFetch<ShopifyCollectionOperation>({
query: getCollectionQuery,
tags: [TAGS.collections],
variables: {
handle
}
});
return reshapeCollection(res.body.data.collection);
}
export async function getCollectionProducts({
collection,
reverse,
sortKey
}: {
collection: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
const res = await shopifyFetch<ShopifyCollectionProductsOperation>({
query: getCollectionProductsQuery,
tags: [TAGS.collections, TAGS.products],
variables: {
handle: collection,
reverse,
sortKey: sortKey === 'CREATED_AT' ? 'CREATED' : sortKey
}
});
if (!res.body.data.collection) {
console.log(`No collection found for \`${collection}\``);
return [];
}
return reshapeProducts(removeEdgesAndNodes(res.body.data.collection.products));
}
export async function getCollections(): Promise<Collection[]> {
const res = await shopifyFetch<ShopifyCollectionsOperation>({
query: getCollectionsQuery,
tags: [TAGS.collections]
});
const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections);
const collections = [
{
handle: '',
title: 'All',
description: 'All products',
seo: {
title: 'All',
description: 'All products'
},
path: '/search',
updatedAt: new Date().toISOString()
},
// Filter out the `hidden` collections.
// Collections that start with `hidden-*` need to be hidden on the search page.
...reshapeCollections(shopifyCollections).filter(
(collection) => !collection.handle.startsWith('hidden')
)
];
return collections;
}
export async function getMenu(handle: string): Promise<Menu[]> {
const res = await shopifyFetch<ShopifyMenuOperation>({
query: getMenuQuery,
tags: [TAGS.collections],
variables: {
handle
}
});
return (
res.body?.data?.menu?.items.map((item: { title: string; url: string }) => ({
title: item.title,
path: item.url.replace(domain, '').replace('/collections', '/search').replace('/pages', '')
})) || []
);
}
export async function getPage(handle: string): Promise<Page> {
const res = await shopifyFetch<ShopifyPageOperation>({
query: getPageQuery,
cache: 'no-store',
variables: { handle }
});
return res.body.data.pageByHandle;
}
export async function getPages(): Promise<Page[]> {
const res = await shopifyFetch<ShopifyPagesOperation>({
query: getPagesQuery,
cache: 'no-store'
});
return removeEdgesAndNodes(res.body.data.pages);
}
export async function getProduct(handle: string): Promise<Product | undefined> {
const res = await shopifyFetch<ShopifyProductOperation>({
query: getProductQuery,
tags: [TAGS.products],
variables: {
handle
}
});
return reshapeProduct(res.body.data.product, false);
}
export async function getProductRecommendations(productId: string): Promise<Product[]> {
const res = await shopifyFetch<ShopifyProductRecommendationsOperation>({
query: getProductRecommendationsQuery,
tags: [TAGS.products],
variables: {
productId
}
});
return reshapeProducts(res.body.data.productRecommendations);
}
export async function getProducts({
query,
reverse,
sortKey
}: {
query?: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
const res = await shopifyFetch<ShopifyProductsOperation>({
query: getProductsQuery,
tags: [TAGS.products],
variables: {
query,
reverse,
sortKey
}
});
return reshapeProducts(removeEdgesAndNodes(res.body.data.products));
}
// This is called from `app/api/revalidate.ts` so providers can control revalidation logic.
export async function revalidate(req: NextRequest): Promise<NextResponse> {
// We always need to respond with a 200 status code to Shopify,
// otherwise it will continue to retry the request.
const collectionWebhooks = ['collections/create', 'collections/delete', 'collections/update'];
const productWebhooks = ['products/create', 'products/delete', 'products/update'];
const topic = headers().get('x-shopify-topic') || 'unknown';
const secret = req.nextUrl.searchParams.get('secret');
const isCollectionUpdate = collectionWebhooks.includes(topic);
const isProductUpdate = productWebhooks.includes(topic);
if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) {
console.error('Invalid revalidation secret.');
return NextResponse.json({ status: 200 });
}
if (!isCollectionUpdate && !isProductUpdate) {
// We don't need to revalidate anything for any other topics.
return NextResponse.json({ status: 200 });
}
if (isCollectionUpdate) {
revalidateTag(TAGS.collections);
}
if (isProductUpdate) {
revalidateTag(TAGS.products);
}
return NextResponse.json({ status: 200, revalidated: true, now: Date.now() });
}

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,56 +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!
$sortKey: ProductCollectionSortKeys
$reverse: Boolean
) {
collection(handle: $handle) {
products(sortKey: $sortKey, reverse: $reverse, 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,272 +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 CartProduct = {
id: string;
handle: string;
title: string;
featuredImage: Image;
};
export type CartItem = {
id: string | undefined;
quantity: number;
cost: {
totalAmount: Money;
};
merchandise: {
id: string;
title: string;
selectedOptions: {
name: string;
value: string;
}[];
product: CartProduct;
};
};
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 | undefined;
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;
reverse?: boolean;
sortKey?: 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;
};
};

125
lib/types.ts Normal file
View File

@ -0,0 +1,125 @@
export type Maybe<T> = T | null;
export type Connection<T> = {
edges: Array<Edge<T>>;
};
export type Edge<T> = {
node: T;
};
export type Cart = {
id: string | undefined;
checkoutUrl: string;
cost: {
subtotalAmount: Money;
totalAmount: Money;
};
totalQuantity: number;
lines: CartItem[];
currency: string;
};
export type CartProduct = {
id: string;
handle: string;
title: string;
featuredImage: Image;
};
export type CartItem = {
id: string | undefined;
quantity: number;
cost: {
totalAmount: Money;
};
merchandise: {
id: string;
title: string;
selectedOptions: {
name: string;
value: string;
}[];
product: CartProduct;
};
};
export type Collection = {
handle: string;
title: string;
description: string;
seo: SEO;
updatedAt: string;
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 = {
id: string;
handle: string;
availableForSale: boolean;
title: string;
description: string;
descriptionHtml: string;
options: ProductOption[];
priceRange: {
maxVariantPrice: Money;
minVariantPrice: Money;
};
featuredImage: Image;
seo: SEO;
tags: string[];
updatedAt: string;
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;
images: Image[];
};
export type SEO = {
title: string;
description: string;
};

View File

@ -7,6 +7,11 @@ module.exports = {
protocol: 'https',
hostname: 'cdn.shopify.com',
pathname: '/s/files/**'
},
{
protocol: 'https',
hostname: 'storage.googleapis.com',
pathname: '/cdn.staging.fourthwall.com/**'
}
]
}