mirror of
https://github.com/vercel/commerce.git
synced 2025-05-14 13:47:49 +00:00
product: display price by variant selection, list images
This commit is contained in:
parent
824070a54b
commit
f7969b87e1
@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
103
components/price.js
Normal file
103
components/price.js
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -38,6 +38,10 @@ const productFragment = /* GraphQL */ `
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
compareAtPrice {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
6
package-lock.json
generated
6
package-lock.json
generated
@ -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",
|
||||
|
@ -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": {
|
||||
|
Loading…
x
Reference in New Issue
Block a user