product page: initial layout

This commit is contained in:
andr-ew 2023-09-16 16:48:34 -05:00
parent 0eac0c50cb
commit 55d289451b
42 changed files with 1415 additions and 1095 deletions

View File

@ -1,4 +1,4 @@
import { getCollections } from 'lib/shopify';
import { getCollections } from 'commerce/shopify';
import { HomeProductsList } from '/components/home';

View File

@ -1,7 +1,9 @@
import styles from './styles.module.scss';
export default function PageLayout({ children }) {
return (
<>
<main>{children}</main>
<main className={styles.main}>{children}</main>
</>
);
}

View File

@ -2,9 +2,77 @@ import Image from 'next/image';
import xss from 'xss';
import { getProducts, getProduct } from 'lib/shopify';
import { getProducts, getProduct } from 'commerce/shopify';
import styles from './styles.module.scss';
import PurchaseInput from '/components/product/purchase-input.js';
import { getTags, listTags } from '/util';
//TODO: NumberInput
const ImageScroll = ({ images }) => (
<div className={styles.imageScroll}>
<div className={styles.horizScroll}>
{images?.length > 1 && (
<p className={styles.scrollMessage}>Scroll to right ( )</p>
)}
<div className={styles.imageContainer}>
{images?.map(image => (
<Image
key={image?.url}
src={image?.url}
alt={image?.altText}
width={image?.width}
height={image?.height}
/>
))}
<div>
<div className={styles.spacer} />
</div>
</div>
</div>
</div>
);
const ProductPane = async ({ product }) => {
const tags = await getTags({ product });
return (
<div className={styles.productPane}>
{product?.handle ? (
<div className={styles.topBottom}>
<div className={styles.description}>
<h1>{product?.title}</h1>
{tags && tags.length > 0 && (
<h2 className={styles.collections}>
{listTags({ tags })}
</h2>
)}
<div
dangerouslySetInnerHTML={{
__html: xss(product.descriptionHtml),
}}
/>
</div>
<PurchaseInput product={product} />
</div>
) : (
<p>Product not found</p>
)}
</div>
);
};
export default async function ProductPage({ params: { handle } }) {
const product = await getProduct(handle);
return (
<div className={styles.productPage}>
<ImageScroll images={product.images} />
<ProductPane {...{ product }} />
</div>
);
}
export async function generateStaticParams() {
const products = await getProducts({
@ -15,37 +83,3 @@ export async function generateStaticParams() {
return products.map(product => ({ product: product.handle }));
}
//TODO: NumberInput
export default async function ProductPage({ params: { handle } }) {
const product = await getProduct(handle);
return (
<>
{product?.handle ? (
<>
<h1>{product?.title}</h1>
<div
dangerouslySetInnerHTML={{
__html: xss(product.descriptionHtml),
}}
/>
<PurchaseInput product={product} />
</>
) : (
<p>Product not found</p>
)}
<p>Scroll to right ( )</p>
{product?.images?.map(image => (
<Image
key={image?.url}
src={image?.url}
alt={image?.altText}
width={image?.width}
height={image?.height}
/>
))}
</>
);
}

View 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;
}

View File

@ -0,0 +1,5 @@
@use 'styles/_spacing';
.main {
// padding: 0 spacing.$page-margin-x;
}

50
commerce/constants.ts Normal file
View 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';

View 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;

View File

@ -0,0 +1,10 @@
const imageFragment = /* GraphQL */ `
fragment image on Image {
url
altText
width
height
}
`;
export default imageFragment;

View File

@ -1,8 +1,8 @@
const seoFragment = /* GraphQL */ `
fragment seo on SEO {
description
title
}
fragment seo on SEO {
description
title
}
`;
export default seoFragment;

424
commerce/shopify/index.ts Normal file
View 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));
}

View 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}
`;

View File

@ -0,0 +1,10 @@
import cartFragment from '../fragments/cart';
export const getCartQuery = /* GraphQL */ `
query getCart($cartId: ID!) {
cart(id: $cartId) {
...cart
}
}
${cartFragment}
`;

View 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}
`;

