fix: update PDP layout

Signed-off-by: Chloe <vanguyen.work@gmail.com>
This commit is contained in:
Chloe 2024-06-17 11:04:43 +07:00
parent 3ac4b140c9
commit a11287d4ad
No known key found for this signature in database
GPG Key ID: CFD53CE570D42DF5
14 changed files with 390 additions and 46 deletions

View File

@ -85,8 +85,12 @@ export default async function ProductPage({ params }: { params: { handle: string
<div className="hidden lg:block"> <div className="hidden lg:block">
<BreadcrumbComponent type="product" handle={product.handle} /> <BreadcrumbComponent type="product" handle={product.handle} />
</div> </div>
<div className="my-3 flex flex-col space-x-0 rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-black md:p-10 lg:flex-row lg:gap-8 lg:space-x-3"> <div className="my-3 flex flex-col space-x-0 lg:flex-row lg:gap-8 lg:space-x-3">
<div className="h-full w-full basis-full lg:basis-7/12"> <div className="h-full w-full basis-full lg:basis-7/12">
<ProductDescription product={product} />
</div>
<div className="basis-full lg:basis-5/12">
<Suspense <Suspense
fallback={ fallback={
<div className="aspect-square relative h-full max-h-[550px] w-full overflow-hidden" /> <div className="aspect-square relative h-full max-h-[550px] w-full overflow-hidden" />
@ -100,18 +104,12 @@ export default async function ProductPage({ params }: { params: { handle: string
/> />
</Suspense> </Suspense>
</div> </div>
<div className="basis-full lg:basis-5/12">
<ProductDescription product={product} />
</div>
</div> </div>
<Suspense> <Suspense>
<RelatedProducts id={product.id} /> <RelatedProducts id={product.id} />
</Suspense> </Suspense>
</div> </div>
<Suspense> <Footer />
<Footer />
</Suspense>
</> </>
); );
} }

View File

@ -18,7 +18,7 @@ function SubmitButton({
}) { }) {
const { pending } = useFormStatus(); const { pending } = useFormStatus();
const buttonClasses = const buttonClasses =
'relative flex w-full items-center justify-center rounded bg-secondary p-4 tracking-wide text-white gap-3'; 'relative flex w-full items-center justify-center rounded bg-secondary p-3 tracking-wide text-white gap-3';
const disabledClasses = 'cursor-not-allowed opacity-60 hover:opacity-60'; const disabledClasses = 'cursor-not-allowed opacity-60 hover:opacity-60';
if (!availableForSale) { if (!availableForSale) {

View File

@ -5,13 +5,15 @@ const Price = ({
className, className,
currencyCode = 'USD', currencyCode = 'USD',
currencyCodeClassName, currencyCodeClassName,
showCurrency = false showCurrency = false,
prefix
}: { }: {
amount: string; amount: string;
className?: string; className?: string;
currencyCode: string; currencyCode: string;
currencyCodeClassName?: string; currencyCodeClassName?: string;
showCurrency?: boolean; showCurrency?: boolean;
prefix?: string;
} & React.ComponentProps<'p'>) => { } & React.ComponentProps<'p'>) => {
// Convert string to float and check if it is zero // Convert string to float and check if it is zero
const price = parseFloat(amount); const price = parseFloat(amount);
@ -24,6 +26,7 @@ const Price = ({
// Otherwise, format and display the price // Otherwise, format and display the price
return ( return (
<p suppressHydrationWarning={true} className={className}> <p suppressHydrationWarning={true} className={className}>
{prefix}
{new Intl.NumberFormat(undefined, { {new Intl.NumberFormat(undefined, {
style: 'currency', style: 'currency',
currency: currencyCode, currency: currencyCode,

View File

@ -93,8 +93,8 @@ const CoreCharge = ({ variants }: CoreChargeProps) => {
period, you will never need to pay the core charge. period, you will never need to pay the core charge.
</p> </p>
<p className="text-sm"> <p className="text-sm">
If you don't manage to return the old part within the 30-day period, we will then If you don&apos;t manage to return the old part within the 30-day period, we will
charge you the core charge. This keeps more money in your pocket upfront. then charge you the core charge. This keeps more money in your pocket upfront.
</p> </p>
</section> </section>

View File

@ -0,0 +1,114 @@
'use client';
import { TruckIcon } from '@heroicons/react/24/outline';
import Price from 'components/price';
import SideDialog from 'components/side-dialog';
import { DELIVERY_OPTION_KEY } from 'lib/constants';
import { cn, createUrl } from 'lib/utils';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { ReactNode, useState } from 'react';
const options = ['Commercial', 'Residential'] as const;
type Option = (typeof options)[number];
export const deliveryOptions: Array<{
key: Option;
template: ReactNode;
price: number;
}> = [
{
template: <span className="font-bold">Commercial</span>,
price: 299,
key: 'Commercial'
},
{
template: <span className="font-bold">Residential</span>,
price: 398,
key: 'Residential'
}
];
const Delivery = () => {
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const [openingDialog, setOpeningDialog] = useState<'information' | 'terms-conditions' | null>(
null
);
const newSearchParams = new URLSearchParams(searchParams.toString());
const selectedDeliveryOption = newSearchParams.get(DELIVERY_OPTION_KEY);
const handleSelectDelivery = (option: Option) => {
newSearchParams.set(DELIVERY_OPTION_KEY, option);
const newUrl = createUrl(pathname, newSearchParams);
router.replace(newUrl, { scroll: false });
};
if (!selectedDeliveryOption) {
handleSelectDelivery(options[0]);
}
return (
<div className="flex flex-col text-xs lg:text-sm">
<div className="mb-3 flex flex-row items-center space-x-1 divide-x divide-gray-400 leading-none lg:space-x-3">
<div className="flex flex-row items-center space-x-2 text-base font-medium">
<TruckIcon className="h-5 w-5" />
<span>Delivery</span>
</div>
<div className="pl-2">
<button
onClick={() => setOpeningDialog('information')}
className="text-xs text-blue-800 hover:underline lg:text-sm"
>
Information
</button>
<SideDialog
title="Information"
onClose={() => setOpeningDialog(null)}
open={openingDialog === 'information'}
>
<p>Information</p>
</SideDialog>
</div>
<div className="pl-2">
<button
onClick={() => setOpeningDialog('terms-conditions')}
className="text-xs text-blue-800 hover:underline lg:text-sm"
>
Terms & Conditions
</button>
<SideDialog
title="Terms & Conditions"
onClose={() => setOpeningDialog(null)}
open={openingDialog === 'terms-conditions'}
>
<p>Terms & Conditions</p>
</SideDialog>
</div>
</div>
<ul className="flex min-h-16 flex-row space-x-4 pt-2">
{deliveryOptions.map((option) => (
<li className="flex w-32" key={option.key}>
<button
onClick={() => handleSelectDelivery(option.key)}
className={cn(
'font-base flex w-full flex-col flex-wrap items-center justify-center space-y-0.5 rounded border text-center text-xs',
{
'border-0 ring-2 ring-secondary': selectedDeliveryOption === option.key
}
)}
>
{option.template}
<Price amount={String(option.price)} currencyCode="USD" />
</button>
</li>
))}
</ul>
</div>
);
};
export default Delivery;

View File

@ -0,0 +1,73 @@
'use client';
import Price from 'components/price';
import { CORE_VARIANT_ID_KEY, CORE_WAIVER, DELIVERY_OPTION_KEY } from 'lib/constants';
import { Money, ProductVariant } from 'lib/shopify/types';
import { useSearchParams } from 'next/navigation';
import { deliveryOptions } from './delivery';
type PriceSummaryProps = {
variants: ProductVariant[];
defaultPrice: Money;
};
const PriceSummary = ({ variants, defaultPrice }: PriceSummaryProps) => {
const searchParams = useSearchParams();
const variant = variants.find((variant) =>
variant.selectedOptions.every(
(option) => option.value === searchParams.get(option.name.toLowerCase())
)
);
const price = variant?.price.amount || defaultPrice.amount;
const selectedCoreChargeOption = searchParams.get(CORE_VARIANT_ID_KEY);
const selectedDeliveryOption = searchParams.get(DELIVERY_OPTION_KEY);
const deliveryPrice =
deliveryOptions.find((option) => option.key === selectedDeliveryOption)?.price ?? 0;
const currencyCode = variant?.price.currencyCode || defaultPrice.currencyCode;
const corePrice = selectedCoreChargeOption === CORE_WAIVER ? 0 : variant?.coreCharge?.amount ?? 0;
const totalPrice = Number(price) + deliveryPrice + Number(corePrice);
return (
<div className="mb-3 flex flex-col gap-2">
<div className="flex flex-row items-center justify-between">
<span className="text-xl font-semibold">Our Price</span>
<Price amount={price} currencyCode={currencyCode} className="text-2xl font-semibold" />
</div>
<div className="flex flex-row items-center justify-between">
<span className="text-sm text-gray-400">{`Core Charge ${selectedCoreChargeOption === CORE_WAIVER ? '(Waived for 30 days)' : ''}`}</span>
{selectedCoreChargeOption === CORE_WAIVER ? (
<span className="text-sm text-gray-400">{`+$0.00`}</span>
) : (
<Price
amount={variant?.coreCharge?.amount ?? '0'}
currencyCode={currencyCode}
className="text-sm text-gray-400"
prefix="+"
/>
)}
</div>
<div className="flex flex-row items-center justify-between">
<span className="text-sm text-gray-400">{`Flat Rate Shipping (${selectedDeliveryOption} address)`}</span>
<Price
amount={String(deliveryPrice)}
currencyCode={currencyCode}
className="text-sm text-gray-400"
prefix="+"
/>
</div>
<hr />
<div className="flex flex-row items-center justify-between">
<span className="text-sm text-gray-400">To Pay Today</span>
<Price
amount={String(totalPrice)}
currencyCode={currencyCode}
className="text-sm text-gray-400"
/>
</div>
</div>
);
};
export default PriceSummary;

View File

@ -3,6 +3,9 @@ import Prose from 'components/prose';
import { Product } from 'lib/shopify/types'; import { Product } from 'lib/shopify/types';
import { Suspense } from 'react'; import { Suspense } from 'react';
import CoreCharge from './core-charge'; import CoreCharge from './core-charge';
import Delivery from './delivery';
import PriceSummary from './price-summary';
import ProductDetails from './product-details';
import SpecialOffer from './special-offer'; import SpecialOffer from './special-offer';
import VariantDetails from './vairant-details'; import VariantDetails from './vairant-details';
import { VariantSelector } from './variant-selector'; import { VariantSelector } from './variant-selector';
@ -11,7 +14,7 @@ import Warranty from './warranty';
export function ProductDescription({ product }: { product: Product }) { export function ProductDescription({ product }: { product: Product }) {
return ( return (
<> <>
<div className="mb-5 flex flex-col dark:border-neutral-700"> <div className="mb-4 flex flex-col">
<h1 className="text-xl font-bold md:text-2xl">{product.title}</h1> <h1 className="text-xl font-bold md:text-2xl">{product.title}</h1>
<VariantDetails <VariantDetails
@ -34,6 +37,7 @@ export function ProductDescription({ product }: { product: Product }) {
/> />
) : null} ) : null}
<ProductDetails product={product} />
<div className="mb-2 border-t py-4 dark:border-neutral-700"> <div className="mb-2 border-t py-4 dark:border-neutral-700">
<CoreCharge variants={product.variants} /> <CoreCharge variants={product.variants} />
</div> </div>
@ -42,12 +46,15 @@ export function ProductDescription({ product }: { product: Product }) {
<Warranty /> <Warranty />
</div> </div>
<div className="mb-2 border-t py-4 dark:border-neutral-700">
<Delivery />
</div>
<PriceSummary variants={product.variants} defaultPrice={product.priceRange.minVariantPrice} />
<Suspense fallback={null}> <Suspense fallback={null}>
<AddToCart variants={product.variants} availableForSale={product.availableForSale} /> <AddToCart variants={product.variants} availableForSale={product.availableForSale} />
</Suspense> </Suspense>
<div className="mt-4 border-t pt-4"> <SpecialOffer />
<SpecialOffer />
</div>
</> </>
); );
} }

View File

@ -0,0 +1,51 @@
import {
BeakerIcon,
BoltIcon,
CogIcon,
CpuChipIcon,
CubeTransparentIcon
} from '@heroicons/react/24/outline';
import { Product } from 'lib/shopify/types';
const ProductDetails = ({ product }: { product: Product }) => {
return (
<div className="mb-3 flex flex-col gap-3">
<span className="font-medium">Details</span>
<div className="grid grid-cols-4 gap-y-3 text-sm">
{product.transmissionType && (
<div className="flex flex-row items-center gap-2">
<CubeTransparentIcon className="size-4 text-primary" />
{product.transmissionType}
</div>
)}
{product.transmissionSpeeds && product.transmissionSpeeds.length && (
<div className="flex flex-row items-center gap-2">
<BoltIcon className="size-4 text-primary" />
{`${product.transmissionSpeeds[0]}-Speed`}
</div>
)}
{product.driveType && (
<div className="flex flex-row items-center gap-2">
<CogIcon className="size-4 text-primary" />
{product.driveType}
</div>
)}
{product.engineCylinders?.length && (
<div className="flex flex-row items-center gap-2">
<BeakerIcon className="size-4 text-primary" />
{`${product.engineCylinders[0]} Cylinders`}
</div>
)}
{product.transmissionCode?.length && (
<div className="flex flex-row items-center gap-2">
<CpuChipIcon className="size-4 text-primary" />
{product.transmissionCode[0]}
</div>
)}
</div>
</div>
);
};
export default ProductDetails;

View File

@ -1,28 +1,71 @@
import { CurrencyDollarIcon, ShieldCheckIcon, UsersIcon } from '@heroicons/react/24/outline'; import {
import { TruckIcon } from '@heroicons/react/24/solid'; ArrowPathIcon,
CurrencyDollarIcon,
ShieldCheckIcon,
StarIcon,
TruckIcon,
UsersIcon
} from '@heroicons/react/24/outline';
const SpecialOffer = () => { const SpecialOffer = () => {
return ( return (
<> <div className="mt-10 grid grid-cols-2 gap-y-5 xl:grid-cols-3">
<div className="mb-3 text-base font-medium tracking-tight">Special Offers</div> <div className="flex items-start gap-3">
<div className="flex flex-col space-y-2 pl-2 text-sm tracking-normal text-neutral-800 lg:text-base dark:text-white"> <TruckIcon className="size-12 text-primary" />
<p className="flex items-center gap-3"> <div className="flex flex-col">
<TruckIcon className="h-4 w-4 text-secondary lg:h-5 lg:w-5" /> Flat Rate Shipping <span className="font-medium uppercase">Flat Rate Shipping</span>
(Commercial Address) <span className="text-sm font-light">
</p> We offer a flat $299 shipping fee to commercial addresses
<p className="flex items-center gap-3"> </span>
<ShieldCheckIcon className="h-4 w-4 text-secondary lg:h-5 lg:w-5" /> Up to 5 Years </div>
Unlimited Miles Warranty
</p>
<p className="flex items-center gap-3">
<UsersIcon className="h-4 w-4 text-secondary lg:h-5 lg:w-5" /> Excellent Customer Support
</p>
<p className="flex items-center gap-3">
<CurrencyDollarIcon className="h-4 w-4 text-secondary lg:h-5 lg:w-5" /> No Core Charge for
30 days
</p>
</div> </div>
</> <div className="flex items-start gap-3">
<CurrencyDollarIcon className="size-10 text-primary" />
<div className="flex flex-col">
<span className="font-medium uppercase">Best Price Guarantee</span>
<span className="text-sm font-light">
We will match or beat any competitor&apos;s pricing
</span>
</div>
</div>
<div className="flex items-start gap-3">
<ShieldCheckIcon className="size-8 text-primary" />
<div className="flex flex-col">
<span className="font-medium uppercase">Unbeatable Warranty</span>
<span className="text-sm font-light">Up to 5 years with unlimited miles</span>
</div>
</div>
<div className="flex items-start gap-3">
<UsersIcon className="size-10 text-primary" />
<div className="flex flex-col">
<span className="font-medium uppercase">Excellent Support</span>
<span className="text-sm font-light">
End-to-end, expert care from our customer service team
</span>
</div>
</div>
<div className="flex items-start gap-3">
<ArrowPathIcon className="size-10 text-primary" />
<div className="flex flex-col">
<span className="font-medium uppercase">Core Charge Waiver</span>
<span className="text-sm font-light">
Avoid the core charge by returning within 30 days
</span>
</div>
</div>
<div className="flex items-start gap-3">
<StarIcon className="size-10 text-primary" />
<div className="flex flex-col">
<span className="font-medium uppercase">Free Core Return</span>
<span className="text-sm font-light">
Unlike competitors, we pay for the return of your core
</span>
</div>
</div>
</div>
); );
}; };

View File

@ -1,5 +1,6 @@
'use client'; 'use client';
import { CheckCircleIcon } from '@heroicons/react/24/outline';
import Price from 'components/price'; import Price from 'components/price';
import { Money, ProductVariant } from 'lib/shopify/types'; import { Money, ProductVariant } from 'lib/shopify/types';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
@ -20,17 +21,23 @@ const VariantDetails = ({ variants, defaultPrice }: VariantDetailsProps) => {
const price = variant?.price.amount || defaultPrice.amount; const price = variant?.price.amount || defaultPrice.amount;
return ( return (
<> <div className="mt-1">
<div className="mb-5 flex items-center justify-start gap-x-2">
<p className="text-sm">SKU: {variant?.sku || 'N/A'}</p>
<p className="text-sm">Condition: {variant?.condition || 'N/A'}</p>
</div>
<Price <Price
amount={price} amount={price}
currencyCode={variant?.price.currencyCode || defaultPrice.currencyCode} currencyCode={variant?.price.currencyCode || defaultPrice.currencyCode}
className="text-2xl font-semibold" className="text-2xl font-semibold"
/> />
</> <div className="mt-2 flex items-center justify-start gap-x-2">
{variant?.availableForSale ? (
<div className="flex items-center gap-1 text-sm text-green-500">
<CheckCircleIcon className="size-5" /> In Stock
</div>
) : (
<span className="text-sm text-red-600">Out of Stock</span>
)}
<p className="text-sm">Condition: {variant?.condition || 'N/A'}</p>
</div>
</div>
); );
}; };

View File

@ -51,3 +51,5 @@ export const CONDITIONS = {
Used: 'Used', Used: 'Used',
Remanufactured: 'Remanufactured' Remanufactured: 'Remanufactured'
}; };
export const DELIVERY_OPTION_KEY = 'delivery';

View File

@ -76,6 +76,21 @@ const productFragment = /* GraphQL */ `
fuelType: metafield(namespace: "custom", key: "fuel") { fuelType: metafield(namespace: "custom", key: "fuel") {
value value
} }
transmissionType: metafield(namespace: "custom", key: "transmission_type") {
value
}
transmissionSpeeds: metafield(namespace: "custom", key: "transmission_speeds") {
value
}
driveType: metafield(namespace: "custom", key: "drive_type") {
value
}
transmissionCode: metafield(namespace: "custom", key: "transmission_code") {
value
}
transmissionTag: metafield(namespace: "custom", key: "transmission_tag") {
value
}
images(first: 20) { images(first: 20) {
edges { edges {
node { node {

View File

@ -75,7 +75,8 @@ import {
ShopifyProductsOperation, ShopifyProductsOperation,
ShopifyRemoveFromCartOperation, ShopifyRemoveFromCartOperation,
ShopifySetCartAttributesOperation, ShopifySetCartAttributesOperation,
ShopifyUpdateCartOperation ShopifyUpdateCartOperation,
TransmissionType
} from './types'; } from './types';
const domain = process.env.SHOPIFY_STORE_DOMAIN const domain = process.env.SHOPIFY_STORE_DOMAIN
@ -287,7 +288,10 @@ const reshapeVariants = (variants: ShopifyProductVariant[]): ProductVariant[] =>
})); }));
}; };
const reshapeProduct = (product: ShopifyProduct, filterHiddenProducts: boolean = true) => { const reshapeProduct = (
product: ShopifyProduct,
filterHiddenProducts: boolean = true
): Product | undefined => {
if (!product || (filterHiddenProducts && product.tags.includes(HIDDEN_PRODUCT_TAG))) { if (!product || (filterHiddenProducts && product.tags.includes(HIDDEN_PRODUCT_TAG))) {
return undefined; return undefined;
} }
@ -295,6 +299,13 @@ const reshapeProduct = (product: ShopifyProduct, filterHiddenProducts: boolean =
const { images, variants, ...rest } = product; const { images, variants, ...rest } = product;
return { return {
...rest, ...rest,
transmissionCode: parseMetaFieldValue<string[]>(product.transmissionCode),
transmissionSpeeds: parseMetaFieldValue<number[]>(product.transmissionSpeeds),
transmissionTag: parseMetaFieldValue<string[]>(product.transmissionTag),
driveType: parseMetaFieldValue<string[]>(product.driveType),
transmissionType: product.transmissionType
? (product.transmissionType.value as TransmissionType)
: null,
engineCylinders: parseMetaFieldValue<number[]>(product.engineCylinders), engineCylinders: parseMetaFieldValue<number[]>(product.engineCylinders),
fuelType: product.fuelType?.value || null, fuelType: product.fuelType?.value || null,
images: reshapeImages(images, product.title), images: reshapeImages(images, product.title),

View File

@ -101,14 +101,29 @@ export type Metaobject = {
[key: string]: string; [key: string]: string;
}; };
export type TransmissionType = 'Automatic' | 'Manual';
export type Product = Omit< export type Product = Omit<
ShopifyProduct, ShopifyProduct,
'variants' | 'images' | 'fuelType' | 'engineCylinders' | 'variants'
| 'images'
| 'fuelType'
| 'engineCylinders'
| 'driveType'
| 'transmissionType'
| 'transmissionSpeeds'
| 'transmissionCode'
| 'transmissionTag'
> & { > & {
variants: ProductVariant[]; variants: ProductVariant[];
images: Image[]; images: Image[];
fuelType: string | null; fuelType: string | null;
engineCylinders: number[] | null; engineCylinders: number[] | null;
driveType: string[] | null;
transmissionType: TransmissionType | null;
transmissionSpeeds: number[] | null;
transmissionCode: string[] | null;
transmissionTag: string[] | null;
}; };
export type ProductOption = { export type ProductOption = {
@ -216,6 +231,11 @@ export type ShopifyProduct = {
}; };
engineCylinders: { value: string } | null; engineCylinders: { value: string } | null;
fuelType: { value: string } | null; fuelType: { value: string } | null;
transmissionType: { value: string } | null;
transmissionTag: { value: string } | null;
transmissionCode: { value: string } | null;
driveType: { value: string } | null;
transmissionSpeeds: { value: string } | null;
}; };
export type ShopifyCartOperation = { export type ShopifyCartOperation = {