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 } }) { ) : ( <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} + /> + ))} </> ); } 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 ( - <p> - {availableForSale ? ( - isForSale && ( - <> - <> - {onSale && ( - <span className={'original-price'}> - {formatPriceRange(compareAtPriceRange)}{' '} - </span> - )} - </> - <span>{formatPriceRange(priceRange)}</span> - </> - ) - ) : ( - <span>Sold Out</span> - )} - </p> - ); -}; +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(', ')})`}</p> )} - <PriceRanges {...product} /> + <PriceRanges product={product} /> </Link> ); } 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} </select> {/* TODO: parentheses around label w/ css */} - <label for={id}>{label}</label> + <label htmlFor={id}>{label}</label> </div> ); } @@ -18,7 +18,7 @@ export function NumberInput({ id, label, ...props }) { return ( <div> <input {...props} type='number' id={id} name={label} /> - <label for={id}>{label}</label> + <label htmlFor={id}>{label}</label> </div> ); } 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 ( + <p> + {availableForSale ? ( + isForSale && ( + <> + <> + {onSale && ( + <span className={'original-price'}> + {formatPriceRange( + product?.compareAtPriceRange + )}{' '} + </span> + )} + </> + <span>{formatPriceRange(product?.priceRange)}</span> + </> + ) + ) : ( + <span>Sold Out</span> + )} + </p> + ); +}; + +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 ? ( + <div> + {availableForSale ? ( + <> + <> + {onSale && ( + <p className={'original-price'}> + {formatPrice({ + amount: + (variant?.compareAtPrice?.amount ?? 0) * + quantity, + currencyCode: + variant?.compareAtPrice?.currencyCode, + })} + </p> + )} + </> + <p> + {formatPrice({ + amount: (variant?.price?.amount ?? 0) * quantity, + currencyCode: variant?.price?.currencyCode, + })} + </p> + </> + ) : ( + // TODO: this can just say "Sold Out" in the future + <p>Variant Sold Out</p> + )} + </div> + ) : ( + <p>Sorry, the price can't be found</p> + ); +}; 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 && ( <> - <NumberInput min='1' value='1' id='quantity' label='Qty' /> + <NumberInput + min='1' + value={qty} + id='quantity' + label='Qty' + onChange={e => setQty(e.target.value)} + /> <> {hasOptions && - product?.options?.map(option => ( + 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, i) => ( - <Option - key={value} - value={value} - selected={i == 0} - > + {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> ); } 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": {