View File

@ -0,0 +1,10 @@
export const getMenuQuery = /* GraphQL */ `
query getMenu($handle: String!) {
menu(handle: $handle) {
items {
title
url
}
}
}
`;

View 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}
`;

View 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
View 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
View 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
View 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}`;
};

View File

@ -3,19 +3,15 @@ import 'server-only';
import Link from 'next/link';
import Image from 'next/image';
import { getCollectionProducts, getMenu } from 'lib/shopify';
import { getCollectionProducts, getMenu } from 'commerce/shopify';
import styles from './styles.module.scss';
import { PriceRanges } from '/components/price';
import { getTags, listTags } from '/util';
export async function HomeProduct({ product }) {
const typesMenu = await getMenu('types-nav');
const types = typesMenu?.map(item => /search\/(\w+)/.exec(item?.path)?.[1]);
const featuredImage = product?.images?.[0];
const collections = product?.collections?.nodes
?.map(col => col?.title)
?.filter(col => types?.includes(col?.toLowerCase()));
const tags = await getTags({ product });
return (
<Link
@ -34,10 +30,8 @@ export async function HomeProduct({ product }) {
</div>
<div>
<p className={styles.title}>{product?.title}</p>
{collections && collections.length > 0 && (
<p className={styles.collections}>{`(${collections.join(
', '
)})`}</p>
{tags && tags.length > 0 && (
<p className={styles.collections}>{listTags({ tags })}</p>
)}
</div>
<PriceRanges product={product} />

View File

@ -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;

View File

@ -70,12 +70,13 @@ export const VariantPrice = ({ variant, quantity }) => {
const onSale = variantOnSale(variant);
return variant ? (
<div>
<div className={styles.variantPrice}>
{availableForSale ? (
<>
<>
{onSale && (
<p className={'original-price'}>
<p className={styles.originalPrice}>
Retail:{' '}
{formatPrice({
amount:
(variant?.compareAtPrice?.amount ?? 0) *
@ -86,7 +87,7 @@ export const VariantPrice = ({ variant, quantity }) => {
</p>
)}
</>
<p>
<p className={styles.actualPrice}>
{formatPrice({
amount: (variant?.price?.amount ?? 0) * quantity,
currencyCode: variant?.price?.currencyCode,
@ -94,8 +95,7 @@ export const VariantPrice = ({ variant, quantity }) => {
</p>
</>
) : (
// TODO: this can just say "Sold Out" in the future
<p>Variant Sold Out</p>
<p className={styles.actualPrice}>Sold Out</p>
)}
</div>
) : (

View File

@ -10,3 +10,15 @@
text-decoration-line: line-through;
}
}
.variantPrice {
.originalPrice {
@include typography.body;
text-decoration-line: line-through;
}
.actualPrice {
@include typography.title;
}
}

View File

@ -3,6 +3,7 @@
import { useState } from 'react';
import Link from 'next/link';
import styles from './styles.module.scss';
import { Option, Select, NumberInput } from '/components/input';
import {
productAvailableForSale,
@ -41,6 +42,7 @@ export const productVariant = ({ product, selectedOptions }) => {
}
};
// TODO: check availability against stock ?
export default function PurchaseInput({ product }) {
const hasOptions = productHasOptions(product);
const isForSale = productIsForSale(product);
@ -56,47 +58,73 @@ export default function PurchaseInput({ product }) {
? productVariant({ product, selectedOptions })
: product?.variants?.[0];
return availableForSale ? (
isForSale && (
<>
<NumberInput
min='1'
value={qty}
id='quantity'
label='Qty'
onChange={e => setQty(e.target.value)}
/>
<>
{hasOptions &&
product?.options?.map((option, i) => (
<Select
key={option?.id}
id={option?.name}
label={option?.name}
value={selectedOptions[i]}
onChange={e =>
setSelectedOptions(
selectedOptions.map((value, ii) =>
i == ii ? e.target.value : value
)
)
}
return (
<div className={styles.purchaseInput}>
{isForSale && (
<div className={styles.topBottom}>
<div>
{availableForSale && (
<>
<NumberInput
min='1'
value={qty}
id='quantity'
label='Qty'
onChange={e => setQty(e.target.value)}
/>
<>
{hasOptions &&
product?.options?.map((option, i) => (
<Select
key={option?.id}
id={option?.name}
label={option?.name}
value={selectedOptions[i]}
onChange={e =>
setSelectedOptions(
selectedOptions.map(
(value, ii) =>
i == ii
? e.target
.value
: value
)
)
}
>
{option?.values?.map(value => (
<Option
key={value}
value={value}
>
{value}
</Option>
))}
</Select>
))}
</>
</>
)}
</div>
<div>
<VariantPrice variant={variant} quantity={qty} />
<div className={styles.ctas}>
{/* TODO: add to cart on click */}
<button
className={`${styles.buyNow} ${
!availableForSale && styles.inactive
}`}
type='button'
>
{option?.values?.map(value => (
<Option key={value} value={value}>
{value}
</Option>
))}
</Select>
))}
</>
<VariantPrice variant={variant} quantity={qty} />
{/* TODO: add to cart on click */}
<button type='button'>Buy Now!</button>
<Link href='/checkout'>Checkout?</Link>
</>
)
) : (
<p>Sold Out</p>
Buy Now!
</button>
<Link href='/checkout' className={styles.checkout}>
Checkout?
</Link>
</div>
</div>
</div>
)}
</div>
);
}

View 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;
}
}
}
}

View File

@ -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';

View File

@ -1,53 +0,0 @@
import productFragment from './product';
const cartFragment = /* GraphQL */ `
fragment cart on Cart {
id
checkoutUrl
cost {
subtotalAmount {
amount
currencyCode
}
totalAmount {
amount
currencyCode
}
totalTaxAmount {
amount
currencyCode
}
}
lines(first: 100) {
edges {
node {
id
quantity
cost {
totalAmount {
amount
currencyCode
}
}
merchandise {
... on ProductVariant {
id
title
selectedOptions {
name
value
}
product {
...product
}
}
}
}
}
}
totalQuantity
}
${productFragment}
`;
export default cartFragment;

View File

@ -1,10 +0,0 @@
const imageFragment = /* GraphQL */ `
fragment image on Image {
url
altText
width
height
}
`;
export default imageFragment;

View File

@ -1,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));
}

View File

@ -1,45 +0,0 @@
import cartFragment from '../fragments/cart';
export const addToCartMutation = /* GraphQL */ `
mutation addToCart($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const createCartMutation = /* GraphQL */ `
mutation createCart($lineItems: [CartLineInput!]) {
cartCreate(input: { lines: $lineItems }) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const editCartItemsMutation = /* GraphQL */ `
mutation editCartItems($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
cartLinesUpdate(cartId: $cartId, lines: $lines) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const removeFromCartMutation = /* GraphQL */ `
mutation removeFromCart($cartId: ID!, $lineIds: [ID!]!) {
cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
cart {
...cart
}
}
}
${cartFragment}
`;

View File

@ -1,10 +0,0 @@
import cartFragment from '../fragments/cart';
export const getCartQuery = /* GraphQL */ `
query getCart($cartId: ID!) {
cart(id: $cartId) {
...cart
}
}
${cartFragment}
`;

View File

@ -1,56 +0,0 @@
import productFragment from '../fragments/product';
import seoFragment from '../fragments/seo';
const collectionFragment = /* GraphQL */ `
fragment collection on Collection {
handle
title
description
seo {
...seo
}
updatedAt
}
${seoFragment}
`;
export const getCollectionQuery = /* GraphQL */ `
query getCollection($handle: String!) {
collection(handle: $handle) {
...collection
}
}
${collectionFragment}
`;
export const getCollectionsQuery = /* GraphQL */ `
query getCollections {
collections(first: 100, sortKey: TITLE) {
edges {
node {
...collection
}
}
}
}
${collectionFragment}
`;
export const getCollectionProductsQuery = /* GraphQL */ `
query getCollectionProducts(
$handle: String!
$sortKey: ProductCollectionSortKeys
$reverse: Boolean
) {
collection(handle: $handle) {
products(sortKey: $sortKey, reverse: $reverse, first: 100) {
edges {
node {
...product
}
}
}
}
}
${productFragment}
`;

View File

@ -1,10 +0,0 @@
export const getMenuQuery = /* GraphQL */ `
query getMenu($handle: String!) {
menu(handle: $handle) {
items {
title
url
}
}
}
`;

View File

@ -1,41 +0,0 @@
import seoFragment from '../fragments/seo';
const pageFragment = /* GraphQL */ `
fragment page on Page {
... on Page {
id
title
handle
body
bodySummary
seo {
...seo
}
createdAt
updatedAt
}
}
${seoFragment}
`;
export const getPageQuery = /* GraphQL */ `
query getPage($handle: String!) {
pageByHandle(handle: $handle) {
...page
}
}
${pageFragment}
`;
export const getPagesQuery = /* GraphQL */ `
query getPages {
pages(first: 100) {
edges {
node {
...page
}
}
}
}
${pageFragment}
`;

View File

@ -1,32 +0,0 @@
import productFragment from '../fragments/product';
export const getProductQuery = /* GraphQL */ `
query getProduct($handle: String!) {
product(handle: $handle) {
...product
}
}
${productFragment}
`;
export const getProductsQuery = /* GraphQL */ `
query getProducts($sortKey: ProductSortKeys, $reverse: Boolean, $query: String) {
products(sortKey: $sortKey, reverse: $reverse, query: $query, first: 100) {
edges {
node {
...product
}
}
}
}
${productFragment}
`;
export const getProductRecommendationsQuery = /* GraphQL */ `
query getProductRecommendations($productId: ID!) {
productRecommendations(productId: $productId) {
...product
}
}
${productFragment}
`;

View File

@ -1,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;
};
};

View File

@ -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);
}

View File

@ -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}`;
};

View File

@ -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;
}

View File

@ -1,3 +1,5 @@
@use 'styles/_spacing';
@mixin title {
font-family: var(--font-century-nova);
font-size: 95px;
@ -37,6 +39,8 @@
font-weight: 300;
line-height: 30px; /* 120% */
letter-spacing: -0.75px;
text-decoration-thickness: 3%;
text-underline-offset: 7%;
a,
a:visited,
@ -45,7 +49,6 @@
font-weight: 100;
letter-spacing: -0.25px;
text-decoration-line: underline;
text-decoration-thickness: 3%;
text-underline-offset: 7%;
}
@ -69,38 +72,74 @@
text-transform: uppercase;
}
@mixin header-cta($decoration: underline) {
@mixin header-cta {
font-family: var(--font-century-nova);
font-size: 35px;
font-style: normal;
font-weight: 100;
line-height: 35px; /* 100% */
letter-spacing: -1.4px;
text-decoration-line: $decoration;
text-decoration-thickness: 5%;
text-underline-offset: 7%;
}
@mixin body-cta($decoration: underline) {
@mixin body-cta {
font-family: var(--font-dia);
font-size: 25px;
font-style: normal;
font-weight: 100;
line-height: 30px;
letter-spacing: -0.25px;
text-decoration-line: $decoration;
text-decoration-thickness: 4%;
text-underline-offset: 4%;
}
@mixin list-cta($decoration: underline) {
@mixin list-cta {
font-family: var(--font-dia);
font-size: 18px;
font-style: normal;
font-weight: 100;
line-height: 20px; /* 111.111% */
text-transform: uppercase;
text-decoration-line: $decoration;
}
@mixin body-content {
h1,
h2,
h3,
h4,
h5,
h6 {
@include subheader;
}
p {
@include body;
margin: spacing.$grid-column-gap 0;
}
ul {
list-style: disc;
}
ul,
ol {
@include list;
padding-left: spacing.$list-padding;
}
li {
margin: spacing.$grid-column-gap 0;
}
span {
text-decoration-line: underline;
text-decoration-thickness: 4%;
text-underline-offset: 5%;
}
}

14
util/index.js Normal file
View 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(', ')})`;