Merge remote-tracking branch 'origin' into CPP-153

This commit is contained in:
tedraykov 2024-06-20 14:52:30 +03:00
commit f90da11e31
39 changed files with 1035 additions and 149 deletions

View File

@ -13,3 +13,16 @@ input,
button {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-neutral-600 dark:focus-visible:ring-offset-neutral-900;
}
@layer utilities {
/* Hide scrollbar for WebKit browsers */
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for Firefox */
.hide-scrollbar {
-ms-overflow-style: none; /* Internet Explorer 10+ */
scrollbar-width: none; /* Firefox */
}
}

View File

@ -35,7 +35,7 @@ export const metadata = {
export default async function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" className={GeistSans.variable}>
<body className="bg-neutral-50 text-black selection:bg-teal-300 dark:bg-neutral-900 dark:text-white dark:selection:bg-pink-500 dark:selection:text-white">
<body className="bg-white text-black selection:bg-teal-300 dark:bg-neutral-900 dark:text-white dark:selection:bg-pink-500 dark:selection:text-white">
<header>
<Banner />
<Navbar />

View File

@ -34,7 +34,7 @@ export default async function HomePage() {
<WhyChoose />
</Suspense>
<Suspense>
<FAQ />
<FAQ handle="home-page-faqs" />
</Suspense>
<Suspense>
<Manufacturers />

View File

@ -81,12 +81,16 @@ export default async function ProductPage({ params }: { params: { handle: string
__html: JSON.stringify(productJsonLd)
}}
/>
<div className="mx-auto mt-4 max-w-screen-2xl px-4">
<div className="mx-auto mt-4 max-w-screen-2xl px-8 xl:px-4">
<div className="hidden lg:block">
<BreadcrumbComponent type="product" handle={product.handle} />
</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">
<ProductDescription product={product} />
</div>
<div className="hidden lg:block lg:basis-5/12">
<Suspense
fallback={
<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>
</div>
<div className="basis-full lg:basis-5/12">
<ProductDescription product={product} />
</div>
</div>
<Suspense>
<RelatedProducts id={product.id} />
</Suspense>
</div>
<Suspense>
<Footer />
</Suspense>
<Footer />
</>
);
}

View File

@ -4,8 +4,10 @@ import { notFound } from 'next/navigation';
import Breadcrumb from 'components/breadcrumb';
import BreadcrumbHome from 'components/breadcrumb/breadcrumb-home';
import FAQ from 'components/faq';
import YMMFilters, { YMMFiltersPlaceholder } from 'components/filters';
import Grid from 'components/grid';
import Manufacturers from 'components/home-page/manufacturers';
import ProductsList from 'components/layout/products-list';
import { getProductsInCollection } from 'components/layout/products-list/actions';
import FiltersContainer, {
@ -14,6 +16,7 @@ import FiltersContainer, {
import MobileFilters from 'components/layout/search/filters/mobile-filters';
import SubMenu from 'components/layout/search/filters/sub-menu';
import Header, { HeaderPlaceholder } from 'components/layout/search/header';
import HelpfulLinks from 'components/layout/search/helpful-links';
import ProductsGridPlaceholder from 'components/layout/search/placeholder';
import SortingMenu from 'components/layout/search/sorting-menu';
import { Suspense } from 'react';
@ -58,7 +61,7 @@ async function CategoryPage({
<MobileFilters filters={filters} menu={<SubMenu collection={params.collection} />} />
<SortingMenu />
</div>
<Grid className="grid-cols-1 sm:grid-cols-2 sm:gap-x-8 lg:grid-cols-3">
<Grid className="hide-scrollbar max-h-[1000px] grid-cols-1 overflow-y-auto sm:grid-cols-2 sm:gap-x-8 lg:grid-cols-3">
{products.length === 0 ? (
<p className="py-3 text-lg">{`No products found in this collection`}</p>
) : (
@ -80,37 +83,53 @@ export default async function CategorySearchPage(props: {
searchParams?: { [key: string]: string | string[] | undefined };
}) {
return (
<div className="grid lg:grid-cols-3 lg:gap-x-10 xl:grid-cols-4">
<aside className="hidden lg:block">
<div className="mb-5">
<Suspense fallback={<YMMFiltersPlaceholder />}>
<YMMFilters />
</Suspense>
</div>
<>
<div className="mx-auto mt-6 max-w-screen-2xl px-8 pb-10">
<div className="grid lg:grid-cols-3 lg:gap-x-10 xl:grid-cols-4">
<aside className="hidden lg:block">
<div className="mb-5">
<Suspense fallback={<YMMFiltersPlaceholder />}>
<YMMFilters />
</Suspense>
</div>
<SubMenu collection={props.params.collection} />
<h3 className="sr-only">Filters</h3>
<Suspense fallback={<FiltersListPlaceholder />} key={`filters-${props.params.collection}`}>
<FiltersContainer searchParams={props.searchParams} />
</Suspense>
</aside>
<div className="lg:col-span-2 xl:col-span-3">
<div className="mb-2">
<Suspense fallback={<BreadcrumbHome />} key={`breadcrumb-${props.params.collection}`}>
<Breadcrumb type="collection" handle={props.params.collection} />
</Suspense>
</div>
<Suspense fallback={<HeaderPlaceholder />} key={`header-${props.params.collection}`}>
<Header collection={props.params.collection} />
</Suspense>
<SubMenu collection={props.params.collection} />
<h3 className="sr-only">Filters</h3>
<Suspense
fallback={<FiltersListPlaceholder />}
key={`filters-${props.params.collection}`}
>
<FiltersContainer searchParams={props.searchParams} />
<HelpfulLinks collection={props.params.collection} />
</Suspense>
</aside>
<div className="lg:col-span-2 xl:col-span-3">
<div className="mb-2">
<Suspense fallback={<BreadcrumbHome />} key={`breadcrumb-${props.params.collection}`}>
<Breadcrumb type="collection" handle={props.params.collection} />
</Suspense>
</div>
<Suspense fallback={<HeaderPlaceholder />} key={`header-${props.params.collection}`}>
<Header collection={props.params.collection} />
</Suspense>
<Suspense
fallback={<ProductsGridPlaceholder />}
key={`products-${props.params.collection}`}
>
<CategoryPage {...props} />
</Suspense>
<Suspense
fallback={<ProductsGridPlaceholder />}
key={`products-${props.params.collection}`}
>
<CategoryPage {...props} />
</Suspense>
</div>
</div>
</div>
</div>
<FAQ handle="plp-faqs" />
<Suspense>
<Manufacturers
variant={
(props.params.collection as string).includes('engines') ? 'engines' : 'transmissions'
}
/>
</Suspense>
</>
);
}

View File

@ -1,12 +1,10 @@
import Footer from 'components/layout/footer';
import { Suspense } from 'react';
export default function SearchLayout({ children }: { children: React.ReactNode }) {
return (
<>
<div className="mx-auto mt-6 min-h-[500px] max-w-screen-2xl px-8 pb-4 lg:min-h-[800px]">
<Suspense>{children}</Suspense>
</div>
<div className="min-h-[500px] lg:min-h-[800px]">{children}</div>
<Footer />
</>
);

View File

@ -12,7 +12,10 @@ import {
import { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers';
export async function addItem(prevState: any, selectedVariantIds: Array<string>) {
export async function addItem(
prevState: any,
selectedVariantIds: Array<{ merchandiseId: string; quantity: number }>
) {
let cartId = cookies().get('cartId')?.value;
let cart;
@ -31,10 +34,8 @@ export async function addItem(prevState: any, selectedVariantIds: Array<string>)
}
try {
await addToCart(
cartId,
selectedVariantIds.map((variantId) => ({ merchandiseId: variantId, quantity: 1 }))
);
const cart = await addToCart(cartId, selectedVariantIds);
console.log({ cartLines: cart.lines });
revalidateTag(TAGS.cart);
} catch (e) {
return 'Error adding item to cart';
@ -65,7 +66,6 @@ export async function setMetafields(
revalidateTag(TAGS.cart);
} catch (e) {
console.log(e);
return 'Error set cart attributes';
}
}

View File

@ -18,7 +18,7 @@ function SubmitButton({
}) {
const { pending } = useFormStatus();
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';
if (!availableForSale) {
@ -80,11 +80,20 @@ export function AddToCart({
const coreVariantId = searchParams.get(CORE_VARIANT_ID_KEY);
// remove special core-waiver value as it is not a valid variant
const selectedVariantIds = [coreVariantId, selectedVariantId]
.filter(Boolean)
.filter((value) => value !== CORE_WAIVER) as string[];
const addingVariants = (
[coreVariantId, selectedVariantId]
.filter(Boolean)
.filter((value) => value !== CORE_WAIVER) as string[]
).map((id) => ({ merchandiseId: id, quantity: 1 }));
const actionWithVariant = formAction.bind(null, selectedVariantIds);
if (variant?.addOnProduct) {
addingVariants.push({
merchandiseId: variant.addOnProduct.id,
quantity: variant.addOnProduct.quantity
});
}
const actionWithVariant = formAction.bind(null, addingVariants);
return (
<form action={actionWithVariant}>

View File

@ -36,10 +36,11 @@ function SubmitButton() {
export function DeleteItemButton({ item }: { item: CartItem }) {
const [message, formAction] = useFormState(removeItem, null);
const { id: itemId, coreCharge } = item;
const { id: itemId, coreCharge, addOnProduct } = item;
const actionWithVariant = formAction.bind(null, [
itemId,
...(coreCharge?.id ? [coreCharge.id] : [])
...(coreCharge?.id ? [coreCharge.id] : []),
...(addOnProduct?.id ? [addOnProduct.id] : [])
]);
return (

View File

@ -51,11 +51,19 @@ export function EditItemQuantityButton({ item, type }: { item: CartItem; type: '
if (item.coreCharge) {
payload.push({
lineId: item.coreCharge.id,
variantId: item.coreCharge.id,
variantId: item.coreCharge.merchandise.id,
quantity
});
}
if (item.addOnProduct) {
payload.push({
lineId: item.addOnProduct.id,
variantId: item.addOnProduct.merchandise.id,
quantity: quantity * item.addOnProduct.quantity
});
}
const actionWithVariant = formAction.bind(null, payload);
return (

View File

@ -21,7 +21,11 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
const openCart = () => setIsOpen(true);
const closeCart = () => setIsOpen(false);
const { control, handleSubmit } = useForm<VehicleFormSchema>({
resolver: zodResolver(vehicleFormSchema)
resolver: zodResolver(vehicleFormSchema),
defaultValues: {
customer_vin: cart?.attributes.find((a) => a.key === 'customer_vin')?.value || '',
customer_mileage: cart?.attributes.find((a) => a.key === 'customer_mileage')?.value || ''
}
});
const [loading, setLoading] = useState(false);

View File

@ -1,16 +1,13 @@
import { PhoneIcon } from '@heroicons/react/24/outline';
import { getMetaobject } from 'lib/shopify';
import kebabCase from 'lodash.kebabcase';
import Image from 'next/image';
import { Suspense } from 'react';
import AccordionBlock from './page/accordion-block';
import Tag from './tag';
const { SITE_NAME } = process.env;
const FAQ = async () => {
const FAQ = async ({ handle }: { handle: string }) => {
const faqs = await getMetaobject({
handle: { handle: `${kebabCase(SITE_NAME)}-faqs`, type: 'accordion' }
handle: { handle, type: 'accordion' }
});
if (!faqs) return null;

View File

@ -6,7 +6,7 @@ import { Menu, Metaobject } from 'lib/shopify/types';
import { createUrl, findParentCollection } from 'lib/shopify/utils';
import get from 'lodash.get';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import FilterField from './field';
type FiltersListProps = {
@ -31,11 +31,13 @@ const FiltersList = ({ years, makes, models, menu, autoFocusField }: FiltersList
PART_TYPES.find((type) => type.value === partTypeCollection) || null
);
const makeIdFromSearchParams = searchParams.get(MAKE_FILTER_ID);
const [make, setMake] = useState<Metaobject | null>(
(partType &&
makes.find((make) =>
searchParams.get(MAKE_FILTER_ID)
? make.id === searchParams.get(MAKE_FILTER_ID)
makeIdFromSearchParams
? make.id === makeIdFromSearchParams
: params.collection?.includes(make.name!.toLowerCase())
)) ||
null
@ -52,6 +54,22 @@ const FiltersList = ({ years, makes, models, menu, autoFocusField }: FiltersList
const disabled = !partType || !make || !model || !year;
useEffect(() => {
if (partType) {
const _make = makes.find((make) =>
makeIdFromSearchParams
? make.id === makeIdFromSearchParams
: params.collection?.includes(make.name!.toLowerCase())
);
if (_make) {
setMake(_make);
setModel(null);
setYear(null);
}
}
}, [makeIdFromSearchParams, makes, params.collection, partType]);
const onChangeMake = (value: Metaobject | null) => {
setMake(value);
setModel(null);

View File

@ -1,10 +1,52 @@
import { ArrowRightIcon, PhotoIcon } from '@heroicons/react/24/solid';
import clsx from 'clsx';
import Price from 'components/price';
import { Product } from 'lib/shopify/types';
import { CONDITIONS } from 'lib/constants';
import { Product, ProductVariant } from 'lib/shopify/types';
import Image from 'next/image';
import Link from 'next/link';
const PriceSection = ({ variants }: { variants: ProductVariant[] }) => {
const usedVariants = variants.filter((variant) => variant.condition === CONDITIONS.Used);
const minUsedVariantPrice = usedVariants.length
? usedVariants.reduce(
(min, variant) => Math.min(min, Number(variant.price.amount)),
Number(usedVariants[0]?.price.amount)
)
: null;
const remanVariants = variants.filter(
(variant) => variant.condition === CONDITIONS.Remanufactured
);
const minRemanufacturedPrice = remanVariants.length
? remanVariants.reduce(
(min, variant) => Math.min(min, Number(variant.price.amount)),
Number(remanVariants[0]?.price.amount)
)
: null;
const currencyCode = variants[0]?.price.currencyCode || 'USD';
return (
<div className="flex w-full flex-col gap-1">
{typeof minUsedVariantPrice === 'number' && (
<div className="flex flex-row items-center justify-between">
<span className="text-sm">{CONDITIONS.Used}</span>
<Price amount={String(minUsedVariantPrice)} currencyCode={currencyCode} />
</div>
)}
{typeof minRemanufacturedPrice === 'number' && (
<div className="flex flex-row items-center justify-between">
<span className="text-sm">{CONDITIONS.Remanufactured}</span>
<Price amount={String(minRemanufacturedPrice)} currencyCode={currencyCode} />
</div>
)}
</div>
);
};
export function GridTileImage({
active,
product,
@ -17,7 +59,7 @@ export function GridTileImage({
} & React.ComponentProps<typeof Image>) {
const metafieldKeys = ['engineCylinders', 'fuelType'] as Partial<keyof Product>[];
const shouldShowDescription = metafieldKeys.some((key) => product[key]);
const variantsWithCondition = product.variants.filter((variant) => variant.condition !== null);
return (
<div className="flex h-full flex-col rounded-b border bg-white">
<div className="grow">
@ -76,12 +118,16 @@ export function GridTileImage({
) : null}
</div>
)}
<div className="flex justify-end border-t py-2">
<Price
className="text-lg font-medium text-gray-900"
amount={product.priceRange.minVariantPrice.amount}
currencyCode={product.priceRange.minVariantPrice.currencyCode}
/>
<div className="flex justify-end border-t py-3">
{variantsWithCondition.length ? (
<PriceSection variants={variantsWithCondition} />
) : (
<Price
className="text-lg font-medium text-gray-900"
amount={product.priceRange.minVariantPrice.amount}
currencyCode={product.priceRange.minVariantPrice.currencyCode}
/>
)}
</div>
</div>

View File

@ -1,15 +1,13 @@
import DisplayTabs from 'components/display-tabs';
import RichTextDisplay from 'components/page/rich-text-display';
import { getMetaobject, getMetaobjectsByIds } from 'lib/shopify';
import kebabCase from 'lodash.kebabcase';
import Image from 'next/image';
import Tag from '../tag';
import ButtonLink from './button-link';
const { SITE_NAME } = process.env;
const About = async () => {
const aboutUs = await getMetaobject({
handle: { type: 'about_us', handle: `${kebabCase(SITE_NAME)}-about` }
handle: { type: 'about_us', handle: 'about-us' }
});
if (!aboutUs) return null;

View File

@ -2,14 +2,24 @@ import ManufacturersGrid from 'components/manufacturers-grid';
import Tag from 'components/tag';
import { getMetaobjects } from 'lib/shopify';
const Manufacturers = async () => {
const Manufacturers = async ({
variant = 'home'
}: {
variant?: 'engines' | 'transmissions' | 'home';
}) => {
const manufacturers = await getMetaobjects('make');
const title: Record<typeof variant, string> = {
engines: 'Engines',
home: 'Parts',
transmissions: 'Transmissions'
};
return (
<div className="px-6 py-20">
<div className="mx-auto flex max-w-7xl flex-col gap-3">
<Tag text="Get Started" />
<h3 className="mb-3 text-3xl font-semibold lg:text-4xl">Browse Parts By Manufacturer</h3>
<ManufacturersGrid manufacturers={manufacturers} />
<h3 className="mb-3 text-3xl font-semibold lg:text-4xl">{`Browse ${title[variant]} By Manufacturer`}</h3>
<ManufacturersGrid manufacturers={manufacturers} variant={variant} />
</div>
</div>
);

View File

@ -1,14 +1,13 @@
import ImageDisplay from 'components/page/image-display';
import RichTextDisplay from 'components/page/rich-text-display';
import { getMetaobject, getMetaobjectsByIds } from 'lib/shopify';
import kebabCase from 'lodash.kebabcase';
import { Suspense } from 'react';
import Tag from '../tag';
const { SITE_NAME } = process.env;
const WhyChoose = async () => {
const whyChooseContent = await getMetaobject({
handle: { type: 'why_choose', handle: `${kebabCase(SITE_NAME)}-why-choose` }
handle: { type: 'why_choose', handle: 'why-choose' }
});
if (!whyChooseContent || !whyChooseContent.items) return null;

View File

@ -0,0 +1,44 @@
import { getCollection, getMetaobjectsByIds } from 'lib/shopify';
import Link from 'next/link';
const LinkItem = async ({
collectionLinkId,
anchorText
}: {
collectionLinkId: string;
anchorText: string;
}) => {
const collection = await getCollection({ id: collectionLinkId });
if (!collection) return null;
return (
<Link href={collection.path} className="border p-2 text-sm text-gray-600">
{anchorText}
</Link>
);
};
const HelpfulLinks = async ({ collection }: { collection: string }) => {
const collectionData = await getCollection({ handle: collection });
if (!collectionData || !collectionData.helpfulLinks) return null;
const helpfulLinks = await getMetaobjectsByIds(collectionData.helpfulLinks);
return (
<div className="py-4">
<div className="mb-4 text-sm font-medium text-gray-900">Helpful links</div>
<div className="flex flex-wrap items-center gap-2">
{helpfulLinks.map((link) => (
<LinkItem
key={link.id}
collectionLinkId={link.collection_link!}
anchorText={link.anchor_text!}
/>
))}
</div>
</div>
);
};
export default HelpfulLinks;

View File

@ -1,11 +1,12 @@
import { GlobeAltIcon, StarIcon } from '@heroicons/react/24/outline';
import { MAKE_FILTER_ID } from 'lib/constants';
import { Metaobject } from 'lib/shopify/types';
import ButtonGroup from './button-group';
import ManufacturerItem from './item';
type ManufacturersGridProps = {
manufacturers: Metaobject[];
variant?: 'engine' | 'transmission' | 'home';
variant?: 'engines' | 'transmissions' | 'home';
};
const ManufacturersGrid = ({ manufacturers, variant = 'home' }: ManufacturersGridProps) => {
@ -22,7 +23,15 @@ const ManufacturersGrid = ({ manufacturers, variant = 'home' }: ManufacturersGri
<div className="mt-6 grid grid-cols-2 gap-x-12 gap-y-5 md:grid-cols-3 md:gap-y-8 lg:grid-cols-4 xl:grid-cols-5">
{popularManufacturers.map((manufacturer) => (
<div key={manufacturer.id} className="flex flex-col gap-2">
<ManufacturerItem manufacturer={manufacturer} />
{variant === 'home' ? (
<ManufacturerItem manufacturer={manufacturer} />
) : (
<ManufacturerItem
manufacturer={manufacturer}
className={'rounded border border-primary px-2 py-1'}
href={`/search/${variant}?${MAKE_FILTER_ID}=${manufacturer.id}`}
/>
)}
{variant === 'home' && <ButtonGroup manufacturer={manufacturer} />}
</div>
))}
@ -37,7 +46,15 @@ const ManufacturersGrid = ({ manufacturers, variant = 'home' }: ManufacturersGri
.toSorted((a, b) => a.display_name!.localeCompare(b.display_name!))
.map((manufacturer) => (
<div key={manufacturer.id} className="flex flex-col gap-2">
<ManufacturerItem manufacturer={manufacturer} />
{variant === 'home' ? (
<ManufacturerItem manufacturer={manufacturer} />
) : (
<ManufacturerItem
manufacturer={manufacturer}
className={'rounded border border-primary px-2 py-1'}
href={`/search/${variant}?${MAKE_FILTER_ID}=${manufacturer.id}`}
/>
)}
{variant === 'home' && <ButtonGroup manufacturer={manufacturer} />}
</div>
))}

View File

@ -1,16 +1,19 @@
import ImageDisplay from 'components/page/image-display';
import { Metaobject } from 'lib/shopify/types';
import Link from 'next/link';
import { Suspense } from 'react';
import { twMerge } from 'tailwind-merge';
const ManufacturerItem = ({
manufacturer,
className
className,
href
}: {
manufacturer: Metaobject;
className?: string;
href?: string;
}) => {
return (
const children = (
<div className={twMerge('flex w-full flex-row items-center justify-between', className)}>
<span className="text-sm leading-5">{manufacturer.display_name}</span>
<div className="hidden md:block">
@ -25,6 +28,11 @@ const ManufacturerItem = ({
</div>
</div>
);
if (href) {
return <Link href={href}>{children}</Link>;
}
return children;
};
export default ManufacturerItem;

View File

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

View File

@ -0,0 +1,16 @@
import { Product } from 'lib/shopify/types';
import Details from './details';
import ShippingPolicy from './shipping-policy';
import WarrantyPolicy from './warranty-policy';
const AdditionalInformation = ({ product }: { product: Product }) => {
return (
<div className="my-5 w-full divide-y">
<Details product={product} />
<WarrantyPolicy />
<ShippingPolicy />
</div>
);
};
export default AdditionalInformation;

View File

@ -93,8 +93,8 @@ const CoreCharge = ({ variants }: CoreChargeProps) => {
period, you will never need to pay the core charge.
</p>
<p className="text-sm">
If you don't manage to return the old part within the 30-day period, we will then
charge you the core charge. This keeps more money in your pocket upfront.
If you don&apos;t manage to return the old part within the 30-day period, we will
then charge you the core charge. This keeps more money in your pocket upfront.
</p>
</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,82 @@
'use client';
import clsx from 'clsx';
import Price from 'components/price';
import { Product } from 'lib/shopify/types';
import { useSearchParams } from 'next/navigation';
import DisclosureSection from './disclosure-section';
const Details = ({ product }: { product: Product }) => {
const searchParams = useSearchParams();
const variants = product.variants;
const variant = variants.find((variant) =>
variant.selectedOptions.every(
(option) => option.value === searchParams.get(option.name.toLowerCase())
)
);
const details = [
...(product.transmissionTag
? [
{
title: 'Transmission Tag',
value: product.transmissionTag.join()
}
]
: []),
...(product.transmissionCode
? [
{
title: 'Transmission Code',
value: product.transmissionCode.join()
}
]
: []),
...(product.transmissionSpeeds
? [
{
title: 'Transmission Speeds',
value: product.transmissionSpeeds.map((speed) => `${speed}-Speed`).join()
}
]
: [])
];
return (
<DisclosureSection title="Product Details" defaultOpen>
<div className="flex w-full items-center p-1">
<span className="basis-2/5">Condition</span>
<span>{variant?.condition || 'N/A'}</span>
</div>
<div className="flex w-full items-center bg-gray-100 p-1">
<span className="basis-2/5">Price</span>
<Price
amount={variant?.price.amount || product.priceRange.minVariantPrice.amount}
currencyCode={
variant?.price.currencyCode || product.priceRange.minVariantPrice.currencyCode
}
/>
</div>
<div className="flex w-full items-center p-1">
<span className="basis-2/5">Warranty</span>
<span />
</div>
<div className="flex w-full items-center bg-gray-100 p-1">
<span className="basis-2/5">Cylinders</span>
<span>{product.engineCylinders?.map((cylinder) => `${cylinder} Cylinders`).join()}</span>
</div>
{details.map(({ title, value }, index) => (
<div
key={index}
className={clsx('flex w-full items-center p-1', { 'bg-gray-100': index % 2 !== 0 })}
>
<span className="basis-2/5">{title}</span>
<span>{value}</span>
</div>
))}
</DisclosureSection>
);
};
export default Details;

View File

@ -0,0 +1,25 @@
'use client';
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import { ReactNode } from 'react';
type DisclosureProps = {
children: ReactNode;
defaultOpen?: boolean;
title: string;
};
const DisclosureSection = ({ children, title, defaultOpen }: DisclosureProps) => {
return (
<Disclosure as="div" className="p-3" defaultOpen={defaultOpen}>
<DisclosureButton className="group flex w-full items-center justify-between">
<span className="font-medium">{title}</span>
<ChevronDownIcon className="size-4 group-data-[open]:rotate-180" />
</DisclosureButton>
<DisclosurePanel className="mt-2 py-2 text-sm">{children}</DisclosurePanel>
</Disclosure>
);
};
export default DisclosureSection;

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

@ -2,7 +2,11 @@ import { AddToCart } from 'components/cart/add-to-cart';
import Prose from 'components/prose';
import { Product } from 'lib/shopify/types';
import { Suspense } from 'react';
import AdditionalInformation from './additional-information';
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 VariantDetails from './vairant-details';
import { VariantSelector } from './variant-selector';
@ -11,7 +15,7 @@ import Warranty from './warranty';
export function ProductDescription({ product }: { product: Product }) {
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>
<VariantDetails
@ -34,6 +38,7 @@ export function ProductDescription({ product }: { product: Product }) {
/>
) : null}
<ProductDetails product={product} />
<div className="mb-2 border-t py-4 dark:border-neutral-700">
<CoreCharge variants={product.variants} />
</div>
@ -42,12 +47,16 @@ export function ProductDescription({ product }: { product: Product }) {
<Warranty />
</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}>
<AddToCart variants={product.variants} availableForSale={product.availableForSale} />
</Suspense>
<div className="mt-4 border-t pt-4">
<SpecialOffer />
</div>
<SpecialOffer />
<AdditionalInformation product={product} />
</>
);
}

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

@ -0,0 +1,44 @@
import DisclosureSection from './disclosure-section';
const { SITE_NAME } = process.env;
const ShippingPolicy = () => {
return (
<DisclosureSection title="Shipping & returns">
<p>
At {SITE_NAME}, we offer a Flat Rate Shipping (Commercial address) service as long as the
delivery address is in a commercially zoned location. Unfortunately, residential and home
businesses are not considered commercial addresses. A business or commercial address
location must be able to receive freight without the requirement of prior appointment setup
or notification. This location should also have the capability of unloading the
remanufactured transmission with a forklift from the delivery truck. If you don&apos;t have
a commercial or business address that meets these specifications, you should ship it
directly to the dealership or repair shop that is performing the repairs to ensure you enjoy
Flat Rate Shipping (Commercial address). Residential delivery or Liftgate service will
result in additional $99 fee.
</p>
<p className="my-3">
After placing the order for a remanufactured transmission, most customers will receive it
within 7-14 business days not including holidays or weekends. Please keep in mind that
certain locations (remote areas) and locations in Colorado, Utah, New York, Oregon, and
California may require an additional delivery fee. In either case, we will always ship your
remanufactured transmission out as soon as possible. Because of weather conditions,
increasing order volumes, and conditions outside of our control, all shipping times are
estimates, not guarantees. It&apos;s important to note that {SITE_NAME} will not be liable
for any extra fees the carrier may levy due to storage or redelivery. While every
transmission from {SITE_NAME} has been rigorously inspected and tested prior to being
shipped, damage may occur during transportation.
</p>
<p>
As such, we strongly suggest you carefully inspect your transmission upon receipt. If you
notice any missing parts, wrong parts, or damage, you should report it prior to signing any
delivery documentation. It&quot;s imperative to report missing parts, damage, or wrong parts
at the time of delivery. If you fail to do so prior to signing your shipping documents,
responsibility will be placed on the purchaser or receiver. For clarity,
&quot;purchaser&quot; refers to any representative of the company designated to sign for the
delivery of the remanufactured transmission.
</p>
</DisclosureSection>
);
};
export default ShippingPolicy;

View File

@ -1,28 +1,71 @@
import { CurrencyDollarIcon, ShieldCheckIcon, UsersIcon } from '@heroicons/react/24/outline';
import { TruckIcon } from '@heroicons/react/24/solid';
import {
ArrowPathIcon,
CurrencyDollarIcon,
ShieldCheckIcon,
StarIcon,
TruckIcon,
UsersIcon
} from '@heroicons/react/24/outline';
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 text-sm tracking-normal text-neutral-800 lg:text-base dark:text-white">
<p className="flex items-center gap-3">
<TruckIcon className="h-4 w-4 text-secondary lg:h-5 lg:w-5" /> Flat Rate Shipping
(Commercial Address)
</p>
<p className="flex items-center gap-3">
<ShieldCheckIcon className="h-4 w-4 text-secondary lg:h-5 lg:w-5" /> Up to 5 Years
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 className="mt-10 grid grid-cols-2 gap-y-5 xl:grid-cols-3">
<div className="flex items-start gap-3">
<TruckIcon className="size-12 text-primary" />
<div className="flex flex-col">
<span className="font-medium uppercase">Flat Rate Shipping</span>
<span className="text-sm font-light">
We offer a flat $299 shipping fee to commercial addresses
</span>
</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';
import { CheckCircleIcon } from '@heroicons/react/24/outline';
import Price from 'components/price';
import { Money, ProductVariant } from 'lib/shopify/types';
import { useSearchParams } from 'next/navigation';
@ -20,17 +21,23 @@ const VariantDetails = ({ variants, defaultPrice }: VariantDetailsProps) => {
const price = variant?.price.amount || defaultPrice.amount;
return (
<>
<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>
<div className="mt-1">
<Price
amount={price}
currencyCode={variant?.price.currencyCode || defaultPrice.currencyCode}
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

@ -0,0 +1,102 @@
import {
ArrowPathIcon,
ArrowsRightLeftIcon,
CurrencyDollarIcon,
FlagIcon
} from '@heroicons/react/24/outline';
import DisclosureSection from './disclosure-section';
const { SITE_NAME } = process.env;
const WarrantyPolicy = () => {
return (
<DisclosureSection title="Warranty">
<div className="mb-3 font-medium">Year 2001 and Newer</div>
<div className="flex items-center p-1">
<span className="basis-1/2">Personal/Individual Transmission Warranty</span>
<span>60 Months/ Unlimited Mileage</span>
</div>
<div className="flex items-center bg-gray-100 p-1">
<span className="basis-1/2">Commercial Transmissions Warranty</span>
<span>Prior to 03/01/2020 18 Months/ 100,000 Miles</span>
</div>
<div className="flex items-center p-1">
<span className="basis-1/2">Commercial Transmissions Warranty</span>
<span>Effective 03/01/2020 36 Months/ Unlimited Mileage</span>
</div>
<div className="flex items-center bg-gray-100 p-1">
<span className="basis-1/2">Continuously Variable Transmission (CVT) Warranty</span>
<span>36 Months/ Unlimited Mileage</span>
</div>
<div className="flex items-center p-1">
<span className="basis-1/2">Manual Transmission Warranty</span>
<span>36 Months/ Unlimited Miles</span>
</div>
<div className="my-3 font-medium">Year 2000 and Older</div>
<div className="flex items-center p-1">
<span className="basis-1/2">Personal/Individual Transmission Warranty</span>
<span>36 Months/ Unlimited Mileage</span>
</div>
<div className="flex items-center bg-gray-100 p-1">
<span className="basis-1/2">Commercial Transmissions Warranty</span>
<span>18 Months/ 100,000 Miles</span>
</div>
<div className="flex items-center p-1">
<span className="basis-1/2">Commercial Transmissions Warranty</span>
<span>36 Months/ Unlimited Mileage</span>
</div>
<div className="flex items-center bg-gray-100 p-1">
<span className="basis-1/2">Continuously Variable Transmission (CVT) Warranty</span>
<span>36 Months/ Unlimited Miles</span>
</div>
<div className="my-5">
<div className="mb-1 flex items-center gap-2 font-medium">
<ArrowsRightLeftIcon className="size-4 text-primary" />
Easy, Hassle-Free, Transferable Warranty
</div>
<p>
At {SITE_NAME}, we offer an easy, transferable, hassle-free warranty. Instead of being
associated only with you, the warranty is attached to your Vehicle Identification Number.
As such, the warranty is transferable with vehicle ownership, which means you never have
to worry about any paperwork or fees involved. Please note, that the used parts warranty
is not transferable.
</p>
</div>
<div className="my-5">
<div className="mb-1 flex items-center gap-2 font-medium">
<FlagIcon className="size-4 text-primary" />
Nationwide Coverage
</div>
<p>
Whether you&apos;re in California, Chicago, New York, Florida, or anywhere in between, you
are covered with a nationwide warranty. This warranty covers you anywhere in the
continental U.S.
</p>
</div>
<div className="my-5">
<div className="mb-1 flex items-center gap-2 font-medium">
<ArrowPathIcon className="size-4 text-primary" />
Instant Replacement
</div>
<p>
With instant replacement, your replacement transmission will be sent out as soon as you
submit your claim. This way you can spend less time waiting and more time doing whatever
needs to be done.
</p>
</div>
<div className="my-5">
<div className="mb-1 flex items-center gap-2 font-medium">
<CurrencyDollarIcon className="size-4 text-primary" />
Paid Parts & Labor
</div>
<p>
When you have your work performed in a certified shop, your {SITE_NAME} warranty will pay
for parts and labor at $50 an hour, which is the Mitchell labor reimbursement rate.
</p>
</div>
</DisclosureSection>
);
};
export default WarrantyPolicy;

View File

@ -46,3 +46,15 @@ export const MODEL_FILTER_ID = 'filter.p.m.custom.make_model_composite';
export const YEAR_FILTER_ID = 'filter.p.m.custom.make_model_year_composite';
export const PRODUCT_METAFIELD_PREFIX = 'filter.p.m';
export const VARIANT_METAFIELD_PREFIX = 'filter.v.m';
export const CONDITIONS = {
Used: 'Used',
Remanufactured: 'Remanufactured'
};
export const DELIVERY_OPTION_KEY = 'delivery';
export const ADD_ON_PRODUCT_TYPES = {
addOn: 'Add On',
coreCharge: 'Core Charge'
};

View File

@ -1,9 +1,13 @@
import productFragment from './product';
import imageFragment from './image';
const cartFragment = /* GraphQL */ `
fragment cart on Cart {
id
checkoutUrl
attributes {
key
value
}
cost {
subtotalAmount {
amount
@ -38,11 +42,22 @@ const cartFragment = /* GraphQL */ `
value
}
product {
...product
featuredImage {
...image
}
handle
title
productType
}
coreVariantId: metafield(key: "coreVariant", namespace: "custom") {
value
}
addOnQuantity: metafield(namespace: "custom", key: "add_on_quantity") {
value
}
addOnProductId: metafield(namespace: "custom", key: "add_on") {
value
}
}
}
}
@ -50,7 +65,7 @@ const cartFragment = /* GraphQL */ `
}
totalQuantity
}
${productFragment}
${imageFragment}
`;
export default cartFragment;

View File

@ -64,6 +64,12 @@ const productFragment = /* GraphQL */ `
condition: metafield(namespace: "custom", key: "condition") {
value
}
addOnQuantity: metafield(namespace: "custom", key: "add_on_quantity") {
value
}
addOnProductId: metafield(namespace: "custom", key: "add_on") {
value
}
}
}
}
@ -76,6 +82,21 @@ const productFragment = /* GraphQL */ `
fuelType: metafield(namespace: "custom", key: "fuel") {
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) {
edges {
node {

View File

@ -1,4 +1,5 @@
import {
ADD_ON_PRODUCT_TYPES,
AVAILABILITY_FILTER_ID,
HIDDEN_PRODUCT_TAG,
MAKE_FILTER_ID,
@ -41,7 +42,6 @@ import {
Address,
Cart,
CartAttributeInput,
CartItem,
Collection,
Connection,
Customer,
@ -84,6 +84,7 @@ import {
ShopifyRemoveFromCartOperation,
ShopifySetCartAttributesOperation,
ShopifyUpdateCartOperation,
TransmissionType,
ShopifyCustomer,
ShopifyOrder,
ShopifyAddress,
@ -260,7 +261,7 @@ const reshapeCart = (cart: ShopifyCart): Cart => {
...lineItem,
merchandise: {
...lineItem.merchandise,
product: reshapeProduct(lineItem.merchandise.product)
product: lineItem.merchandise.product
}
}))
};
@ -273,6 +274,7 @@ const reshapeCollection = (collection: ShopifyCollection): Collection | undefine
return {
...collection,
helpfulLinks: parseMetaFieldValue<string[]>(collection.helpfulLinks),
path: `/search/${collection.handle}`
};
};
@ -374,18 +376,29 @@ const reshapeImages = (images: Connection<Image>, productTitle: string) => {
};
const reshapeVariants = (variants: ShopifyProductVariant[]): ProductVariant[] => {
return variants.map((variant) => ({
return variants.map(({ addOnProductId, addOnQuantity, ...variant }) => ({
...variant,
waiverAvailable: parseMetaFieldValue<boolean>(variant.waiverAvailable),
coreVariantId: variant.coreVariantId?.value || null,
coreCharge: parseMetaFieldValue<Money>(variant.coreCharge),
mileage: variant.mileage?.value ?? null,
estimatedDelivery: variant.estimatedDelivery?.value || null,
condition: variant.condition?.value || null
condition: variant.condition?.value || null,
...(addOnProductId
? {
addOnProduct: {
id: addOnProductId.value,
quantity: addOnQuantity?.value ? Number(addOnQuantity.value) : 1
}
}
: {})
}));
};
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))) {
return undefined;
}
@ -393,6 +406,13 @@ const reshapeProduct = (product: ShopifyProduct, filterHiddenProducts: boolean =
const { images, variants, ...rest } = product;
return {
...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),
fuelType: product.fuelType?.value || null,
images: reshapeImages(images, product.title),
@ -633,20 +653,34 @@ export async function getCart(cartId: string): Promise<Cart | undefined> {
const cart = reshapeCart(res.body.data.cart);
// attach core charge as an additional attribute of a cart line, and remove the core charge line from cart
const extendedCartLines = cart?.lines.reduce((lines, item) => {
const coreVariantId = item.merchandise.coreVariantId?.value;
if (coreVariantId) {
const relatedCoreCharge = cart.lines.find((line) => line.merchandise.id === coreVariantId);
return lines.concat([
{
...item,
coreCharge: relatedCoreCharge
}
]);
}
const extendedCartLines = cart?.lines
.map((item) => {
const coreVariantId = item.merchandise.coreVariantId?.value;
const addOnProductId = item.merchandise.addOnProductId;
const _item = { ...item };
return lines;
}, [] as CartItem[]);
if (coreVariantId) {
const relatedCoreCharge = cart.lines.find((line) => line.merchandise.id === coreVariantId);
_item.coreCharge = relatedCoreCharge;
}
if (addOnProductId) {
const relatedAddOnProduct = cart.lines.find(
(line) => line.merchandise.id === addOnProductId.value
);
_item.addOnProduct = relatedAddOnProduct
? {
...relatedAddOnProduct,
quantity: item.merchandise.addOnQuantity
? Number(item.merchandise.addOnQuantity.value)
: 1
}
: undefined;
}
return _item;
})
// core charge shouldn't present as a dedicated product as it's tightly coupled with the product
.filter((item) => item.merchandise.product.productType !== ADD_ON_PRODUCT_TYPES.coreCharge);
const totalQuantity = extendedCartLines.reduce((sum, line) => sum + line.quantity, 0);
@ -730,7 +764,8 @@ export async function getCollections(): Promise<Collection[]> {
description: 'All products'
},
path: '/search',
updatedAt: new Date().toISOString()
updatedAt: new Date().toISOString(),
helpfulLinks: null
},
// Filter out the `hidden` collections.
// Collections that start with `hidden-*` need to be hidden on the search page.

View File

@ -9,6 +9,9 @@ const collectionFragment = /* GraphQL */ `
seo {
...seo
}
helpfulLinks: metafield(namespace: "custom", key: "helpful_links") {
value
}
updatedAt
}
${seoFragment}

View File

@ -25,14 +25,24 @@ export type CartItem = {
name: string;
value: string;
}[];
product: Product;
product: {
id: string;
handle: string;
title: string;
featuredImage: Image;
productType: string;
};
coreVariantId: { value: string } | null;
addOnQuantity: { value: string } | null;
addOnProductId: { value: string } | null;
};
coreCharge?: CartItem;
addOnProduct?: CartItem & { quantity: number };
};
export type Collection = ShopifyCollection & {
export type Collection = Omit<ShopifyCollection, 'helpfulLinks'> & {
path: string;
helpfulLinks: string[] | null;
};
export type Customer = {
@ -379,14 +389,29 @@ export type Metaobject = {
[key: string]: string;
};
export type TransmissionType = 'Automatic' | 'Manual';
export type Product = Omit<
ShopifyProduct,
'variants' | 'images' | 'fuelType' | 'engineCylinders'
| 'variants'
| 'images'
| 'fuelType'
| 'engineCylinders'
| 'driveType'
| 'transmissionType'
| 'transmissionSpeeds'
| 'transmissionCode'
| 'transmissionTag'
> & {
variants: ProductVariant[];
images: Image[];
fuelType: string | null;
engineCylinders: number[] | null;
driveType: string[] | null;
transmissionType: TransmissionType | null;
transmissionSpeeds: number[] | null;
transmissionCode: string[] | null;
transmissionTag: string[] | null;
};
export type ProductOption = {
@ -414,6 +439,10 @@ export type ProductVariant = {
condition: string | null;
engineCylinders: string | null;
fuelType: string | null;
addOnProduct?: {
quantity: number;
id: string;
};
};
export type ShopifyCartProductVariant = {
@ -432,7 +461,13 @@ export type CartProductVariant = Omit<ShopifyCartProductVariant, 'coreVariantId'
export type ShopifyProductVariant = Omit<
ProductVariant,
'coreCharge' | 'waiverAvailable' | 'coreVariantId' | 'mileage' | 'estimatedDelivery' | 'condition'
| 'coreCharge'
| 'waiverAvailable'
| 'coreVariantId'
| 'mileage'
| 'estimatedDelivery'
| 'condition'
| 'addOnProduct'
> & {
waiverAvailable: { value: string };
coreVariantId: { value: string } | null;
@ -440,6 +475,8 @@ export type ShopifyProductVariant = Omit<
mileage: { value: number } | null;
estimatedDelivery: { value: string } | null;
condition: { value: string } | null;
addOnProductId: { value: string } | null;
addOnQuantity: { value: string } | null;
};
export type SEO = {
@ -450,6 +487,7 @@ export type SEO = {
export type ShopifyCart = {
id: string;
checkoutUrl: string;
attributes: { key: string; value: string }[];
cost: {
subtotalAmount: Money;
totalAmount: Money;
@ -465,6 +503,7 @@ export type ShopifyCollection = {
description: string;
seo: SEO;
updatedAt: string;
helpfulLinks: { value: string } | null;
};
export type ShopifyProduct = {
@ -493,6 +532,11 @@ export type ShopifyProduct = {
};
engineCylinders: { 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 = {