From 55d289451b30c8f406cf823c0ddc14f509aadec2 Mon Sep 17 00:00:00 2001 From: andr-ew Date: Sat, 16 Sep 2023 16:48:34 -0500 Subject: [PATCH] product page: initial layout --- app/(home)/search/[collection]/page.js | 2 +- app/(page)/layout.js | 4 +- app/(page)/product/[handle]/page.js | 104 +++-- .../product/[handle]/styles.module.scss | 93 ++++ app/(page)/styles.module.scss | 5 + commerce/constants.ts | 50 +++ commerce/shopify/fragments/cart.ts | 53 +++ commerce/shopify/fragments/image.ts | 10 + .../shopify/fragments/product.ts | 0 {lib => commerce}/shopify/fragments/seo.ts | 8 +- commerce/shopify/index.ts | 424 ++++++++++++++++++ commerce/shopify/mutations/cart.ts | 45 ++ commerce/shopify/queries/cart.ts | 10 + commerce/shopify/queries/collection.ts | 56 +++ commerce/shopify/queries/menu.ts | 10 + commerce/shopify/queries/page.ts | 41 ++ commerce/shopify/queries/product.ts | 41 ++ commerce/shopify/types.ts | 265 +++++++++++ commerce/type-guards.ts | 30 ++ commerce/utils.ts | 11 + components/home/index.js | 16 +- components/home/styles.module.scss | 10 +- components/price/index.js | 10 +- components/price/styles.module.scss | 12 + components/product/purchase-input.js | 110 +++-- components/product/styles.module.scss | 32 ++ lib/constants.ts | 30 -- lib/shopify/fragments/cart.ts | 53 --- lib/shopify/fragments/image.ts | 10 - lib/shopify/index.ts | 396 ---------------- lib/shopify/mutations/cart.ts | 45 -- lib/shopify/queries/cart.ts | 10 - lib/shopify/queries/collection.ts | 56 --- lib/shopify/queries/menu.ts | 10 - lib/shopify/queries/page.ts | 41 -- lib/shopify/queries/product.ts | 32 -- lib/shopify/types.ts | 265 ----------- lib/type-guards.ts | 26 -- lib/utils.ts | 8 - styles/_spacing.scss | 9 + styles/_typography.scss | 53 ++- util/index.js | 14 + 42 files changed, 1415 insertions(+), 1095 deletions(-) create mode 100644 app/(page)/product/[handle]/styles.module.scss create mode 100644 app/(page)/styles.module.scss create mode 100644 commerce/constants.ts create mode 100644 commerce/shopify/fragments/cart.ts create mode 100644 commerce/shopify/fragments/image.ts rename {lib => commerce}/shopify/fragments/product.ts (100%) rename {lib => commerce}/shopify/fragments/seo.ts (50%) create mode 100644 commerce/shopify/index.ts create mode 100644 commerce/shopify/mutations/cart.ts create mode 100644 commerce/shopify/queries/cart.ts create mode 100644 commerce/shopify/queries/collection.ts create mode 100644 commerce/shopify/queries/menu.ts create mode 100644 commerce/shopify/queries/page.ts create mode 100644 commerce/shopify/queries/product.ts create mode 100644 commerce/shopify/types.ts create mode 100644 commerce/type-guards.ts create mode 100644 commerce/utils.ts create mode 100644 components/product/styles.module.scss delete mode 100644 lib/constants.ts delete mode 100644 lib/shopify/fragments/cart.ts delete mode 100644 lib/shopify/fragments/image.ts delete mode 100644 lib/shopify/index.ts delete mode 100644 lib/shopify/mutations/cart.ts delete mode 100644 lib/shopify/queries/cart.ts delete mode 100644 lib/shopify/queries/collection.ts delete mode 100644 lib/shopify/queries/menu.ts delete mode 100644 lib/shopify/queries/page.ts delete mode 100644 lib/shopify/queries/product.ts delete mode 100644 lib/shopify/types.ts delete mode 100644 lib/type-guards.ts delete mode 100644 lib/utils.ts create mode 100644 util/index.js diff --git a/app/(home)/search/[collection]/page.js b/app/(home)/search/[collection]/page.js index 8a2f510de..b13445aae 100644 --- a/app/(home)/search/[collection]/page.js +++ b/app/(home)/search/[collection]/page.js @@ -1,4 +1,4 @@ -import { getCollections } from 'lib/shopify'; +import { getCollections } from 'commerce/shopify'; import { HomeProductsList } from '/components/home'; diff --git a/app/(page)/layout.js b/app/(page)/layout.js index c0ebc6566..760809b04 100644 --- a/app/(page)/layout.js +++ b/app/(page)/layout.js @@ -1,7 +1,9 @@ +import styles from './styles.module.scss'; + export default function PageLayout({ children }) { return ( <> -
{children}
+
{children}
); } diff --git a/app/(page)/product/[handle]/page.js b/app/(page)/product/[handle]/page.js index aa43f46b3..825f91f37 100644 --- a/app/(page)/product/[handle]/page.js +++ b/app/(page)/product/[handle]/page.js @@ -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 }) => ( +
+
+ {images?.length > 1 && ( +

Scroll to right ( → )

+ )} +
+ {images?.map(image => ( + {image?.altText} + ))} +
+
+
+
+
+
+); + +const ProductPane = async ({ product }) => { + const tags = await getTags({ product }); + + return ( +
+ {product?.handle ? ( +
+
+

{product?.title}

+ {tags && tags.length > 0 && ( +

+ {listTags({ tags })} +

+ )} +
+
+ +
+ ) : ( +

Product not found

+ )} +
+ ); +}; + +export default async function ProductPage({ params: { handle } }) { + const product = await getProduct(handle); + + return ( +
+ + +
+ ); +} 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 ? ( - <> -

{product?.title}

-
- - - ) : ( -

Product not found

- )} -

