mirror of
https://github.com/vercel/commerce.git
synced 2025-05-13 21:27:50 +00:00
product page: initial layout
This commit is contained in:
parent
0eac0c50cb
commit
55d289451b
@ -1,4 +1,4 @@
|
||||
import { getCollections } from 'lib/shopify';
|
||||
import { getCollections } from 'commerce/shopify';
|
||||
|
||||
import { HomeProductsList } from '/components/home';
|
||||
|
||||
|
@ -1,7 +1,9 @@
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
export default function PageLayout({ children }) {
|
||||
return (
|
||||
<>
|
||||
<main>{children}</main>
|
||||
<main className={styles.main}>{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -2,9 +2,77 @@ import Image from 'next/image';
|
||||
|
||||
import xss from 'xss';
|
||||
|
||||
import { getProducts, getProduct } from 'lib/shopify';
|
||||
import { getProducts, getProduct } from 'commerce/shopify';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
import PurchaseInput from '/components/product/purchase-input.js';
|
||||
import { getTags, listTags } from '/util';
|
||||
|
||||
//TODO: NumberInput
|
||||
|
||||
const ImageScroll = ({ images }) => (
|
||||
<div className={styles.imageScroll}>
|
||||
<div className={styles.horizScroll}>
|
||||
{images?.length > 1 && (
|
||||
<p className={styles.scrollMessage}>Scroll to right ( → )</p>
|
||||
)}
|
||||
<div className={styles.imageContainer}>
|
||||
{images?.map(image => (
|
||||
<Image
|
||||
key={image?.url}
|
||||
src={image?.url}
|
||||
alt={image?.altText}
|
||||
width={image?.width}
|
||||
height={image?.height}
|
||||
/>
|
||||
))}
|
||||
<div>
|
||||
<div className={styles.spacer} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ProductPane = async ({ product }) => {
|
||||
const tags = await getTags({ product });
|
||||
|
||||
return (
|
||||
<div className={styles.productPane}>
|
||||
{product?.handle ? (
|
||||
<div className={styles.topBottom}>
|
||||
<div className={styles.description}>
|
||||
<h1>{product?.title}</h1>
|
||||
{tags && tags.length > 0 && (
|
||||
<h2 className={styles.collections}>
|
||||
{listTags({ tags })}
|
||||
</h2>
|
||||
)}
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: xss(product.descriptionHtml),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<PurchaseInput product={product} />
|
||||
</div>
|
||||
) : (
|
||||
<p>Product not found</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default async function ProductPage({ params: { handle } }) {
|
||||
const product = await getProduct(handle);
|
||||
|
||||
return (
|
||||
<div className={styles.productPage}>
|
||||
<ImageScroll images={product.images} />
|
||||
<ProductPane {...{ product }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const products = await getProducts({
|
||||
@ -15,37 +83,3 @@ export async function generateStaticParams() {
|
||||
|
||||
return products.map(product => ({ product: product.handle }));
|
||||
}
|
||||
|
||||
//TODO: NumberInput
|
||||
|
||||
export default async function ProductPage({ params: { handle } }) {
|
||||
const product = await getProduct(handle);
|
||||
|
||||
return (
|
||||
<>
|
||||
{product?.handle ? (
|
||||
<>
|
||||
<h1>{product?.title}</h1>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: xss(product.descriptionHtml),
|
||||
}}
|
||||
/>
|
||||
<PurchaseInput product={product} />
|
||||
</>
|
||||
) : (
|
||||
<p>Product not found</p>
|
||||
)}
|
||||
<p>Scroll to right ( → )</p>
|
||||
{product?.images?.map(image => (
|
||||
<Image
|
||||
key={image?.url}
|
||||
src={image?.url}
|
||||
alt={image?.altText}
|
||||
width={image?.width}
|
||||
height={image?.height}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
93
app/(page)/product/[handle]/styles.module.scss
Normal file
93
app/(page)/product/[handle]/styles.module.scss
Normal file
@ -0,0 +1,93 @@
|
||||
@use 'styles/_spacing';
|
||||
@use 'styles/_typography';
|
||||
|
||||
$spacer-width: calc(100vw - 100vh);
|
||||
|
||||
.imageScroll {
|
||||
position: relative;
|
||||
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 0;
|
||||
overflow-y: visible;
|
||||
|
||||
.horizScroll {
|
||||
height: 100vh;
|
||||
overflow-x: scroll;
|
||||
position: relative;
|
||||
|
||||
.scrollMessage {
|
||||
@include typography.subheader;
|
||||
|
||||
position: absolute;
|
||||
|
||||
left: 30px;
|
||||
bottom: spacing.$page-bottom-baseline;
|
||||
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
height: 100%;
|
||||
|
||||
> * {
|
||||
height: 100%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
img {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
padding-right: $spacer-width;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.productPane {
|
||||
padding-left: calc(calc(100vw - $spacer-width) + spacing.$grid-column-gap);
|
||||
padding-right: spacing.$page-margin-x;
|
||||
padding-top: 59px;
|
||||
padding-bottom: spacing.$page-bottom-baseline;
|
||||
|
||||
height: 100vh;
|
||||
|
||||
.topBottom {
|
||||
* {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.description {
|
||||
@include typography.body-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.productPage {
|
||||
position: absolute;
|
||||
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.productPage {
|
||||
position: absolute;
|
||||
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
5
app/(page)/styles.module.scss
Normal file
5
app/(page)/styles.module.scss
Normal file
@ -0,0 +1,5 @@
|
||||
@use 'styles/_spacing';
|
||||
|
||||
.main {
|
||||
// padding: 0 spacing.$page-margin-x;
|
||||
}
|
50
commerce/constants.ts
Normal file
50
commerce/constants.ts
Normal file
@ -0,0 +1,50 @@
|
||||
export type SortFilterItem = {
|
||||
title: string;
|
||||
slug: string | null;
|
||||
sortKey: 'RELEVANCE' | 'BEST_SELLING' | 'CREATED_AT' | 'PRICE';
|
||||
reverse: boolean;
|
||||
};
|
||||
|
||||
export const defaultSort: SortFilterItem = {
|
||||
title: 'Relevance',
|
||||
slug: null,
|
||||
sortKey: 'RELEVANCE',
|
||||
reverse: false,
|
||||
};
|
||||
|
||||
export const sorting: SortFilterItem[] = [
|
||||
defaultSort,
|
||||
{
|
||||
title: 'Trending',
|
||||
slug: 'trending-desc',
|
||||
sortKey: 'BEST_SELLING',
|
||||
reverse: false,
|
||||
}, // asc
|
||||
{
|
||||
title: 'Latest arrivals',
|
||||
slug: 'latest-desc',
|
||||
sortKey: 'CREATED_AT',
|
||||
reverse: true,
|
||||
},
|
||||
{
|
||||
title: 'Price: Low to high',
|
||||
slug: 'price-asc',
|
||||
sortKey: 'PRICE',
|
||||
reverse: false,
|
||||
}, // asc
|
||||
{
|
||||
title: 'Price: High to low',
|
||||
slug: 'price-desc',
|
||||
sortKey: 'PRICE',
|
||||
reverse: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const TAGS = {
|
||||
collections: 'collections',
|
||||
products: 'products',
|
||||
};
|
||||
|
||||
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
|
||||
export const DEFAULT_OPTION = 'Default Title';
|
||||
export const SHOPIFY_GRAPHQL_API_ENDPOINT = '/api/2023-01/graphql.json';
|
53
commerce/shopify/fragments/cart.ts
Normal file
53
commerce/shopify/fragments/cart.ts
Normal file
@ -0,0 +1,53 @@
|
||||
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;
|
10
commerce/shopify/fragments/image.ts
Normal file
10
commerce/shopify/fragments/image.ts
Normal file
@ -0,0 +1,10 @@
|
||||
const imageFragment = /* GraphQL */ `
|
||||
fragment image on Image {
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
`;
|
||||
|
||||
export default imageFragment;
|
@ -1,8 +1,8 @@
|
||||
const seoFragment = /* GraphQL */ `
|
||||
fragment seo on SEO {
|
||||
description
|
||||
title
|
||||
}
|
||||
fragment seo on SEO {
|
||||
description
|
||||
title
|
||||
}
|
||||
`;
|
||||
|
||||
export default seoFragment;
|
424
commerce/shopify/index.ts
Normal file
424
commerce/shopify/index.ts
Normal file
@ -0,0 +1,424 @@
|
||||
import {
|
||||
HIDDEN_PRODUCT_TAG,
|
||||
SHOPIFY_GRAPHQL_API_ENDPOINT,
|
||||
TAGS,
|
||||
} from 'commerce/constants';
|
||||
import { isShopifyError } from 'commerce/type-guards';
|
||||
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,
|
||||
Menu,
|
||||
Page,
|
||||
Product,
|
||||
ShopifyAddToCartOperation,
|
||||
ShopifyCart,
|
||||
ShopifyCartOperation,
|
||||
ShopifyCollection,
|
||||
ShopifyCollectionOperation,
|
||||
ShopifyCollectionProductsOperation,
|
||||
ShopifyCollectionsOperation,
|
||||
ShopifyCreateCartOperation,
|
||||
ShopifyMenuOperation,
|
||||
ShopifyPageOperation,
|
||||
ShopifyPagesOperation,
|
||||
ShopifyProduct,
|
||||
ShopifyProductOperation,
|
||||
ShopifyProductRecommendationsOperation,
|
||||
ShopifyProductsOperation,
|
||||
ShopifyRemoveFromCartOperation,
|
||||
ShopifyUpdateCartOperation,
|
||||
} from './types';
|
||||
|
||||
const domain = `https://${process.env.SHOPIFY_STORE_DOMAIN!}`;
|
||||
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 {
|
||||
status: e.status || 500,
|
||||
message: e.message,
|
||||
query,
|
||||
};
|
||||
}
|
||||
|
||||
throw {
|
||||
error: e,
|
||||
query,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const removeEdgesAndNodes = (array: Connection<any>) => {
|
||||
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 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: removeEdgesAndNodes(images),
|
||||
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): Promise<Cart | null> {
|
||||
const res = await shopifyFetch<ShopifyCartOperation>({
|
||||
query: getCartQuery,
|
||||
variables: { cartId },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.body.data.cart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
variables: { handle },
|
||||
});
|
||||
|
||||
return res.body.data.pageByHandle;
|
||||
}
|
||||
|
||||
export async function getPages(): Promise<Page[]> {
|
||||
const res = await shopifyFetch<ShopifyPagesOperation>({
|
||||
query: getPagesQuery,
|
||||
});
|
||||
|
||||
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));
|
||||
}
|
45
commerce/shopify/mutations/cart.ts
Normal file
45
commerce/shopify/mutations/cart.ts
Normal file
@ -0,0 +1,45 @@
|
||||
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}
|
||||
`;
|
10
commerce/shopify/queries/cart.ts
Normal file
10
commerce/shopify/queries/cart.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import cartFragment from '../fragments/cart';
|
||||
|
||||
export const getCartQuery = /* GraphQL */ `
|
||||
query getCart($cartId: ID!) {
|
||||
cart(id: $cartId) {
|
||||
...cart
|
||||
}
|
||||
}
|
||||
${cartFragment}
|
||||
`;
|
56
commerce/shopify/queries/collection.ts
Normal file
56
commerce/shopify/queries/collection.ts
Normal file
@ -0,0 +1,56 @@
|
||||
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}
|
||||
`;
|
10
commerce/shopify/queries/menu.ts
Normal file
10
commerce/shopify/queries/menu.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export const getMenuQuery = /* GraphQL */ `
|
||||
query getMenu($handle: String!) {
|
||||
menu(handle: $handle) {
|
||||
items {
|
||||
title
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
41
commerce/shopify/queries/page.ts
Normal file
41
commerce/shopify/queries/page.ts
Normal file
@ -0,0 +1,41 @@
|
||||
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}
|
||||
`;
|
41
commerce/shopify/queries/product.ts
Normal file
41
commerce/shopify/queries/product.ts
Normal file
@ -0,0 +1,41 @@
|
||||
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}
|
||||
`;
|
265
commerce/shopify/types.ts
Normal file
265
commerce/shopify/types.ts
Normal file
@ -0,0 +1,265 @@
|
||||
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;
|
||||
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;
|
||||
};
|
||||
};
|
30
commerce/type-guards.ts
Normal file
30
commerce/type-guards.ts
Normal file
@ -0,0 +1,30 @@
|
||||
export interface ShopifyErrorLike {
|
||||
status: number;
|
||||
message: Error;
|
||||
}
|
||||
|
||||
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 => {
|
||||
if (!isObject(error)) return false;
|
||||
|
||||
if (error instanceof Error) return true;
|
||||
|
||||
return findError(error);
|
||||
};
|
||||
|
||||
function findError<T extends object>(error: T): boolean {
|
||||
if (Object.prototype.toString.call(error) === '[object Error]') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(error) as T | null;
|
||||
|
||||
return prototype === null ? false : findError(prototype);
|
||||
}
|
11
commerce/utils.ts
Normal file
11
commerce/utils.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { ReadonlyURLSearchParams } from 'next/navigation';
|
||||
|
||||
export const createUrl = (
|
||||
pathname: string,
|
||||
params: URLSearchParams | ReadonlyURLSearchParams
|
||||
) => {
|
||||
const paramsString = params.toString();
|
||||
const queryString = `${paramsString.length ? '?' : ''}${paramsString}`;
|
||||
|
||||
return `${pathname}${queryString}`;
|
||||
};
|
@ -3,19 +3,15 @@ import 'server-only';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
|
||||
import { getCollectionProducts, getMenu } from 'lib/shopify';
|
||||
import { getCollectionProducts, getMenu } from 'commerce/shopify';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
import { PriceRanges } from '/components/price';
|
||||
import { getTags, listTags } from '/util';
|
||||
|
||||
export async function HomeProduct({ product }) {
|
||||
const typesMenu = await getMenu('types-nav');
|
||||
|
||||
const types = typesMenu?.map(item => /search\/(\w+)/.exec(item?.path)?.[1]);
|
||||
const featuredImage = product?.images?.[0];
|
||||
const collections = product?.collections?.nodes
|
||||
?.map(col => col?.title)
|
||||
?.filter(col => types?.includes(col?.toLowerCase()));
|
||||
const tags = await getTags({ product });
|
||||
|
||||
return (
|
||||
<Link
|
||||
@ -34,10 +30,8 @@ export async function HomeProduct({ product }) {
|
||||
</div>
|
||||
<div>
|
||||
<p className={styles.title}>{product?.title}</p>
|
||||
{collections && collections.length > 0 && (
|
||||
<p className={styles.collections}>{`(${collections.join(
|
||||
', '
|
||||
)})`}</p>
|
||||
{tags && tags.length > 0 && (
|
||||
<p className={styles.collections}>{listTags({ tags })}</p>
|
||||
)}
|
||||
</div>
|
||||
<PriceRanges product={product} />
|
||||
|
@ -2,12 +2,6 @@
|
||||
@use 'styles/_spacing';
|
||||
@use 'styles/_colors';
|
||||
|
||||
@mixin home-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(24, 1fr);
|
||||
column-gap: 10px;
|
||||
}
|
||||
|
||||
.homeNav {
|
||||
padding: (51px - spacing.$home-spacer-y) 115px 22px 115px;
|
||||
|
||||
@ -59,7 +53,7 @@
|
||||
}
|
||||
|
||||
.homeProductsList {
|
||||
@include home-grid;
|
||||
@include spacing.home-grid;
|
||||
|
||||
row-gap: 20px;
|
||||
padding-bottom: 364px;
|
||||
@ -132,7 +126,7 @@
|
||||
padding-top: 20px;
|
||||
padding-bottom: 30px;
|
||||
|
||||
@include home-grid;
|
||||
@include spacing.home-grid;
|
||||
|
||||
> p {
|
||||
@include typography.body;
|
||||
|
@ -70,12 +70,13 @@ export const VariantPrice = ({ variant, quantity }) => {
|
||||
const onSale = variantOnSale(variant);
|
||||
|
||||
return variant ? (
|
||||
<div>
|
||||
<div className={styles.variantPrice}>
|
||||
{availableForSale ? (
|
||||
<>
|
||||
<>
|
||||
{onSale && (
|
||||
<p className={'original-price'}>
|
||||
<p className={styles.originalPrice}>
|
||||
Retail:{' '}
|
||||
{formatPrice({
|
||||
amount:
|
||||
(variant?.compareAtPrice?.amount ?? 0) *
|
||||
@ -86,7 +87,7 @@ export const VariantPrice = ({ variant, quantity }) => {
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
<p>
|
||||
<p className={styles.actualPrice}>
|
||||
{formatPrice({
|
||||
amount: (variant?.price?.amount ?? 0) * quantity,
|
||||
currencyCode: variant?.price?.currencyCode,
|
||||
@ -94,8 +95,7 @@ export const VariantPrice = ({ variant, quantity }) => {
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
// TODO: this can just say "Sold Out" in the future
|
||||
<p>Variant Sold Out</p>
|
||||
<p className={styles.actualPrice}>Sold Out</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
@ -10,3 +10,15 @@
|
||||
text-decoration-line: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
.variantPrice {
|
||||
.originalPrice {
|
||||
@include typography.body;
|
||||
|
||||
text-decoration-line: line-through;
|
||||
}
|
||||
|
||||
.actualPrice {
|
||||
@include typography.title;
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
import { Option, Select, NumberInput } from '/components/input';
|
||||
import {
|
||||
productAvailableForSale,
|
||||
@ -41,6 +42,7 @@ export const productVariant = ({ product, selectedOptions }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: check availability against stock ?
|
||||
export default function PurchaseInput({ product }) {
|
||||
const hasOptions = productHasOptions(product);
|
||||
const isForSale = productIsForSale(product);
|
||||
@ -56,47 +58,73 @@ export default function PurchaseInput({ product }) {
|
||||
? productVariant({ product, selectedOptions })
|
||||
: product?.variants?.[0];
|
||||
|
||||
return availableForSale ? (
|
||||
isForSale && (
|
||||
<>
|
||||
<NumberInput
|
||||
min='1'
|
||||
value={qty}
|
||||
id='quantity'
|
||||
label='Qty'
|
||||
onChange={e => setQty(e.target.value)}
|
||||
/>
|
||||
<>
|
||||
{hasOptions &&
|
||||
product?.options?.map((option, i) => (
|
||||
<Select
|
||||
key={option?.id}
|
||||
id={option?.name}
|
||||
label={option?.name}
|
||||
value={selectedOptions[i]}
|
||||
onChange={e =>
|
||||
setSelectedOptions(
|
||||
selectedOptions.map((value, ii) =>
|
||||
i == ii ? e.target.value : value
|
||||
)
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={styles.purchaseInput}>
|
||||
{isForSale && (
|
||||
<div className={styles.topBottom}>
|
||||
<div>
|
||||
{availableForSale && (
|
||||
<>
|
||||
<NumberInput
|
||||
min='1'
|
||||
value={qty}
|
||||
id='quantity'
|
||||
label='Qty'
|
||||
onChange={e => setQty(e.target.value)}
|
||||
/>
|
||||
<>
|
||||
{hasOptions &&
|
||||
product?.options?.map((option, i) => (
|
||||
<Select
|
||||
key={option?.id}
|
||||
id={option?.name}
|
||||
label={option?.name}
|
||||
value={selectedOptions[i]}
|
||||
onChange={e =>
|
||||
setSelectedOptions(
|
||||
selectedOptions.map(
|
||||
(value, ii) =>
|
||||
i == ii
|
||||
? e.target
|
||||
.value
|
||||
: value
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
{option?.values?.map(value => (
|
||||
<Option
|
||||
key={value}
|
||||
value={value}
|
||||
>
|
||||
{value}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
))}
|
||||
</>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<VariantPrice variant={variant} quantity={qty} />
|
||||
<div className={styles.ctas}>
|
||||
{/* TODO: add to cart on click */}
|
||||
<button
|
||||
className={`${styles.buyNow} ${
|
||||
!availableForSale && styles.inactive
|
||||
}`}
|
||||
type='button'
|
||||
>
|
||||
{option?.values?.map(value => (
|
||||
<Option key={value} value={value}>
|
||||
{value}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
))}
|
||||
</>
|
||||
<VariantPrice variant={variant} quantity={qty} />
|
||||
{/* TODO: add to cart on click */}
|
||||
<button type='button'>Buy Now!</button>
|
||||
<Link href='/checkout'>Checkout?</Link>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<p>Sold Out</p>
|
||||
Buy Now!
|
||||
</button>
|
||||
<Link href='/checkout' className={styles.checkout}>
|
||||
Checkout?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
32
components/product/styles.module.scss
Normal file
32
components/product/styles.module.scss
Normal file
@ -0,0 +1,32 @@
|
||||
@use 'styles/_typography';
|
||||
@use 'styles/_spacing';
|
||||
|
||||
.purchaseInput {
|
||||
padding-left: spacing.$list-padding;
|
||||
height: 100%;
|
||||
|
||||
.topBottom {
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
.ctas {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 20px;
|
||||
|
||||
.buyNow,
|
||||
.checkout {
|
||||
@include typography.header-cta;
|
||||
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
|
||||
.buyNow.inactive {
|
||||
text-decoration-line: line-through;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
export type SortFilterItem = {
|
||||
title: string;
|
||||
slug: string | null;
|
||||
sortKey: 'RELEVANCE' | 'BEST_SELLING' | 'CREATED_AT' | 'PRICE';
|
||||
reverse: boolean;
|
||||
};
|
||||
|
||||
export const defaultSort: SortFilterItem = {
|
||||
title: 'Relevance',
|
||||
slug: null,
|
||||
sortKey: 'RELEVANCE',
|
||||
reverse: false
|
||||
};
|
||||
|
||||
export const sorting: SortFilterItem[] = [
|
||||
defaultSort,
|
||||
{ title: 'Trending', slug: 'trending-desc', sortKey: 'BEST_SELLING', reverse: false }, // asc
|
||||
{ title: 'Latest arrivals', slug: 'latest-desc', sortKey: 'CREATED_AT', reverse: true },
|
||||
{ title: 'Price: Low to high', slug: 'price-asc', sortKey: 'PRICE', reverse: false }, // asc
|
||||
{ title: 'Price: High to low', slug: 'price-desc', sortKey: 'PRICE', reverse: true }
|
||||
];
|
||||
|
||||
export const TAGS = {
|
||||
collections: 'collections',
|
||||
products: 'products'
|
||||
};
|
||||
|
||||
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
|
||||
export const DEFAULT_OPTION = 'Default Title';
|
||||
export const SHOPIFY_GRAPHQL_API_ENDPOINT = '/api/2023-01/graphql.json';
|
@ -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;
|
@ -1,10 +0,0 @@
|
||||
const imageFragment = /* GraphQL */ `
|
||||
fragment image on Image {
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
`;
|
||||
|
||||
export default imageFragment;
|
@ -1,396 +0,0 @@
|
||||
import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT, TAGS } from 'lib/constants';
|
||||
import { isShopifyError } from 'lib/type-guards';
|
||||
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,
|
||||
Menu,
|
||||
Page,
|
||||
Product,
|
||||
ShopifyAddToCartOperation,
|
||||
ShopifyCart,
|
||||
ShopifyCartOperation,
|
||||
ShopifyCollection,
|
||||
ShopifyCollectionOperation,
|
||||
ShopifyCollectionProductsOperation,
|
||||
ShopifyCollectionsOperation,
|
||||
ShopifyCreateCartOperation,
|
||||
ShopifyMenuOperation,
|
||||
ShopifyPageOperation,
|
||||
ShopifyPagesOperation,
|
||||
ShopifyProduct,
|
||||
ShopifyProductOperation,
|
||||
ShopifyProductRecommendationsOperation,
|
||||
ShopifyProductsOperation,
|
||||
ShopifyRemoveFromCartOperation,
|
||||
ShopifyUpdateCartOperation
|
||||
} from './types';
|
||||
|
||||
const domain = `https://${process.env.SHOPIFY_STORE_DOMAIN!}`;
|
||||
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 {
|
||||
status: e.status || 500,
|
||||
message: e.message,
|
||||
query
|
||||
};
|
||||
}
|
||||
|
||||
throw {
|
||||
error: e,
|
||||
query
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const removeEdgesAndNodes = (array: Connection<any>) => {
|
||||
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 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: removeEdgesAndNodes(images),
|
||||
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): Promise<Cart | null> {
|
||||
const res = await shopifyFetch<ShopifyCartOperation>({
|
||||
query: getCartQuery,
|
||||
variables: { cartId },
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (!res.body.data.cart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
variables: { handle }
|
||||
});
|
||||
|
||||
return res.body.data.pageByHandle;
|
||||
}
|
||||
|
||||
export async function getPages(): Promise<Page[]> {
|
||||
const res = await shopifyFetch<ShopifyPagesOperation>({
|
||||
query: getPagesQuery
|
||||
});
|
||||
|
||||
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));
|
||||
}
|
@ -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}
|
||||
`;
|
@ -1,10 +0,0 @@
|
||||
import cartFragment from '../fragments/cart';
|
||||
|
||||
export const getCartQuery = /* GraphQL */ `
|
||||
query getCart($cartId: ID!) {
|
||||
cart(id: $cartId) {
|
||||
...cart
|
||||
}
|
||||
}
|
||||
${cartFragment}
|
||||
`;
|
@ -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}
|
||||
`;
|
@ -1,10 +0,0 @@
|
||||
export const getMenuQuery = /* GraphQL */ `
|
||||
query getMenu($handle: String!) {
|
||||
menu(handle: $handle) {
|
||||
items {
|
||||
title
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
@ -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}
|
||||
`;
|
@ -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}
|
||||
`;
|
@ -1,265 +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;
|
||||
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;
|
||||
};
|
||||
};
|
@ -1,26 +0,0 @@
|
||||
export interface ShopifyErrorLike {
|
||||
status: number;
|
||||
message: Error;
|
||||
}
|
||||
|
||||
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 => {
|
||||
if (!isObject(error)) return false;
|
||||
|
||||
if (error instanceof Error) return true;
|
||||
|
||||
return findError(error);
|
||||
};
|
||||
|
||||
function findError<T extends object>(error: T): boolean {
|
||||
if (Object.prototype.toString.call(error) === '[object Error]') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(error) as T | null;
|
||||
|
||||
return prototype === null ? false : findError(prototype);
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
import { ReadonlyURLSearchParams } from 'next/navigation';
|
||||
|
||||
export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyURLSearchParams) => {
|
||||
const paramsString = params.toString();
|
||||
const queryString = `${paramsString.length ? '?' : ''}${paramsString}`;
|
||||
|
||||
return `${pathname}${queryString}`;
|
||||
};
|
@ -1,2 +1,11 @@
|
||||
$page-margin-x: 60px;
|
||||
$home-spacer-y: 13px + 12px;
|
||||
$grid-column-gap: 10px;
|
||||
$list-padding: 50px;
|
||||
$page-bottom-baseline: 40px;
|
||||
|
||||
@mixin home-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(24, 1fr);
|
||||
column-gap: $grid-column-gap;
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
@use 'styles/_spacing';
|
||||
|
||||
@mixin title {
|
||||
font-family: var(--font-century-nova);
|
||||
font-size: 95px;
|
||||
@ -37,6 +39,8 @@
|
||||
font-weight: 300;
|
||||
line-height: 30px; /* 120% */
|
||||
letter-spacing: -0.75px;
|
||||
text-decoration-thickness: 3%;
|
||||
text-underline-offset: 7%;
|
||||
|
||||
a,
|
||||
a:visited,
|
||||
@ -45,7 +49,6 @@
|
||||
font-weight: 100;
|
||||
letter-spacing: -0.25px;
|
||||
|
||||
text-decoration-line: underline;
|
||||
text-decoration-thickness: 3%;
|
||||
text-underline-offset: 7%;
|
||||
}
|
||||
@ -69,38 +72,74 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@mixin header-cta($decoration: underline) {
|
||||
@mixin header-cta {
|
||||
font-family: var(--font-century-nova);
|
||||
font-size: 35px;
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
line-height: 35px; /* 100% */
|
||||
letter-spacing: -1.4px;
|
||||
text-decoration-line: $decoration;
|
||||
|
||||
text-decoration-thickness: 5%;
|
||||
text-underline-offset: 7%;
|
||||
}
|
||||
|
||||
@mixin body-cta($decoration: underline) {
|
||||
@mixin body-cta {
|
||||
font-family: var(--font-dia);
|
||||
font-size: 25px;
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
line-height: 30px;
|
||||
letter-spacing: -0.25px;
|
||||
text-decoration-line: $decoration;
|
||||
|
||||
text-decoration-thickness: 4%;
|
||||
text-underline-offset: 4%;
|
||||
}
|
||||
|
||||
@mixin list-cta($decoration: underline) {
|
||||
@mixin list-cta {
|
||||
font-family: var(--font-dia);
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
line-height: 20px; /* 111.111% */
|
||||
text-transform: uppercase;
|
||||
text-decoration-line: $decoration;
|
||||
}
|
||||
|
||||
@mixin body-content {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@include subheader;
|
||||
}
|
||||
|
||||
p {
|
||||
@include body;
|
||||
|
||||
margin: spacing.$grid-column-gap 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
@include list;
|
||||
|
||||
padding-left: spacing.$list-padding;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: spacing.$grid-column-gap 0;
|
||||
}
|
||||
|
||||
span {
|
||||
text-decoration-line: underline;
|
||||
|
||||
text-decoration-thickness: 4%;
|
||||
text-underline-offset: 5%;
|
||||
}
|
||||
}
|
||||
|
14
util/index.js
Normal file
14
util/index.js
Normal file
@ -0,0 +1,14 @@
|
||||
import { getMenu } from 'commerce/shopify';
|
||||
|
||||
export const getTags = async ({ product }) => {
|
||||
const typesMenu = await getMenu('types-nav');
|
||||
|
||||
const types = typesMenu?.map(item => /search\/(\w+)/.exec(item?.path)?.[1]);
|
||||
const tags = product?.collections?.nodes
|
||||
?.map(col => col?.title)
|
||||
?.filter(col => types?.includes(col?.toLowerCase()));
|
||||
|
||||
return tags;
|
||||
};
|
||||
|
||||
export const listTags = ({ tags }) => `(${tags.join(', ')})`;
|
Loading…
x
Reference in New Issue
Block a user