Merge branch 'main' into develop

This commit is contained in:
Thomas Frost 2024-07-20 13:56:15 -07:00
commit 659d7cf255
13 changed files with 175 additions and 271 deletions

View File

@ -1,12 +1,9 @@
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,73 +1,78 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { GridTileImage } from 'components/grid/tile';
import Footer from 'components/layout/footer';
import { Gallery } from 'components/product/gallery';
import { ProductDescription } from 'components/product/product-description';
import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
import { getProduct, getProductRecommendations } from 'lib/shopify';
import { Image } from 'lib/shopify/types';
import { getProductById, getProductRecommendations } from 'lib/shopify';
import { ContentLandingPages, Image, Store } from 'lib/shopify/types';
import Link from 'next/link';
import { Suspense } from 'react';
export async function generateMetadata({
params
}: {
params: { handle: string };
}): Promise<Metadata> {
const product = await getProduct(params.handle);
if (!product) return notFound();
const { url, width, height, altText: alt } = product.featuredImage || {};
const indexable = !product.tags.includes(HIDDEN_PRODUCT_TAG);
return {
title: product.seo.title || product.title,
description: product.seo.description || product.description,
robots: {
index: indexable,
follow: indexable,
googleBot: {
index: indexable,
follow: indexable
}
const lookupContentLandingPage = async (contentLandingPageId: string) => {
const contentLandingPages: ContentLandingPages = {
ABC: {
contentLandingPageId: 'ABC',
content: {
contentId: 'ABC-123',
contentUrl: 'https://vercel.com'
},
brand: {
brandId: '123456789',
companyName: 'Vercel'
},
store: {
domain: 'https://test-app-furie.myshopify.com',
key: '30f0c9b2ee5c69d6c0de2e7a048eb6b4'
},
productId: 'gid://shopify/Product/8587441176812'
},
openGraph: url
? {
images: [
{
url,
width,
height,
alt
}
]
}
: null
'123': {
contentLandingPageId: '123',
content: {
contentId: '123-ABC',
contentUrl: 'https://vercel.com'
},
brand: {
brandId: '123456789',
companyName: 'Vercel'
},
store: {
domain: 'https://test-app-furie.myshopify.com',
key: '30f0c9b2ee5c69d6c0de2e7a048eb6b4'
},
productId: 'gid://shopify/Product/8587440849132'
}
};
}
export default async function ProductPage({ params }: { params: { handle: string } }) {
const product = await getProduct(params.handle);
const contentLandingPage = contentLandingPages[contentLandingPageId];
if (!product) return notFound();
if (!contentLandingPage) {
throw new Error('Content Landing Page not found');
}
const product = await getProductById(contentLandingPage.store, contentLandingPage?.productId);
return { ...contentLandingPage, product };
};
export default async function Page({ params }: { params: { ContentLandingPage: string } }) {
const instance = await lookupContentLandingPage(params.ContentLandingPage);
if (!instance.product) {
return <div>Product not found</div>;
}
const productJsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.title,
description: product.description,
image: product.featuredImage.url,
name: instance.product.title,
description: instance.product.description,
image: instance.product.featuredImage.url,
offers: {
'@type': 'AggregateOffer',
availability: product.availableForSale
availability: instance.product.availableForSale
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
priceCurrency: product.priceRange.minVariantPrice.currencyCode,
highPrice: product.priceRange.maxVariantPrice.amount,
lowPrice: product.priceRange.minVariantPrice.amount
priceCurrency: instance.product.priceRange.minVariantPrice.currencyCode,
highPrice: instance.product.priceRange.maxVariantPrice.amount,
lowPrice: instance.product.priceRange.minVariantPrice.amount
}
};
@ -88,7 +93,7 @@ export default async function ProductPage({ params }: { params: { handle: string
}
>
<Gallery
images={product.images.map((image: Image) => ({
images={instance.product.images.map((image: Image) => ({
src: image.url,
altText: image.altText
}))}
@ -97,18 +102,17 @@ export default async function ProductPage({ params }: { params: { handle: string
</div>
<div className="basis-full lg:basis-2/6">
<ProductDescription product={product} />
<ProductDescription product={instance.product} />
</div>
</div>
<RelatedProducts id={product.id} />
<RelatedProducts id={instance.product.id} store={instance.store} />
</div>
<Footer />
</>
);
}
async function RelatedProducts({ id }: { id: string }) {
const relatedProducts = await getProductRecommendations(id);
async function RelatedProducts({ store, id }: { store: Store; id: string }) {
const relatedProducts = await getProductRecommendations(store, id);
if (!relatedProducts.length) return null;

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,4 +1,3 @@
import Navbar from 'components/layout/navbar';
import { GeistSans } from 'geist/font/sans';
import { ensureStartsWith } from 'lib/utils';
import { ReactNode } from 'react';
@ -35,7 +34,6 @@ export default async function RootLayout({ children }: { children: ReactNode })
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">
<Navbar />
<main>{children}</main>
</body>
</html>

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,21 +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';
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">{children}</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,15 +0,0 @@
import Grid from 'components/grid';
export default function Loading() {
return (
<Grid className="grid-cols-2 lg:grid-cols-3">
{Array(12)
.fill(0)
.map((_, index) => {
return (
<Grid.Item key={index} className="animate-pulse bg-neutral-100 dark:bg-neutral-900" />
);
})}
</Grid>
);
}

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,6 +1,5 @@
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';
@ -19,6 +18,7 @@ import {
import { getMenuQuery } from './queries/menu';
import { getPageQuery, getPagesQuery } from './queries/page';
import {
getProductByIdQuery,
getProductQuery,
getProductRecommendationsQuery,
getProductsQuery
@ -47,36 +47,46 @@ import {
ShopifyProductRecommendationsOperation,
ShopifyProductsOperation,
ShopifyRemoveFromCartOperation,
ShopifyUpdateCartOperation
ShopifyUpdateCartOperation,
Store
} from './types';
/*
const domain = process.env.SHOPIFY_STORE_DOMAIN
? ensureStartsWith(process.env.SHOPIFY_STORE_DOMAIN, 'https://')
: '';
const endpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`;
const endpoint = `${domain}`;
const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
*/
type ExtractVariables<T> = T extends { variables: object } ? T['variables'] : never;
export async function shopifyFetch<T>({
store,
cache = 'force-cache',
headers,
query,
tags,
variables
}: {
store: Store;
cache?: RequestCache;
headers?: HeadersInit;
query: string;
tags?: string[];
variables?: ExtractVariables<T>;
}): Promise<{ status: number; body: T } | never> {
if (!store) {
throw new Error('Missing Shopify store configuration.');
}
const url = `${store.domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`;
console.log({ url, query });
try {
const result = await fetch(endpoint, {
const result = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': key,
'X-Shopify-Storefront-Access-Token': store.key,
...headers
},
body: JSON.stringify({
@ -201,8 +211,9 @@ const reshapeProducts = (products: ShopifyProduct[]) => {
return reshapedProducts;
};
export async function createCart(): Promise<Cart> {
export async function createCart(store: Store): Promise<Cart> {
const res = await shopifyFetch<ShopifyCreateCartOperation>({
store: store,
query: createCartMutation,
cache: 'no-store'
});
@ -211,10 +222,12 @@ export async function createCart(): Promise<Cart> {
}
export async function addToCart(
store: Store,
cartId: string,
lines: { merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const res = await shopifyFetch<ShopifyAddToCartOperation>({
store: store,
query: addToCartMutation,
variables: {
cartId,
@ -225,8 +238,13 @@ export async function addToCart(
return reshapeCart(res.body.data.cartLinesAdd.cart);
}
export async function removeFromCart(cartId: string, lineIds: string[]): Promise<Cart> {
export async function removeFromCart(
store: Store,
cartId: string,
lineIds: string[]
): Promise<Cart> {
const res = await shopifyFetch<ShopifyRemoveFromCartOperation>({
store: store,
query: removeFromCartMutation,
variables: {
cartId,
@ -239,10 +257,12 @@ export async function removeFromCart(cartId: string, lineIds: string[]): Promise
}
export async function updateCart(
store: Store,
cartId: string,
lines: { id: string; merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const res = await shopifyFetch<ShopifyUpdateCartOperation>({
store: store,
query: editCartItemsMutation,
variables: {
cartId,
@ -254,8 +274,9 @@ export async function updateCart(
return reshapeCart(res.body.data.cartLinesUpdate.cart);
}
export async function getCart(cartId: string): Promise<Cart | undefined> {
export async function getCart(store: Store, cartId: string): Promise<Cart | undefined> {
const res = await shopifyFetch<ShopifyCartOperation>({
store: store,
query: getCartQuery,
variables: { cartId },
tags: [TAGS.cart],
@ -270,8 +291,9 @@ export async function getCart(cartId: string): Promise<Cart | undefined> {
return reshapeCart(res.body.data.cart);
}
export async function getCollection(handle: string): Promise<Collection | undefined> {
export async function getCollection(store: Store, handle: string): Promise<Collection | undefined> {
const res = await shopifyFetch<ShopifyCollectionOperation>({
store: store,
query: getCollectionQuery,
tags: [TAGS.collections],
variables: {
@ -283,15 +305,18 @@ export async function getCollection(handle: string): Promise<Collection | undefi
}
export async function getCollectionProducts({
store,
collection,
reverse,
sortKey
}: {
store: Store;
collection: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
const res = await shopifyFetch<ShopifyCollectionProductsOperation>({
store,
query: getCollectionProductsQuery,
tags: [TAGS.collections, TAGS.products],
variables: {
@ -309,8 +334,9 @@ export async function getCollectionProducts({
return reshapeProducts(removeEdgesAndNodes(res.body.data.collection.products));
}
export async function getCollections(): Promise<Collection[]> {
export async function getCollections(store: Store): Promise<Collection[]> {
const res = await shopifyFetch<ShopifyCollectionsOperation>({
store: store,
query: getCollectionsQuery,
tags: [TAGS.collections]
});
@ -337,8 +363,9 @@ export async function getCollections(): Promise<Collection[]> {
return collections;
}
export async function getMenu(handle: string): Promise<Menu[]> {
export async function getMenu(store: Store, handle: string): Promise<Menu[]> {
const res = await shopifyFetch<ShopifyMenuOperation>({
store: store,
query: getMenuQuery,
tags: [TAGS.collections],
variables: {
@ -349,13 +376,17 @@ export async function getMenu(handle: string): Promise<Menu[]> {
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', '')
path: item.url
.replace(store.domain, '')
.replace('/collections', '/search')
.replace('/pages', '')
})) || []
);
}
export async function getPage(handle: string): Promise<Page> {
export async function getPage(store: Store, handle: string): Promise<Page> {
const res = await shopifyFetch<ShopifyPageOperation>({
store: store,
query: getPageQuery,
cache: 'no-store',
variables: { handle }
@ -364,8 +395,9 @@ export async function getPage(handle: string): Promise<Page> {
return res.body.data.pageByHandle;
}
export async function getPages(): Promise<Page[]> {
export async function getPages(store: Store): Promise<Page[]> {
const res = await shopifyFetch<ShopifyPagesOperation>({
store: store,
query: getPagesQuery,
cache: 'no-store'
});
@ -373,8 +405,9 @@ export async function getPages(): Promise<Page[]> {
return removeEdgesAndNodes(res.body.data.pages);
}
export async function getProduct(handle: string): Promise<Product | undefined> {
export async function getProduct(store: Store, handle: string): Promise<Product | undefined> {
const res = await shopifyFetch<ShopifyProductOperation>({
store,
query: getProductQuery,
tags: [TAGS.products],
variables: {
@ -385,8 +418,28 @@ export async function getProduct(handle: string): Promise<Product | undefined> {
return reshapeProduct(res.body.data.product, false);
}
export async function getProductRecommendations(productId: string): Promise<Product[]> {
export async function getProductById(
store: Store,
productId: string
): Promise<Product | undefined> {
const res = await shopifyFetch<ShopifyProductOperation>({
store,
query: getProductByIdQuery,
tags: [TAGS.products],
variables: {
id: productId
}
});
return reshapeProduct(res.body.data.product, false);
}
export async function getProductRecommendations(
store: Store,
productId: string
): Promise<Product[]> {
const res = await shopifyFetch<ShopifyProductRecommendationsOperation>({
store,
query: getProductRecommendationsQuery,
tags: [TAGS.products],
variables: {
@ -398,15 +451,18 @@ export async function getProductRecommendations(productId: string): Promise<Prod
}
export async function getProducts({
store,
query,
reverse,
sortKey
}: {
store: Store;
query?: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
const res = await shopifyFetch<ShopifyProductsOperation>({
store,
query: getProductsQuery,
tags: [TAGS.products],
variables: {

View File

@ -9,6 +9,15 @@ export const getProductQuery = /* GraphQL */ `
${productFragment}
`;
export const getProductByIdQuery = /* GraphQL */ `
query getProduct($id: ID!) {
product(id: $id) {
...product
}
}
${productFragment}
`;
export const getProductsQuery = /* GraphQL */ `
query getProducts($sortKey: ProductSortKeys, $reverse: Boolean, $query: String) {
products(sortKey: $sortKey, reverse: $reverse, query: $query, first: 100) {

View File

@ -8,6 +8,33 @@ export type Edge<T> = {
node: T;
};
export type Brand = {
brandId: string;
companyName: string;
};
export type Content = {
contentId: string;
contentUrl: string;
};
export type ContentLandingPage = {
contentLandingPageId: string;
content: Content;
brand: Brand;
store: Store;
productId: string;
};
export type ContentLandingPages = {
[key: string]: ContentLandingPage;
};
export type Store = {
domain: string;
key: string;
};
export type Cart = Omit<ShopifyCart, 'lines'> & {
lines: CartItem[];
};
@ -239,9 +266,11 @@ export type ShopifyPagesOperation = {
export type ShopifyProductOperation = {
data: { product: ShopifyProduct };
variables: {
handle: string;
};
variables:
| {
handle: string;
}
| { id: string };
};
export type ShopifyProductRecommendationsOperation = {