Scroll to right ( → )

- {product?.images?.map(image => ( - {image?.altText} - ))} - - ); -} diff --git a/app/(page)/product/[handle]/styles.module.scss b/app/(page)/product/[handle]/styles.module.scss new file mode 100644 index 000000000..1729a7a74 --- /dev/null +++ b/app/(page)/product/[handle]/styles.module.scss @@ -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; +} diff --git a/app/(page)/styles.module.scss b/app/(page)/styles.module.scss new file mode 100644 index 000000000..60598a9fa --- /dev/null +++ b/app/(page)/styles.module.scss @@ -0,0 +1,5 @@ +@use 'styles/_spacing'; + +.main { + // padding: 0 spacing.$page-margin-x; +} diff --git a/commerce/constants.ts b/commerce/constants.ts new file mode 100644 index 000000000..2f965aa67 --- /dev/null +++ b/commerce/constants.ts @@ -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'; diff --git a/commerce/shopify/fragments/cart.ts b/commerce/shopify/fragments/cart.ts new file mode 100644 index 000000000..335128b9f --- /dev/null +++ b/commerce/shopify/fragments/cart.ts @@ -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; diff --git a/commerce/shopify/fragments/image.ts b/commerce/shopify/fragments/image.ts new file mode 100644 index 000000000..46b61a86d --- /dev/null +++ b/commerce/shopify/fragments/image.ts @@ -0,0 +1,10 @@ +const imageFragment = /* GraphQL */ ` + fragment image on Image { + url + altText + width + height + } +`; + +export default imageFragment; diff --git a/lib/shopify/fragments/product.ts b/commerce/shopify/fragments/product.ts similarity index 100% rename from lib/shopify/fragments/product.ts rename to commerce/shopify/fragments/product.ts diff --git a/lib/shopify/fragments/seo.ts b/commerce/shopify/fragments/seo.ts similarity index 50% rename from lib/shopify/fragments/seo.ts rename to commerce/shopify/fragments/seo.ts index 2d4786c4f..f41b8bcfd 100644 --- a/lib/shopify/fragments/seo.ts +++ b/commerce/shopify/fragments/seo.ts @@ -1,8 +1,8 @@ const seoFragment = /* GraphQL */ ` - fragment seo on SEO { - description - title - } + fragment seo on SEO { + description + title + } `; export default seoFragment; diff --git a/commerce/shopify/index.ts b/commerce/shopify/index.ts new file mode 100644 index 000000000..d04053fdb --- /dev/null +++ b/commerce/shopify/index.ts @@ -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 extends { variables: object } + ? T['variables'] + : never; + +export async function shopifyFetch({ + cache = 'force-cache', + headers, + query, + tags, + variables, +}: { + cache?: RequestCache; + headers?: HeadersInit; + query: string; + tags?: string[]; + variables?: ExtractVariables; +}): 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) => { + 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 { + const res = await shopifyFetch({ + query: createCartMutation, + cache: 'no-store', + }); + + return reshapeCart(res.body.data.cartCreate.cart); +} + +export async function addToCart( + cartId: string, + lines: { merchandiseId: string; quantity: number }[] +): Promise { + const res = await shopifyFetch({ + query: addToCartMutation, + variables: { + cartId, + lines, + }, + cache: 'no-store', + }); + return reshapeCart(res.body.data.cartLinesAdd.cart); +} + +export async function removeFromCart( + cartId: string, + lineIds: string[] +): Promise { + const res = await shopifyFetch({ + 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 { + const res = await shopifyFetch({ + query: editCartItemsMutation, + variables: { + cartId, + lines, + }, + cache: 'no-store', + }); + + return reshapeCart(res.body.data.cartLinesUpdate.cart); +} + +export async function getCart(cartId: string): Promise { + const res = await shopifyFetch({ + 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 { + const res = await shopifyFetch({ + 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 { + const res = await shopifyFetch({ + 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 { + const res = await shopifyFetch({ + 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 { + const res = await shopifyFetch({ + 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 { + const res = await shopifyFetch({ + query: getPageQuery, + variables: { handle }, + }); + + return res.body.data.pageByHandle; +} + +export async function getPages(): Promise { + const res = await shopifyFetch({ + query: getPagesQuery, + }); + + return removeEdgesAndNodes(res.body.data.pages); +} + +export async function getProduct(handle: string): Promise { + const res = await shopifyFetch({ + query: getProductQuery, + tags: [TAGS.products], + variables: { + handle, + }, + }); + + return reshapeProduct(res.body.data.product, false); +} + +export async function getProductRecommendations( + productId: string +): Promise { + const res = await shopifyFetch({ + 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 { + const res = await shopifyFetch({ + query: getProductsQuery, + tags: [TAGS.products], + variables: { + query, + reverse, + sortKey, + }, + }); + + return reshapeProducts(removeEdgesAndNodes(res.body.data.products)); +} diff --git a/commerce/shopify/mutations/cart.ts b/commerce/shopify/mutations/cart.ts new file mode 100644 index 000000000..f59aeb85b --- /dev/null +++ b/commerce/shopify/mutations/cart.ts @@ -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} +`; diff --git a/commerce/shopify/queries/cart.ts b/commerce/shopify/queries/cart.ts new file mode 100644 index 000000000..14054332a --- /dev/null +++ b/commerce/shopify/queries/cart.ts @@ -0,0 +1,10 @@ +import cartFragment from '../fragments/cart'; + +export const getCartQuery = /* GraphQL */ ` + query getCart($cartId: ID!) { + cart(id: $cartId) { + ...cart + } + } + ${cartFragment} +`; diff --git a/commerce/shopify/queries/collection.ts b/commerce/shopify/queries/collection.ts new file mode 100644 index 000000000..60bcfb427 --- /dev/null +++ b/commerce/shopify/queries/collection.ts @@ -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} +`; diff --git a/commerce/shopify/queries/menu.ts b/commerce/shopify/queries/menu.ts new file mode 100644 index 000000000..6401b794d --- /dev/null +++ b/commerce/shopify/queries/menu.ts @@ -0,0 +1,10 @@ +export const getMenuQuery = /* GraphQL */ ` + query getMenu($handle: String!) { + menu(handle: $handle) { + items { + title + url + } + } + } +`; diff --git a/commerce/shopify/queries/page.ts b/commerce/shopify/queries/page.ts new file mode 100644 index 000000000..0c50c9ec8 --- /dev/null +++ b/commerce/shopify/queries/page.ts @@ -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} +`; diff --git a/commerce/shopify/queries/product.ts b/commerce/shopify/queries/product.ts new file mode 100644 index 000000000..83901440d --- /dev/null +++ b/commerce/shopify/queries/product.ts @@ -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} +`; diff --git a/commerce/shopify/types.ts b/commerce/shopify/types.ts new file mode 100644 index 000000000..1cbc3be96 --- /dev/null +++ b/commerce/shopify/types.ts @@ -0,0 +1,265 @@ +export type Maybe = T | null; + +export type Connection = { + edges: Array>; +}; + +export type Edge = { + node: T; +}; + +export type Cart = Omit & { + 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 & { + 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; + 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; + featuredImage: Image; + images: Connection; + 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; + }; + }; + variables: { + handle: string; + reverse?: boolean; + sortKey?: string; + }; +}; + +export type ShopifyCollectionsOperation = { + data: { + collections: Connection; + }; +}; + +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; + }; +}; + +export type ShopifyProductOperation = { + data: { product: ShopifyProduct }; + variables: { + handle: string; + }; +}; + +export type ShopifyProductRecommendationsOperation = { + data: { + productRecommendations: ShopifyProduct[]; + }; + variables: { + productId: string; + }; +}; + +export type ShopifyProductsOperation = { + data: { + products: Connection; + }; + variables: { + query?: string; + reverse?: boolean; + sortKey?: string; + }; +}; diff --git a/commerce/type-guards.ts b/commerce/type-guards.ts new file mode 100644 index 000000000..0141a6970 --- /dev/null +++ b/commerce/type-guards.ts @@ -0,0 +1,30 @@ +export interface ShopifyErrorLike { + status: number; + message: Error; +} + +export const isObject = ( + object: unknown +): object is Record => { + 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(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); +} diff --git a/commerce/utils.ts b/commerce/utils.ts new file mode 100644 index 000000000..2d736e737 --- /dev/null +++ b/commerce/utils.ts @@ -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}`; +}; diff --git a/components/home/index.js b/components/home/index.js index 7b372436d..f2458b564 100644 --- a/components/home/index.js +++ b/components/home/index.js @@ -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 (

{product?.title}

- {collections && collections.length > 0 && ( -

{`(${collections.join( - ', ' - )})`}

+ {tags && tags.length > 0 && ( +

{listTags({ tags })}

)}
diff --git a/components/home/styles.module.scss b/components/home/styles.module.scss index f21fe93e3..806889eda 100644 --- a/components/home/styles.module.scss +++ b/components/home/styles.module.scss @@ -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; diff --git a/components/price/index.js b/components/price/index.js index 10ec2739a..fbf47a78b 100644 --- a/components/price/index.js +++ b/components/price/index.js @@ -70,12 +70,13 @@ export const VariantPrice = ({ variant, quantity }) => { const onSale = variantOnSale(variant); return variant ? ( -
+
{availableForSale ? ( <> <> {onSale && ( -

+

+ Retail:{' '} {formatPrice({ amount: (variant?.compareAtPrice?.amount ?? 0) * @@ -86,7 +87,7 @@ export const VariantPrice = ({ variant, quantity }) => {

)} -

+

{formatPrice({ amount: (variant?.price?.amount ?? 0) * quantity, currencyCode: variant?.price?.currencyCode, @@ -94,8 +95,7 @@ export const VariantPrice = ({ variant, quantity }) => {

) : ( - // TODO: this can just say "Sold Out" in the future -

Variant Sold Out

+

Sold Out

)}
) : ( diff --git a/components/price/styles.module.scss b/components/price/styles.module.scss index e2170f724..6d9e6d0c3 100644 --- a/components/price/styles.module.scss +++ b/components/price/styles.module.scss @@ -10,3 +10,15 @@ text-decoration-line: line-through; } } + +.variantPrice { + .originalPrice { + @include typography.body; + + text-decoration-line: line-through; + } + + .actualPrice { + @include typography.title; + } +} diff --git a/components/product/purchase-input.js b/components/product/purchase-input.js index 8eddb518d..4010ba3ab 100644 --- a/components/product/purchase-input.js +++ b/components/product/purchase-input.js @@ -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 && ( - <> - setQty(e.target.value)} - /> - <> - {hasOptions && - product?.options?.map((option, i) => ( - + setSelectedOptions( + selectedOptions.map( + (value, ii) => + i == ii + ? e.target + .value + : value + ) + ) + } + > + {option?.values?.map(value => ( + + ))} + + ))} + + + )} +
+
+ +
+ {/* TODO: add to cart on click */} + - Checkout? - - ) - ) : ( -

Sold Out

+ Buy Now! + + + Checkout? + +
+
+
+ )} +
); } diff --git a/components/product/styles.module.scss b/components/product/styles.module.scss new file mode 100644 index 000000000..4ac99fc8f --- /dev/null +++ b/components/product/styles.module.scss @@ -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; + } + } + } +} diff --git a/lib/constants.ts b/lib/constants.ts deleted file mode 100644 index 99711221a..000000000 --- a/lib/constants.ts +++ /dev/null @@ -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'; diff --git a/lib/shopify/fragments/cart.ts b/lib/shopify/fragments/cart.ts deleted file mode 100644 index fc5c838dd..000000000 --- a/lib/shopify/fragments/cart.ts +++ /dev/null @@ -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; diff --git a/lib/shopify/fragments/image.ts b/lib/shopify/fragments/image.ts deleted file mode 100644 index 5d002f175..000000000 --- a/lib/shopify/fragments/image.ts +++ /dev/null @@ -1,10 +0,0 @@ -const imageFragment = /* GraphQL */ ` - fragment image on Image { - url - altText - width - height - } -`; - -export default imageFragment; diff --git a/lib/shopify/index.ts b/lib/shopify/index.ts deleted file mode 100644 index 2e23a7f78..000000000 --- a/lib/shopify/index.ts +++ /dev/null @@ -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 extends { variables: object } ? T['variables'] : never; - -export async function shopifyFetch({ - cache = 'force-cache', - headers, - query, - tags, - variables -}: { - cache?: RequestCache; - headers?: HeadersInit; - query: string; - tags?: string[]; - variables?: ExtractVariables; -}): 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) => { - 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 { - const res = await shopifyFetch({ - query: createCartMutation, - cache: 'no-store' - }); - - return reshapeCart(res.body.data.cartCreate.cart); -} - -export async function addToCart( - cartId: string, - lines: { merchandiseId: string; quantity: number }[] -): Promise { - const res = await shopifyFetch({ - query: addToCartMutation, - variables: { - cartId, - lines - }, - cache: 'no-store' - }); - return reshapeCart(res.body.data.cartLinesAdd.cart); -} - -export async function removeFromCart(cartId: string, lineIds: string[]): Promise { - const res = await shopifyFetch({ - 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 { - const res = await shopifyFetch({ - query: editCartItemsMutation, - variables: { - cartId, - lines - }, - cache: 'no-store' - }); - - return reshapeCart(res.body.data.cartLinesUpdate.cart); -} - -export async function getCart(cartId: string): Promise { - const res = await shopifyFetch({ - 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 { - const res = await shopifyFetch({ - 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 { - const res = await shopifyFetch({ - 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 { - const res = await shopifyFetch({ - 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 { - const res = await shopifyFetch({ - 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 { - const res = await shopifyFetch({ - query: getPageQuery, - variables: { handle } - }); - - return res.body.data.pageByHandle; -} - -export async function getPages(): Promise { - const res = await shopifyFetch({ - query: getPagesQuery - }); - - return removeEdgesAndNodes(res.body.data.pages); -} - -export async function getProduct(handle: string): Promise { - const res = await shopifyFetch({ - query: getProductQuery, - tags: [TAGS.products], - variables: { - handle - } - }); - - return reshapeProduct(res.body.data.product, false); -} - -export async function getProductRecommendations(productId: string): Promise { - const res = await shopifyFetch({ - 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 { - const res = await shopifyFetch({ - query: getProductsQuery, - tags: [TAGS.products], - variables: { - query, - reverse, - sortKey - } - }); - - return reshapeProducts(removeEdgesAndNodes(res.body.data.products)); -} diff --git a/lib/shopify/mutations/cart.ts b/lib/shopify/mutations/cart.ts deleted file mode 100644 index 4cc1b5ac6..000000000 --- a/lib/shopify/mutations/cart.ts +++ /dev/null @@ -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} -`; diff --git a/lib/shopify/queries/cart.ts b/lib/shopify/queries/cart.ts deleted file mode 100644 index 044e47f66..000000000 --- a/lib/shopify/queries/cart.ts +++ /dev/null @@ -1,10 +0,0 @@ -import cartFragment from '../fragments/cart'; - -export const getCartQuery = /* GraphQL */ ` - query getCart($cartId: ID!) { - cart(id: $cartId) { - ...cart - } - } - ${cartFragment} -`; diff --git a/lib/shopify/queries/collection.ts b/lib/shopify/queries/collection.ts deleted file mode 100644 index 6396ff8eb..000000000 --- a/lib/shopify/queries/collection.ts +++ /dev/null @@ -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} -`; diff --git a/lib/shopify/queries/menu.ts b/lib/shopify/queries/menu.ts deleted file mode 100644 index d05b09949..000000000 --- a/lib/shopify/queries/menu.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const getMenuQuery = /* GraphQL */ ` - query getMenu($handle: String!) { - menu(handle: $handle) { - items { - title - url - } - } - } -`; diff --git a/lib/shopify/queries/page.ts b/lib/shopify/queries/page.ts deleted file mode 100644 index ac6f6f986..000000000 --- a/lib/shopify/queries/page.ts +++ /dev/null @@ -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} -`; diff --git a/lib/shopify/queries/product.ts b/lib/shopify/queries/product.ts deleted file mode 100644 index d3f12bd0f..000000000 --- a/lib/shopify/queries/product.ts +++ /dev/null @@ -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} -`; diff --git a/lib/shopify/types.ts b/lib/shopify/types.ts deleted file mode 100644 index 23dc02d46..000000000 --- a/lib/shopify/types.ts +++ /dev/null @@ -1,265 +0,0 @@ -export type Maybe = T | null; - -export type Connection = { - edges: Array>; -}; - -export type Edge = { - node: T; -}; - -export type Cart = Omit & { - 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 & { - 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; - 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; - featuredImage: Image; - images: Connection; - 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; - }; - }; - variables: { - handle: string; - reverse?: boolean; - sortKey?: string; - }; -}; - -export type ShopifyCollectionsOperation = { - data: { - collections: Connection; - }; -}; - -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; - }; -}; - -export type ShopifyProductOperation = { - data: { product: ShopifyProduct }; - variables: { - handle: string; - }; -}; - -export type ShopifyProductRecommendationsOperation = { - data: { - productRecommendations: ShopifyProduct[]; - }; - variables: { - productId: string; - }; -}; - -export type ShopifyProductsOperation = { - data: { - products: Connection; - }; - variables: { - query?: string; - reverse?: boolean; - sortKey?: string; - }; -}; diff --git a/lib/type-guards.ts b/lib/type-guards.ts deleted file mode 100644 index 1b7e7af5a..000000000 --- a/lib/type-guards.ts +++ /dev/null @@ -1,26 +0,0 @@ -export interface ShopifyErrorLike { - status: number; - message: Error; -} - -export const isObject = (object: unknown): object is Record => { - 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(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); -} diff --git a/lib/utils.ts b/lib/utils.ts deleted file mode 100644 index 3fa32280b..000000000 --- a/lib/utils.ts +++ /dev/null @@ -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}`; -}; diff --git a/styles/_spacing.scss b/styles/_spacing.scss index f4233362b..c35877d6c 100644 --- a/styles/_spacing.scss +++ b/styles/_spacing.scss @@ -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; +} diff --git a/styles/_typography.scss b/styles/_typography.scss index e068011bb..61f470a4f 100644 --- a/styles/_typography.scss +++ b/styles/_typography.scss @@ -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%; + } } diff --git a/util/index.js b/util/index.js new file mode 100644 index 000000000..436e901a1 --- /dev/null +++ b/util/index.js @@ -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(', ')})`;