From f7969b87e132de09e2da4da63b251cf28a38bb50 Mon Sep 17 00:00:00 2001 From: andr-ew Date: Sat, 24 Jun 2023 17:04:52 -0500 Subject: [PATCH] product: display price by variant selection, list images --- app/(page)/product/[handle]/page.js | 13 +++ components/home.js | 58 +------------- components/input.js | 4 +- components/price.js | 103 ++++++++++++++++++++++++ components/product/purchase-input.js | 113 ++++++++++++++++++++++----- lib/shopify/fragments/product.ts | 4 + package-lock.json | 6 ++ package.json | 1 + 8 files changed, 228 insertions(+), 74 deletions(-) create mode 100644 components/price.js diff --git a/app/(page)/product/[handle]/page.js b/app/(page)/product/[handle]/page.js index c4932e8f3..aa43f46b3 100644 --- a/app/(page)/product/[handle]/page.js +++ b/app/(page)/product/[handle]/page.js @@ -1,3 +1,5 @@ +import Image from 'next/image'; + import xss from 'xss'; import { getProducts, getProduct } from 'lib/shopify'; @@ -18,6 +20,7 @@ export async function generateStaticParams() { export default async function ProductPage({ params: { handle } }) { const product = await getProduct(handle); + return ( <> {product?.handle ? ( @@ -33,6 +36,16 @@ export default async function ProductPage({ params: { handle } }) { ) : (

Product not found

)} +

Scroll to right ( → )

+ {product?.images?.map(image => ( + {image?.altText} + ))} ); } diff --git a/components/home.js b/components/home.js index e6d7d5fa7..a5bf76c0e 100644 --- a/components/home.js +++ b/components/home.js @@ -1,61 +1,11 @@ +import 'server-only'; + import Link from 'next/link'; import Image from 'next/image'; import { getCollectionProducts, getMenu } from 'lib/shopify'; -export const formatPrice = ({ amount, currencyCode }) => { - const USDollar = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: currencyCode, - }); - - return USDollar.format(amount); -}; - -export const formatPriceRange = ({ maxVariantPrice, minVariantPrice }) => { - if (maxVariantPrice.amount == minVariantPrice.amount) { - return `${formatPrice(maxVariantPrice)}`; - } else { - return `${formatPrice(minVariantPrice)} - ${formatPrice( - maxVariantPrice - )}`; - } -}; - -export const PriceRanges = ({ - priceRange, - compareAtPriceRange, - availableForSale, -}) => { - //TODO: turn these checks into shared functions - const onSale = - (compareAtPriceRange?.minVariantPrice?.amount ?? 0) > - (priceRange?.minVariantPrice?.amount ?? 0) || - (compareAtPriceRange?.maxVariantPrice?.amount ?? 0) > - (priceRange?.maxVariantPrice?.amount ?? 0); - const isForSale = (priceRange?.maxVariantPrice?.amount ?? 0) > 0; - - return ( -

- {availableForSale ? ( - isForSale && ( - <> - <> - {onSale && ( - - {formatPriceRange(compareAtPriceRange)}{' '} - - )} - - {formatPriceRange(priceRange)} - - ) - ) : ( - Sold Out - )} -

- ); -}; +import { PriceRanges } from '/components/price.js'; export async function HomeProduct({ product }) { const featuredImage = product?.images?.[0]; @@ -75,7 +25,7 @@ export async function HomeProduct({ product }) { ?.map(collection => collection?.title) .join(', ')})`}

)} - + ); } diff --git a/components/input.js b/components/input.js index 92f4059d2..fd5777d11 100644 --- a/components/input.js +++ b/components/input.js @@ -9,7 +9,7 @@ export function Select({ id, label, children, ...props }) { {children} {/* TODO: parentheses around label w/ css */} - + ); } @@ -18,7 +18,7 @@ export function NumberInput({ id, label, ...props }) { return (
- +
); } diff --git a/components/price.js b/components/price.js new file mode 100644 index 000000000..152ac68b9 --- /dev/null +++ b/components/price.js @@ -0,0 +1,103 @@ +export const formatPrice = ({ amount, currencyCode }) => { + const USDollar = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currencyCode, + }); + + return USDollar.format(amount); +}; + +export const formatPriceRange = ({ maxVariantPrice, minVariantPrice }) => { + if (maxVariantPrice.amount == minVariantPrice.amount) { + return `${formatPrice(maxVariantPrice)}`; + } else { + return `${formatPrice(minVariantPrice)} - ${formatPrice( + maxVariantPrice + )}`; + } +}; + +//TODO: might be safer not to destructure keys from `product`, use nullish coalescing instead +export const productAvailableForSale = product => + product?.availableForSale ?? false; +export const productOnSale = product => + (product?.compareAtPriceRange?.minVariantPrice?.amount ?? 0) > + (product?.priceRange?.minVariantPrice?.amount ?? 0) || + (product?.compareAtPriceRange?.maxVariantPrice?.amount ?? 0) > + (product?.priceRange?.maxVariantPrice?.amount ?? 0); +export const productIsForSale = product => + (product?.priceRange?.maxVariantPrice?.amount ?? 0) > 0; +export const productHasOptions = product => + product?.options?.[0]?.values?.length > 1 ?? false; + +export const PriceRanges = ({ product }) => { + const availableForSale = productAvailableForSale(product); + const onSale = productOnSale(product); + const isForSale = productIsForSale(product); + + return ( +

+ {availableForSale ? ( + isForSale && ( + <> + <> + {onSale && ( + + {formatPriceRange( + product?.compareAtPriceRange + )}{' '} + + )} + + {formatPriceRange(product?.priceRange)} + + ) + ) : ( + Sold Out + )} +

+ ); +}; + +export const variantAvailableForSale = variant => + variant?.availableForSale ?? false; +export const variantOnSale = variant => + (variant?.compareAtPrice?.amount ?? 0) > (variant?.price?.amount ?? 0); + +export const VariantPrice = ({ variant, quantity }) => { + const availableForSale = variantAvailableForSale(variant); + const onSale = variantOnSale(variant); + + return variant ? ( +
+ {availableForSale ? ( + <> + <> + {onSale && ( +

+ {formatPrice({ + amount: + (variant?.compareAtPrice?.amount ?? 0) * + quantity, + currencyCode: + variant?.compareAtPrice?.currencyCode, + })} +

+ )} + +

+ {formatPrice({ + amount: (variant?.price?.amount ?? 0) * quantity, + currencyCode: variant?.price?.currencyCode, + })} +

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

Variant Sold Out

+ )} +
+ ) : ( +

Sorry, the price can't be found

+ ); +}; diff --git a/components/product/purchase-input.js b/components/product/purchase-input.js index 212cbc37d..afd14b320 100644 --- a/components/product/purchase-input.js +++ b/components/product/purchase-input.js @@ -1,43 +1,120 @@ 'use client'; +import { useState } from 'react'; +import Link from 'next/link'; + import { Option, Select, NumberInput } from '/components/input.js'; +import { + productAvailableForSale, + productHasOptions, + productIsForSale, + VariantPrice, +} from '/components/price.js'; + +export const productVariant = ({ product, selectedOptions }) => { + const hasOptions = productHasOptions(product); + + if (hasOptions) { + const optionNames = + product?.options?.map(option => option?.name ?? '') ?? []; + + console.log({ + product: product?.handle, + optionNames, + variants: product?.variants, + }); + + for (const variant of product?.variants ?? []) { + let matching = true; + + console.log({ variantTitle: variant?.title }); + + for (const option of variant?.selectedOptions) { + const optionName = option?.name ?? ''; + const optionValue = option?.value ?? ''; + + console.log({ optionName, optionValue, optionNames }); + + for (let i = 0; i < optionNames?.length; i++) { + if (optionName == optionNames[i]) { + console.log({ + optionName, + optionValue, + selectedOption: selectedOptions[i], + }); + + if (optionValue != selectedOptions[i]) { + matching = false; + } + } + } + } + + if (matching) { + return variant; + } + } + } +}; export default function PurchaseInput({ product }) { - const hasOptions = product?.options?.[0]?.values.length > 1 ?? false; - //TODO: turn these checks into shared functions - // const onSale = - // (compareAtPriceRange?.minVariantPrice?.amount ?? 0) > - // (priceRange?.minVariantPrice?.amount ?? 0) || - // (compareAtPriceRange?.maxVariantPrice?.amount ?? 0) > - // (priceRange?.maxVariantPrice?.amount ?? 0); - const isForSale = (product?.priceRange?.maxVariantPrice?.amount ?? 0) > 0; + const hasOptions = productHasOptions(product); + const isForSale = productIsForSale(product); + const availableForSale = productAvailableForSale(product); - return ( - product?.availableForSale && + const [qty, setQty] = useState(1); + + const [selectedOptions, setSelectedOptions] = useState( + product?.options?.map(option => option?.values?.[0] ?? '') ?? [] + ); + + const variant = hasOptions + ? productVariant({ product, selectedOptions }) + : product?.variants?.[0]; + + console.log({ variant }); + + return availableForSale ? ( isForSale && ( <> - + setQty(e.target.value)} + /> <> {hasOptions && - product?.options?.map(option => ( + product?.options?.map((option, i) => ( ))} + + {/* TODO: add to cart on click */} + + Checkout? ) + ) : ( +

Sold Out

); } diff --git a/lib/shopify/fragments/product.ts b/lib/shopify/fragments/product.ts index e6ea44cf4..c4df506ae 100644 --- a/lib/shopify/fragments/product.ts +++ b/lib/shopify/fragments/product.ts @@ -38,6 +38,10 @@ const productFragment = /* GraphQL */ ` amount currencyCode } + compareAtPrice { + amount + currencyCode + } } } } diff --git a/package-lock.json b/package-lock.json index 74feeadb4..eededf34d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "react": "18.2.0", "react-cookie": "^4.1.1", "react-dom": "18.2.0", + "server-only": "^0.0.1", "xss": "^1.0.14" }, "devDependencies": { @@ -5157,6 +5158,11 @@ "node": ">=10" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index 669b9196f..f56cb15d3 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "react": "18.2.0", "react-cookie": "^4.1.1", "react-dom": "18.2.0", + "server-only": "^0.0.1", "xss": "^1.0.14" }, "devDependencies": {