mirror of
https://github.com/vercel/commerce.git
synced 2025-05-12 12:47:50 +00:00
feat: adding more information warranty, part number, sku, speciall offers
Signed-off-by: Chloe <pinkcloudvnn@gmail.com>
This commit is contained in:
parent
e3f564ca77
commit
59c3f07beb
@ -84,8 +84,8 @@ export default async function ProductPage({ params }: { params: { handle: string
|
||||
/>
|
||||
<div className="mx-auto max-w-screen-2xl px-4">
|
||||
<BreadcrumbComponent type="product" handle={product.handle} />
|
||||
<div className="my-3 flex flex-col rounded-lg border border-neutral-200 bg-white p-8 md:p-12 lg:flex-row lg:gap-8 dark:border-neutral-800 dark:bg-black">
|
||||
<div className="h-full w-full basis-full lg:basis-4/6">
|
||||
<div className="my-3 flex flex-col space-x-0 rounded-lg border border-neutral-200 bg-white p-8 md:p-10 lg:flex-row lg:gap-8 lg:space-x-3 dark:border-neutral-800 dark:bg-black">
|
||||
<div className="h-full w-full basis-full lg:basis-7/12">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden" />
|
||||
@ -100,7 +100,7 @@ export default async function ProductPage({ params }: { params: { handle: string
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div className="basis-full lg:basis-2/6">
|
||||
<div className="basis-full lg:basis-5/12">
|
||||
<ProductDescription product={product} />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -11,12 +11,10 @@ type CoreChargeBadgeProps = {
|
||||
|
||||
const CoreChargeBadge = ({ variants, selectedOptions }: CoreChargeBadgeProps) => {
|
||||
const selectedOptionsMap = new Map(selectedOptions.map((option) => [option.name, option.value]));
|
||||
console.log({ selectedOptionsMap, variants });
|
||||
const variant = variants.find((variant: ProductVariant) =>
|
||||
variant.selectedOptions.every((option) => option.value === selectedOptionsMap.get(option.name))
|
||||
);
|
||||
|
||||
console.log({ variant });
|
||||
return <CoreCharge variant={variant} sm />;
|
||||
};
|
||||
|
||||
|
@ -183,7 +183,7 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
|
||||
</div>
|
||||
<a
|
||||
href={cart.checkoutUrl}
|
||||
className="block w-full rounded-full bg-blue-600 p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100"
|
||||
className="block w-full rounded-full bg-secondary p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100"
|
||||
>
|
||||
Proceed to Checkout
|
||||
</a>
|
||||
|
@ -11,35 +11,36 @@ type CoreChargeProps = {
|
||||
const CoreCharge = ({ variant, sm = false }: CoreChargeProps) => {
|
||||
if (!variant || !variant.coreCharge?.amount || variant.waiverAvailable) return null;
|
||||
|
||||
const originalPrice = String(Number(variant.price.amount) - Number(variant.coreCharge.amount));
|
||||
|
||||
const coreChargeDisplay = (
|
||||
<Price amount={variant.coreCharge.amount} currencyCode={variant.price.currencyCode} />
|
||||
);
|
||||
|
||||
const originalPrice = String(Number(variant.price.amount) - Number(variant.coreCharge.amount));
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md bg-gray-100 px-3 py-1 text-sm">
|
||||
<ArrowPathIcon className="h-4 w-4" />
|
||||
<span className="flex items-center gap-1" data-tooltip-id="core-charge-explanation">
|
||||
<ArrowPathIcon className="h-3 w-3" />
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
data-tooltip-id={!sm ? 'core-charge-explanation' : undefined}
|
||||
>
|
||||
{sm ? coreChargeDisplay : <>Core Charge: {coreChargeDisplay}</>}
|
||||
</span>
|
||||
{sm ? null : (
|
||||
<Tooltip id="core-charge-explanation" className="max-w-64">
|
||||
<p className="flex flex-wrap items-center gap-1">
|
||||
The core charge of {coreChargeDisplay} is a refundable deposit that is added to the
|
||||
price of the part.
|
||||
<Tooltip id="core-charge-explanation" className="z-20 max-w-72">
|
||||
<p className="flex flex-wrap items-center gap-1 text-sm">
|
||||
The core charge of {coreChargeDisplay} is a refundable deposit that is added to the price
|
||||
of the part.
|
||||
</p>
|
||||
<p>
|
||||
<p className="text-sm">
|
||||
This charge ensures that the old, worn-out part is returned to the supplier for proper
|
||||
disposal or recycling.
|
||||
</p>
|
||||
<p className="flex flex-wrap items-center gap-1">
|
||||
When you return the old part, you'll receive a refund of the core charge, making
|
||||
the final price of the part{' '}
|
||||
<p className="flex flex-wrap items-center gap-1 text-sm">
|
||||
When you return the old part, you'll receive a refund of the core charge, making the
|
||||
final price of the part
|
||||
<Price amount={originalPrice} currencyCode={variant.price.currencyCode} />
|
||||
</p>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -39,10 +39,10 @@ export function Gallery({ images }: { images: { src: string; altText: string }[]
|
||||
priority={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{images.length > 1 ? (
|
||||
<>
|
||||
<div className="absolute bottom-[15%] flex w-full justify-center">
|
||||
<div className="mx-auto flex h-11 items-center rounded-full border border-white bg-neutral-50/80 text-neutral-500 backdrop-blur dark:border-black dark:bg-neutral-900/80">
|
||||
<div className="mx-auto mb-3 flex h-11 items-center rounded-full border border-white bg-neutral-50/80 text-neutral-500 backdrop-blur dark:border-black dark:bg-neutral-900/80">
|
||||
<Link
|
||||
aria-label="Previous product image"
|
||||
href={previousUrl}
|
||||
@ -62,6 +62,10 @@ export function Gallery({ images }: { images: { src: string; altText: string }[]
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<p className="absolute bottom-[5%] flex w-full justify-center text-sm text-neutral-500">
|
||||
Representative Image
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
|
@ -1,10 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { InformationCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { Checkbox } from 'components/checkbox';
|
||||
import CoreCharge from 'components/core-charge';
|
||||
import Price from 'components/price';
|
||||
import Tooltip from 'components/tooltip';
|
||||
import { Money, ProductVariant } from 'lib/shopify/types';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
@ -20,32 +17,28 @@ const PriceWithCoreCharge = ({ variants, defaultPrice }: PriceWithCoreChargeProp
|
||||
(option) => option.value === searchParams.get(option.name.toLowerCase())
|
||||
)
|
||||
);
|
||||
console.log({ variant });
|
||||
|
||||
const price = variant?.price.amount || defaultPrice.amount;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
{variant && (
|
||||
<div className="flex flex-row items-center space-x-3 text-sm text-neutral-700">
|
||||
{variant.sku && <span>SKU: {variant.sku}</span>}
|
||||
{variant.barcode && <span>Part Number: {variant.barcode}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mr-auto flex w-auto flex-row flex-wrap items-center gap-3 text-sm">
|
||||
<Price
|
||||
amount={variant?.price.amount || defaultPrice.amount}
|
||||
amount={price}
|
||||
currencyCode={variant?.price.currencyCode || defaultPrice.currencyCode}
|
||||
className="text-lg font-semibold"
|
||||
className="text-2xl font-semibold"
|
||||
/>
|
||||
<CoreCharge variant={variant} />
|
||||
{variant?.coreCharge?.amount && variant.waiverAvailable ? (
|
||||
<div className="mt-1 flex w-full items-center space-x-3">
|
||||
<Checkbox id="payCoreCharge" />
|
||||
<label htmlFor="payCoreCharge" className="text-md flex items-center gap-1 leading-none">
|
||||
Pay a core charge of
|
||||
<Price
|
||||
amount={variant.coreCharge.amount}
|
||||
currencyCode={variant.coreCharge.currencyCode}
|
||||
/>
|
||||
<span data-tooltip-id="payCoreCharge">
|
||||
<InformationCircleIcon className="ml-1 h-4 w-4 text-gray-500" />
|
||||
</span>
|
||||
</label>
|
||||
<Tooltip id="payCoreCharge">Select this if you do not have a core to return</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -3,13 +3,15 @@ import Prose from 'components/prose';
|
||||
import { Product } from 'lib/shopify/types';
|
||||
import { Suspense } from 'react';
|
||||
import PriceWithCoreCharge from './price-with-core-charge';
|
||||
import SpecialOffer from './special-offer';
|
||||
import { VariantSelector } from './variant-selector';
|
||||
import Warranty from './warranty';
|
||||
|
||||
export function ProductDescription({ product }: { product: Product }) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6 flex flex-col border-b pb-6 dark:border-neutral-700">
|
||||
<h1 className="mb-3 text-4xl font-bold">{product.title}</h1>
|
||||
<div className="mb-5 flex flex-col dark:border-neutral-700">
|
||||
<h1 className="mb-3 text-2xl font-bold">{product.title}</h1>
|
||||
<PriceWithCoreCharge
|
||||
variants={product.variants}
|
||||
defaultPrice={product.priceRange.minVariantPrice}
|
||||
@ -21,14 +23,21 @@ export function ProductDescription({ product }: { product: Product }) {
|
||||
|
||||
{product.descriptionHtml ? (
|
||||
<Prose
|
||||
className="mb-6 text-sm leading-tight dark:text-white/[60%]"
|
||||
className="mb-4 text-sm leading-tight dark:text-white/[60%]"
|
||||
html={product.descriptionHtml}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="mb-4 border-t py-6 dark:border-neutral-700">
|
||||
<Warranty productType={product.productType} />
|
||||
</div>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<AddToCart variants={product.variants} availableForSale={product.availableForSale} />
|
||||
</Suspense>
|
||||
<div className="mt-4 border-t pt-4">
|
||||
<SpecialOffer />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
27
components/product/special-offer.tsx
Normal file
27
components/product/special-offer.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { CurrencyDollarIcon, ShieldCheckIcon, UsersIcon } from '@heroicons/react/24/outline';
|
||||
import { TruckIcon } from '@heroicons/react/24/solid';
|
||||
|
||||
const SpecialOffer = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 text-base font-medium tracking-tight">Special Offers</div>
|
||||
<div className="flex flex-col space-y-2 pl-2 tracking-normal text-neutral-800">
|
||||
<p className="flex items-center gap-3">
|
||||
<TruckIcon className="h-5 w-5 text-secondary" /> Flat Rate Shipping (Commercial Address)
|
||||
</p>
|
||||
<p className="flex items-center gap-3">
|
||||
<ShieldCheckIcon className="h-5 w-5 text-secondary" /> Up to 5 Years Unlimited Miles
|
||||
Warranty
|
||||
</p>
|
||||
<p className="flex items-center gap-3">
|
||||
<UsersIcon className="h-5 w-5 text-secondary" /> Excellent Customer Support
|
||||
</p>
|
||||
<p className="flex items-center gap-3">
|
||||
<CurrencyDollarIcon className="h-5 w-5 text-secondary" /> No Core Charge for 30 days
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpecialOffer;
|
@ -39,8 +39,8 @@ export function VariantSelector({
|
||||
}));
|
||||
|
||||
return options.map((option) => (
|
||||
<dl className="mb-8" key={option.id}>
|
||||
<dt className="mb-4 text-sm uppercase tracking-wide">{option.name}</dt>
|
||||
<dl className="mb-6" key={option.id}>
|
||||
<dt className="mb-4 text-sm font-medium tracking-wide">{option.name}</dt>
|
||||
<dd className="flex flex-wrap gap-3">
|
||||
{option.values.map((value) => {
|
||||
const optionNameLowerCase = option.name.toLowerCase();
|
||||
|
57
components/product/warranty-selector.tsx
Normal file
57
components/product/warranty-selector.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import Price from 'components/price';
|
||||
import { cn } from 'lib/utils';
|
||||
import { ReactNode, useState } from 'react';
|
||||
|
||||
const options = ['Included', 'Premium Labor', '+1 Year'] as const;
|
||||
type Option = (typeof options)[number];
|
||||
|
||||
const plans: Array<{
|
||||
key: Option;
|
||||
template: ReactNode;
|
||||
price: number;
|
||||
}> = [
|
||||
{
|
||||
template: (
|
||||
<>
|
||||
<span>Included</span>
|
||||
<span>3-Year Warranty</span>
|
||||
</>
|
||||
),
|
||||
price: 0,
|
||||
key: 'Included'
|
||||
},
|
||||
{
|
||||
template: <span>Premium Labor</span>,
|
||||
price: 150,
|
||||
key: 'Premium Labor'
|
||||
},
|
||||
{
|
||||
template: <span>+1 Year</span>,
|
||||
price: 100,
|
||||
key: '+1 Year'
|
||||
}
|
||||
];
|
||||
const WarrantySelector = () => {
|
||||
const [selectedOptions, setSelectedOptions] = useState<Option>('Included');
|
||||
return (
|
||||
<ul className="flex min-h-16 flex-row space-x-4 pt-2">
|
||||
{plans.map((plan) => (
|
||||
<li
|
||||
key={plan.key}
|
||||
onClick={() => setSelectedOptions(plan.key)}
|
||||
className={cn(
|
||||
'flex w-32 cursor-pointer flex-col items-center justify-center space-y-2 rounded-md border p-2 text-xs font-medium',
|
||||
{ 'ring-2 ring-secondary': plan.key === selectedOptions }
|
||||
)}
|
||||
>
|
||||
{plan.template}
|
||||
<Price amount={String(plan.price)} currencyCode="USD" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default WarrantySelector;
|
30
components/product/warranty.tsx
Normal file
30
components/product/warranty.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { ShieldCheckIcon } from '@heroicons/react/24/outline';
|
||||
import Link from 'next/link';
|
||||
import WarrantySelector from './warranty-selector';
|
||||
|
||||
type WarrantyProps = {
|
||||
productType: string | null;
|
||||
};
|
||||
|
||||
const Warranty = ({ productType }: WarrantyProps) => {
|
||||
return (
|
||||
<div className="flex flex-col text-sm">
|
||||
<div className="mb-3 flex flex-row items-center space-x-2 text-base font-medium">
|
||||
<ShieldCheckIcon className="h-7 w-7" />
|
||||
<span> Protect your {productType ?? 'product'}</span>
|
||||
</div>
|
||||
<div className="mb-1 flex flex-row items-center space-x-3 divide-x divide-gray-400 leading-none">
|
||||
<span>Extended Warranty</span>
|
||||
<Link href="#" className="pl-2 text-blue-800 hover:underline">
|
||||
What's Included
|
||||
</Link>
|
||||
<Link href="#" className="pl-2 text-blue-800 hover:underline">
|
||||
Terms & Conditions
|
||||
</Link>
|
||||
</div>
|
||||
<WarrantySelector />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Warranty;
|
@ -36,6 +36,8 @@ const productFragment = /* GraphQL */ `
|
||||
id
|
||||
title
|
||||
availableForSale
|
||||
barcode
|
||||
sku
|
||||
selectedOptions {
|
||||
name
|
||||
value
|
||||
@ -68,6 +70,9 @@ const productFragment = /* GraphQL */ `
|
||||
}
|
||||
tags
|
||||
updatedAt
|
||||
productType: metafield(namespace: "custom", key: "product_type") {
|
||||
value
|
||||
}
|
||||
}
|
||||
${imageFragment}
|
||||
${seoFragment}
|
||||
|
@ -193,12 +193,12 @@ const reshapeProduct = (product: ShopifyProduct, filterHiddenProducts: boolean =
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { images, variants, ...rest } = product;
|
||||
|
||||
const { images, variants, productType, ...rest } = product;
|
||||
return {
|
||||
...rest,
|
||||
images: reshapeImages(images, product.title),
|
||||
variants: reshapeVariants(removeEdgesAndNodes(variants))
|
||||
variants: reshapeVariants(removeEdgesAndNodes(variants)),
|
||||
productType: productType?.value ?? null
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -62,9 +62,10 @@ export type Page = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type Product = Omit<ShopifyProduct, 'variants' | 'images'> & {
|
||||
export type Product = Omit<ShopifyProduct, 'variants' | 'images' | 'productType'> & {
|
||||
variants: ProductVariant[];
|
||||
images: Image[];
|
||||
productType: string | null;
|
||||
};
|
||||
|
||||
export type ProductOption = {
|
||||
@ -84,6 +85,8 @@ export type ProductVariant = {
|
||||
price: Money;
|
||||
coreCharge: Money | null;
|
||||
waiverAvailable: boolean | null;
|
||||
barcode: string | null;
|
||||
sku: string | null;
|
||||
};
|
||||
|
||||
export type ShopifyProductVariant = Omit<ProductVariant, 'coreCharge' | 'waiverAvailable'> & {
|
||||
@ -140,6 +143,9 @@ export type ShopifyProduct = {
|
||||
handle: string;
|
||||
}[];
|
||||
};
|
||||
productType: {
|
||||
value: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type ShopifyCartOperation = {
|
||||
|
Loading…
x
Reference in New Issue
Block a user