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 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}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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
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';
|
'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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -38,6 +38,10 @@ const productFragment = /* GraphQL */ `
|
|||||||
amount
|
amount
|
||||||
currencyCode
|
currencyCode
|
||||||
}
|
}
|
||||||
|
compareAtPrice {
|
||||||
|
amount
|
||||||
|
currencyCode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
6
package-lock.json
generated
6
package-lock.json
generated
@ -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",
|
||||||
|
@ -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": {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user