product: display price by variant selection, list images

This commit is contained in:
andr-ew 2023-06-24 17:04:52 -05:00
parent 824070a54b
commit f7969b87e1
8 changed files with 228 additions and 74 deletions

View File

@ -1,3 +1,5 @@
import Image from 'next/image';
import xss from 'xss'; import xss from 'xss';
import { getProducts, getProduct } from 'lib/shopify'; import { getProducts, getProduct } from 'lib/shopify';
@ -18,6 +20,7 @@ export async function generateStaticParams() {
export default async function ProductPage({ params: { handle } }) { export default async function ProductPage({ params: { handle } }) {
const product = await getProduct(handle); const product = await getProduct(handle);
return ( return (
<> <>
{product?.handle ? ( {product?.handle ? (
@ -33,6 +36,16 @@ export default async function ProductPage({ params: { handle } }) {
) : ( ) : (
<p>Product not found</p> <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

@ -1,61 +1,11 @@
import 'server-only';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { getCollectionProducts, getMenu } from 'lib/shopify'; import { getCollectionProducts, getMenu } from 'lib/shopify';
export const formatPrice = ({ amount, currencyCode }) => { import { PriceRanges } from '/components/price.js';
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>
);
};
export async function HomeProduct({ product }) { export async function HomeProduct({ product }) {
const featuredImage = product?.images?.[0]; const featuredImage = product?.images?.[0];
@ -75,7 +25,7 @@ export async function HomeProduct({ product }) {
?.map(collection => collection?.title) ?.map(collection => collection?.title)
.join(', ')})`}</p> .join(', ')})`}</p>
)} )}
<PriceRanges {...product} /> <PriceRanges product={product} />
</Link> </Link>
); );
} }

View File

@ -9,7 +9,7 @@ export function Select({ id, label, children, ...props }) {
{children} {children}
</select> </select>
{/* TODO: parentheses around label w/ css */} {/* TODO: parentheses around label w/ css */}
<label for={id}>{label}</label> <label htmlFor={id}>{label}</label>
</div> </div>
); );
} }
@ -18,7 +18,7 @@ export function NumberInput({ id, label, ...props }) {
return ( return (
<div> <div>
<input {...props} type='number' id={id} name={label} /> <input {...props} type='number' id={id} name={label} />
<label for={id}>{label}</label> <label htmlFor={id}>{label}</label>
</div> </div>
); );
} }

103
components/price.js Normal file
View File

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

View File

@ -1,43 +1,120 @@
'use client'; 'use client';
import { useState } from 'react';
import Link from 'next/link';
import { Option, Select, NumberInput } from '/components/input.js'; 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 }) { export default function PurchaseInput({ product }) {
const hasOptions = product?.options?.[0]?.values.length > 1 ?? false; const hasOptions = productHasOptions(product);
//TODO: turn these checks into shared functions const isForSale = productIsForSale(product);
// const onSale = const availableForSale = productAvailableForSale(product);
// (compareAtPriceRange?.minVariantPrice?.amount ?? 0) >
// (priceRange?.minVariantPrice?.amount ?? 0) ||
// (compareAtPriceRange?.maxVariantPrice?.amount ?? 0) >
// (priceRange?.maxVariantPrice?.amount ?? 0);
const isForSale = (product?.priceRange?.maxVariantPrice?.amount ?? 0) > 0;
return ( const [qty, setQty] = useState(1);
product?.availableForSale &&
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 && ( 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 && {hasOptions &&
product?.options?.map(option => ( product?.options?.map((option, i) => (
<Select <Select
key={option?.id} key={option?.id}
id={option?.name} id={option?.name}
label={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?.values?.map(value => (
<Option <Option key={value} value={value}>
key={value}
value={value}
selected={i == 0}
>
{value} {value}
</Option> </Option>
))} ))}
</Select> </Select>
))} ))}
</> </>
<VariantPrice variant={variant} quantity={qty} />
{/* TODO: add to cart on click */}
<button type='button'>Buy Now!</button>
<Link href='/checkout'>Checkout?</Link>
</> </>
) )
) : (
<p>Sold Out</p>
); );
} }

View File

@ -38,6 +38,10 @@ const productFragment = /* GraphQL */ `
amount amount
currencyCode currencyCode
} }
compareAtPrice {
amount
currencyCode
}
} }
} }
} }

6
package-lock.json generated
View File

@ -14,6 +14,7 @@
"react": "18.2.0", "react": "18.2.0",
"react-cookie": "^4.1.1", "react-cookie": "^4.1.1",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"server-only": "^0.0.1",
"xss": "^1.0.14" "xss": "^1.0.14"
}, },
"devDependencies": { "devDependencies": {
@ -5157,6 +5158,11 @@
"node": ">=10" "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": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@ -31,6 +31,7 @@
"react": "18.2.0", "react": "18.2.0",
"react-cookie": "^4.1.1", "react-cookie": "^4.1.1",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"server-only": "^0.0.1",
"xss": "^1.0.14" "xss": "^1.0.14"
}, },
"devDependencies": { "devDependencies": {