mirror of
https://github.com/vercel/commerce.git
synced 2025-05-19 16:07:01 +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';
|
import { HomeProductsList } from '/components/home';
|
||||||
|
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
|
import styles from './styles.module.scss';
|
||||||
|
|
||||||
export default function PageLayout({ children }) {
|
export default function PageLayout({ children }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<main>{children}</main>
|
<main className={styles.main}>{children}</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,9 +2,77 @@ import Image from 'next/image';
|
|||||||
|
|
||||||
import xss from 'xss';
|
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 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() {
|
export async function generateStaticParams() {
|
||||||
const products = await getProducts({
|
const products = await getProducts({
|
||||||
@ -15,37 +83,3 @@ export async function generateStaticParams() {
|
|||||||
|
|
||||||
return products.map(product => ({ product: product.handle }));
|
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;
|
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 Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
import { getCollectionProducts, getMenu } from 'lib/shopify';
|
import { getCollectionProducts, getMenu } from 'commerce/shopify';
|
||||||
|
|
||||||
import styles from './styles.module.scss';
|
import styles from './styles.module.scss';
|
||||||
import { PriceRanges } from '/components/price';
|
import { PriceRanges } from '/components/price';
|
||||||
|
import { getTags, listTags } from '/util';
|
||||||
|
|
||||||
export async function HomeProduct({ product }) {
|
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 featuredImage = product?.images?.[0];
|
||||||
const collections = product?.collections?.nodes
|
const tags = await getTags({ product });
|
||||||
?.map(col => col?.title)
|
|
||||||
?.filter(col => types?.includes(col?.toLowerCase()));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@ -34,10 +30,8 @@ export async function HomeProduct({ product }) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className={styles.title}>{product?.title}</p>
|
<p className={styles.title}>{product?.title}</p>
|
||||||
{collections && collections.length > 0 && (
|
{tags && tags.length > 0 && (
|
||||||
<p className={styles.collections}>{`(${collections.join(
|
<p className={styles.collections}>{listTags({ tags })}</p>
|
||||||
', '
|
|
||||||
)})`}</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<PriceRanges product={product} />
|
<PriceRanges product={product} />
|
||||||
|
@ -2,12 +2,6 @@
|
|||||||
@use 'styles/_spacing';
|
@use 'styles/_spacing';
|
||||||
@use 'styles/_colors';
|
@use 'styles/_colors';
|
||||||
|
|
||||||
@mixin home-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(24, 1fr);
|
|
||||||
column-gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.homeNav {
|
.homeNav {
|
||||||
padding: (51px - spacing.$home-spacer-y) 115px 22px 115px;
|
padding: (51px - spacing.$home-spacer-y) 115px 22px 115px;
|
||||||
|
|
||||||
@ -59,7 +53,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.homeProductsList {
|
.homeProductsList {
|
||||||
@include home-grid;
|
@include spacing.home-grid;
|
||||||
|
|
||||||
row-gap: 20px;
|
row-gap: 20px;
|
||||||
padding-bottom: 364px;
|
padding-bottom: 364px;
|
||||||
@ -132,7 +126,7 @@
|
|||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
padding-bottom: 30px;
|
padding-bottom: 30px;
|
||||||
|
|
||||||
@include home-grid;
|
@include spacing.home-grid;
|
||||||
|
|
||||||
> p {
|
> p {
|
||||||
@include typography.body;
|
@include typography.body;
|
||||||
|
@ -70,12 +70,13 @@ export const VariantPrice = ({ variant, quantity }) => {
|
|||||||
const onSale = variantOnSale(variant);
|
const onSale = variantOnSale(variant);
|
||||||
|
|
||||||
return variant ? (
|
return variant ? (
|
||||||
<div>
|
<div className={styles.variantPrice}>
|
||||||
{availableForSale ? (
|
{availableForSale ? (
|
||||||
<>
|
<>
|
||||||
<>
|
<>
|
||||||
{onSale && (
|
{onSale && (
|
||||||
<p className={'original-price'}>
|
<p className={styles.originalPrice}>
|
||||||
|
Retail:{' '}
|
||||||
{formatPrice({
|
{formatPrice({
|
||||||
amount:
|
amount:
|
||||||
(variant?.compareAtPrice?.amount ?? 0) *
|
(variant?.compareAtPrice?.amount ?? 0) *
|
||||||
@ -86,7 +87,7 @@ export const VariantPrice = ({ variant, quantity }) => {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
<p>
|
<p className={styles.actualPrice}>
|
||||||
{formatPrice({
|
{formatPrice({
|
||||||
amount: (variant?.price?.amount ?? 0) * quantity,
|
amount: (variant?.price?.amount ?? 0) * quantity,
|
||||||
currencyCode: variant?.price?.currencyCode,
|
currencyCode: variant?.price?.currencyCode,
|
||||||
@ -94,8 +95,7 @@ export const VariantPrice = ({ variant, quantity }) => {
|
|||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
// TODO: this can just say "Sold Out" in the future
|
<p className={styles.actualPrice}>Sold Out</p>
|
||||||
<p>Variant Sold Out</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
@ -10,3 +10,15 @@
|
|||||||
text-decoration-line: line-through;
|
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 { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import styles from './styles.module.scss';
|
||||||
import { Option, Select, NumberInput } from '/components/input';
|
import { Option, Select, NumberInput } from '/components/input';
|
||||||
import {
|
import {
|
||||||
productAvailableForSale,
|
productAvailableForSale,
|
||||||
@ -41,6 +42,7 @@ export const productVariant = ({ product, selectedOptions }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TODO: check availability against stock ?
|
||||||
export default function PurchaseInput({ product }) {
|
export default function PurchaseInput({ product }) {
|
||||||
const hasOptions = productHasOptions(product);
|
const hasOptions = productHasOptions(product);
|
||||||
const isForSale = productIsForSale(product);
|
const isForSale = productIsForSale(product);
|
||||||
@ -56,8 +58,12 @@ export default function PurchaseInput({ product }) {
|
|||||||
? productVariant({ product, selectedOptions })
|
? productVariant({ product, selectedOptions })
|
||||||
: product?.variants?.[0];
|
: product?.variants?.[0];
|
||||||
|
|
||||||
return availableForSale ? (
|
return (
|
||||||
isForSale && (
|
<div className={styles.purchaseInput}>
|
||||||
|
{isForSale && (
|
||||||
|
<div className={styles.topBottom}>
|
||||||
|
<div>
|
||||||
|
{availableForSale && (
|
||||||
<>
|
<>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
min='1'
|
min='1'
|
||||||
@ -76,27 +82,49 @@ export default function PurchaseInput({ product }) {
|
|||||||
value={selectedOptions[i]}
|
value={selectedOptions[i]}
|
||||||
onChange={e =>
|
onChange={e =>
|
||||||
setSelectedOptions(
|
setSelectedOptions(
|
||||||
selectedOptions.map((value, ii) =>
|
selectedOptions.map(
|
||||||
i == ii ? e.target.value : value
|
(value, ii) =>
|
||||||
|
i == ii
|
||||||
|
? e.target
|
||||||
|
.value
|
||||||
|
: value
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{option?.values?.map(value => (
|
{option?.values?.map(value => (
|
||||||
<Option key={value} value={value}>
|
<Option
|
||||||
|
key={value}
|
||||||
|
value={value}
|
||||||
|
>
|
||||||
{value}
|
{value}
|
||||||
</Option>
|
</Option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
<VariantPrice variant={variant} quantity={qty} />
|
|
||||||
{/* TODO: add to cart on click */}
|
|
||||||
<button type='button'>Buy Now!</button>
|
|
||||||
<Link href='/checkout'>Checkout?</Link>
|
|
||||||
</>
|
</>
|
||||||
)
|
)}
|
||||||
) : (
|
</div>
|
||||||
<p>Sold Out</p>
|
<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'
|
||||||
|
>
|
||||||
|
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;
|
$page-margin-x: 60px;
|
||||||
$home-spacer-y: 13px + 12px;
|
$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 {
|
@mixin title {
|
||||||
font-family: var(--font-century-nova);
|
font-family: var(--font-century-nova);
|
||||||
font-size: 95px;
|
font-size: 95px;
|
||||||
@ -37,6 +39,8 @@
|
|||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 30px; /* 120% */
|
line-height: 30px; /* 120% */
|
||||||
letter-spacing: -0.75px;
|
letter-spacing: -0.75px;
|
||||||
|
text-decoration-thickness: 3%;
|
||||||
|
text-underline-offset: 7%;
|
||||||
|
|
||||||
a,
|
a,
|
||||||
a:visited,
|
a:visited,
|
||||||
@ -45,7 +49,6 @@
|
|||||||
font-weight: 100;
|
font-weight: 100;
|
||||||
letter-spacing: -0.25px;
|
letter-spacing: -0.25px;
|
||||||
|
|
||||||
text-decoration-line: underline;
|
|
||||||
text-decoration-thickness: 3%;
|
text-decoration-thickness: 3%;
|
||||||
text-underline-offset: 7%;
|
text-underline-offset: 7%;
|
||||||
}
|
}
|
||||||
@ -69,38 +72,74 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin header-cta($decoration: underline) {
|
@mixin header-cta {
|
||||||
font-family: var(--font-century-nova);
|
font-family: var(--font-century-nova);
|
||||||
font-size: 35px;
|
font-size: 35px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 100;
|
font-weight: 100;
|
||||||
line-height: 35px; /* 100% */
|
line-height: 35px; /* 100% */
|
||||||
letter-spacing: -1.4px;
|
letter-spacing: -1.4px;
|
||||||
text-decoration-line: $decoration;
|
|
||||||
|
|
||||||
text-decoration-thickness: 5%;
|
text-decoration-thickness: 5%;
|
||||||
text-underline-offset: 7%;
|
text-underline-offset: 7%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin body-cta($decoration: underline) {
|
@mixin body-cta {
|
||||||
font-family: var(--font-dia);
|
font-family: var(--font-dia);
|
||||||
font-size: 25px;
|
font-size: 25px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 100;
|
font-weight: 100;
|
||||||
line-height: 30px;
|
line-height: 30px;
|
||||||
letter-spacing: -0.25px;
|
letter-spacing: -0.25px;
|
||||||
text-decoration-line: $decoration;
|
|
||||||
|
|
||||||
text-decoration-thickness: 4%;
|
text-decoration-thickness: 4%;
|
||||||
text-underline-offset: 4%;
|
text-underline-offset: 4%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin list-cta($decoration: underline) {
|
@mixin list-cta {
|
||||||
font-family: var(--font-dia);
|
font-family: var(--font-dia);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 100;
|
font-weight: 100;
|
||||||
line-height: 20px; /* 111.111% */
|
line-height: 20px; /* 111.111% */
|
||||||
text-transform: uppercase;
|
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