mirror of
https://github.com/vercel/commerce.git
synced 2025-05-12 04:37:51 +00:00
fix: update core charge appearance
Signed-off-by: Chloe <pinkcloudvnn@gmail.com>
This commit is contained in:
parent
3a3ff3798f
commit
3bf7fa5af9
@ -10,10 +10,10 @@ import { useFormState, useFormStatus } from 'react-dom';
|
||||
|
||||
function SubmitButton({
|
||||
availableForSale,
|
||||
selectedVariantId
|
||||
disabled
|
||||
}: {
|
||||
availableForSale: boolean;
|
||||
selectedVariantId: string | undefined;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const { pending } = useFormStatus();
|
||||
const buttonClasses =
|
||||
@ -28,7 +28,7 @@ function SubmitButton({
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedVariantId) {
|
||||
if (disabled) {
|
||||
return (
|
||||
<button
|
||||
aria-label="Please select an option"
|
||||
@ -75,11 +75,15 @@ export function AddToCart({
|
||||
)
|
||||
);
|
||||
const selectedVariantId = variant?.id || defaultVariantId;
|
||||
const missingCoreVariantId = variant?.coreVariantId && !searchParams.has('coreVariantId');
|
||||
const actionWithVariant = formAction.bind(null, selectedVariantId);
|
||||
|
||||
return (
|
||||
<form action={actionWithVariant}>
|
||||
<SubmitButton availableForSale={availableForSale} selectedVariantId={selectedVariantId} />
|
||||
<SubmitButton
|
||||
availableForSale={availableForSale}
|
||||
disabled={Boolean(!selectedVariantId || missingCoreVariantId)}
|
||||
/>
|
||||
<p aria-live="polite" className="sr-only" role="status">
|
||||
{message}
|
||||
</p>
|
||||
|
@ -1,21 +0,0 @@
|
||||
import CoreCharge from 'components/core-charge';
|
||||
import { ProductVariant } from 'lib/shopify/types';
|
||||
|
||||
type CoreChargeBadgeProps = {
|
||||
selectedOptions: {
|
||||
name: string;
|
||||
value: string;
|
||||
}[];
|
||||
variants: ProductVariant[];
|
||||
};
|
||||
|
||||
const CoreChargeBadge = ({ variants, selectedOptions }: CoreChargeBadgeProps) => {
|
||||
const selectedOptionsMap = new Map(selectedOptions.map((option) => [option.name, option.value]));
|
||||
const variant = variants.find((variant: ProductVariant) =>
|
||||
variant.selectedOptions.every((option) => option.value === selectedOptionsMap.get(option.name))
|
||||
);
|
||||
|
||||
return <CoreCharge variant={variant} sm />;
|
||||
};
|
||||
|
||||
export default CoreChargeBadge;
|
@ -10,7 +10,6 @@ import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import CloseCart from './close-cart';
|
||||
import CoreChargeBadge from './core-charge-badge';
|
||||
import { DeleteItemButton } from './delete-item-button';
|
||||
import { EditItemQuantityButton } from './edit-item-quantity-button';
|
||||
import OpenCart from './open-cart';
|
||||
@ -136,17 +135,11 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="ml-20 flex flex-col gap-2">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Price
|
||||
className="font-semibold"
|
||||
amount={item.cost.totalAmount.amount}
|
||||
currencyCode={item.cost.totalAmount.currencyCode}
|
||||
/>
|
||||
<CoreChargeBadge
|
||||
variants={item.merchandise.product.variants}
|
||||
selectedOptions={item.merchandise.selectedOptions}
|
||||
/>
|
||||
</div>
|
||||
<Price
|
||||
className="font-semibold"
|
||||
amount={item.cost.totalAmount.amount}
|
||||
currencyCode={item.cost.totalAmount.currencyCode}
|
||||
/>
|
||||
<div className="flex h-9 w-fit flex-row items-center rounded-sm border border-neutral-300 dark:border-neutral-700">
|
||||
<EditItemQuantityButton item={item} type="minus" />
|
||||
<p className="w-6 text-center">
|
||||
|
@ -1,48 +0,0 @@
|
||||
import { ArrowPathIcon } from '@heroicons/react/16/solid';
|
||||
import { ProductVariant } from 'lib/shopify/types';
|
||||
import Price from './price';
|
||||
import Tooltip from './tooltip';
|
||||
|
||||
type CoreChargeProps = {
|
||||
variant?: ProductVariant;
|
||||
sm?: boolean;
|
||||
};
|
||||
|
||||
const CoreCharge = ({ variant, sm = false }: CoreChargeProps) => {
|
||||
if (!variant || !variant.coreCharge?.amount || variant.waiverAvailable) return null;
|
||||
|
||||
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-3 w-3" />
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
data-tooltip-id={!sm ? 'core-charge-explanation' : undefined}
|
||||
>
|
||||
{sm ? coreChargeDisplay : <>Core Charge: {coreChargeDisplay}</>}
|
||||
</span>
|
||||
<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 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 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>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoreCharge;
|
103
components/product/core-charge.tsx
Normal file
103
components/product/core-charge.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import Price from 'components/price';
|
||||
import { CORE_VARIANT_ID_KEY, CORE_WAIVER } from 'lib/constants';
|
||||
import { Money, ProductVariant } from 'lib/shopify/types';
|
||||
import { cn, createUrl } from 'lib/utils';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
type CoreChargeProps = {
|
||||
variants: ProductVariant[];
|
||||
defaultPrice: Money;
|
||||
};
|
||||
const CoreCharge = ({ variants, defaultPrice }: CoreChargeProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const optionSearchParams = new URLSearchParams(searchParams);
|
||||
const coreVariantIdSearchParam = optionSearchParams.get(CORE_VARIANT_ID_KEY);
|
||||
|
||||
const variant = variants.find((variant: ProductVariant) =>
|
||||
variant.selectedOptions.every(
|
||||
(option) => option.value === optionSearchParams.get(option.name.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
const { coreCharge, waiverAvailable } = variant ?? {};
|
||||
|
||||
const handleSelectCoreChargeOption = (action: 'add' | 'remove') => {
|
||||
if (action === 'add' && variant?.coreVariantId) {
|
||||
optionSearchParams.set(CORE_VARIANT_ID_KEY, variant.coreVariantId);
|
||||
} else if (action === 'remove') {
|
||||
optionSearchParams.set(CORE_VARIANT_ID_KEY, CORE_WAIVER);
|
||||
}
|
||||
|
||||
const newUrl = createUrl(pathname, optionSearchParams);
|
||||
router.replace(newUrl, { scroll: false });
|
||||
};
|
||||
|
||||
// if the selected variant has changed, and the core change variant id is not the same as the selected variant id
|
||||
// or if users have selected the core waiver but the selected variant does not have a waiver available
|
||||
// we remove the core charge from the url
|
||||
if (
|
||||
variant?.coreVariantId &&
|
||||
optionSearchParams.has(CORE_VARIANT_ID_KEY) &&
|
||||
(coreVariantIdSearchParam !== CORE_WAIVER || !variant.waiverAvailable) &&
|
||||
coreVariantIdSearchParam !== variant.coreVariantId
|
||||
) {
|
||||
optionSearchParams.delete(CORE_VARIANT_ID_KEY);
|
||||
const newUrl = createUrl(pathname, optionSearchParams);
|
||||
router.replace(newUrl, { scroll: false });
|
||||
}
|
||||
|
||||
const selectedPayCoreCharge = coreVariantIdSearchParam === variant?.coreVariantId;
|
||||
const selectedCoreWaiver = coreVariantIdSearchParam === CORE_WAIVER;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col text-xs lg:text-sm">
|
||||
<div className="mb-2 text-base font-medium">Core Charge</div>
|
||||
<p className="mb-2 text-sm tracking-tight text-neutral-500">
|
||||
The core charge is a refundable deposit that is added to the price of the part. This charge
|
||||
ensures that the old, worn-out part is returned to the supplier for proper disposal or
|
||||
recycling. When you return the old part, you'll receive a refund of the core charge.
|
||||
</p>
|
||||
<ul className="flex min-h-16 flex-row space-x-4 pt-2">
|
||||
{waiverAvailable ? (
|
||||
<li className="flex w-32">
|
||||
<button
|
||||
onClick={() => handleSelectCoreChargeOption('remove')}
|
||||
className={cn(
|
||||
'flex w-full flex-col flex-wrap items-center justify-center space-y-2 rounded-md border p-2 text-center text-xs font-medium',
|
||||
{
|
||||
'ring-2 ring-secondary': selectedCoreWaiver
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span>Core Waiver</span>
|
||||
<Price amount="0" currencyCode={defaultPrice.currencyCode} />
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
{coreCharge && variant?.coreVariantId ? (
|
||||
<li className="flex w-32">
|
||||
<button
|
||||
onClick={() => handleSelectCoreChargeOption('add')}
|
||||
className={cn(
|
||||
'flex w-full flex-col flex-wrap items-center justify-center space-y-2 rounded-md border p-2 text-center text-xs font-medium',
|
||||
{
|
||||
'ring-2 ring-secondary': selectedPayCoreCharge
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span>Core Charge</span>
|
||||
<Price amount={coreCharge.amount} currencyCode={coreCharge.currencyCode} />
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoreCharge;
|
@ -1,45 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import CoreCharge from 'components/core-charge';
|
||||
import Price from 'components/price';
|
||||
import { Money, ProductVariant } from 'lib/shopify/types';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
type PriceWithCoreChargeProps = {
|
||||
variants: ProductVariant[];
|
||||
defaultPrice: Money;
|
||||
};
|
||||
|
||||
const PriceWithCoreCharge = ({ variants, defaultPrice }: PriceWithCoreChargeProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const variant = variants.find((variant: ProductVariant) =>
|
||||
variant.selectedOptions.every(
|
||||
(option) => option.value === searchParams.get(option.name.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
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={price}
|
||||
currencyCode={variant?.price.currencyCode || defaultPrice.currencyCode}
|
||||
className="text-2xl font-semibold"
|
||||
/>
|
||||
<CoreCharge variant={variant} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PriceWithCoreCharge;
|
@ -2,8 +2,9 @@ import { AddToCart } from 'components/cart/add-to-cart';
|
||||
import Prose from 'components/prose';
|
||||
import { Product } from 'lib/shopify/types';
|
||||
import { Suspense } from 'react';
|
||||
import PriceWithCoreCharge from './price-with-core-charge';
|
||||
import CoreCharge from './core-charge';
|
||||
import SpecialOffer from './special-offer';
|
||||
import VariantPrice from './vairant-price';
|
||||
import { VariantSelector } from './variant-selector';
|
||||
import Warranty from './warranty';
|
||||
|
||||
@ -12,7 +13,7 @@ export function ProductDescription({ product }: { product: Product }) {
|
||||
<>
|
||||
<div className="mb-5 flex flex-col dark:border-neutral-700">
|
||||
<h1 className="mb-3 text-2xl font-bold">{product.title}</h1>
|
||||
<PriceWithCoreCharge
|
||||
<VariantPrice
|
||||
variants={product.variants}
|
||||
defaultPrice={product.priceRange.minVariantPrice}
|
||||
/>
|
||||
@ -28,6 +29,10 @@ export function ProductDescription({ product }: { product: Product }) {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="mb-4 border-t pb-4 pt-6 dark:border-neutral-700">
|
||||
<CoreCharge variants={product.variants} defaultPrice={product.priceRange.minVariantPrice} />
|
||||
</div>
|
||||
|
||||
<div className="mb-4 border-t py-6 dark:border-neutral-700">
|
||||
<Warranty productType={product.productType} />
|
||||
</div>
|
||||
|
31
components/product/vairant-price.tsx
Normal file
31
components/product/vairant-price.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import Price from 'components/price';
|
||||
import { Money, ProductVariant } from 'lib/shopify/types';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
type PriceWithCoreChargeProps = {
|
||||
variants: ProductVariant[];
|
||||
defaultPrice: Money;
|
||||
};
|
||||
|
||||
const VariantPrice = ({ variants, defaultPrice }: PriceWithCoreChargeProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const variant = variants.find((variant: ProductVariant) =>
|
||||
variant.selectedOptions.every(
|
||||
(option) => option.value === searchParams.get(option.name.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
const price = variant?.price.amount || defaultPrice.amount;
|
||||
|
||||
return (
|
||||
<Price
|
||||
amount={price}
|
||||
currencyCode={variant?.price.currencyCode || defaultPrice.currencyCode}
|
||||
className="text-2xl font-semibold"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default VariantPrice;
|
@ -51,6 +51,7 @@ export function VariantSelector({
|
||||
// Update the option params using the current option to reflect how the url *would* change,
|
||||
// if the option was clicked.
|
||||
optionSearchParams.set(optionNameLowerCase, value);
|
||||
|
||||
const optionUrl = createUrl(pathname, optionSearchParams);
|
||||
|
||||
// In order to determine if an option is available for sale, we need to:
|
||||
|
@ -38,16 +38,19 @@ const WarrantySelector = () => {
|
||||
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-center text-xs font-medium',
|
||||
{ 'ring-2 ring-secondary': plan.key === selectedOptions }
|
||||
)}
|
||||
>
|
||||
{plan.template}
|
||||
<Price amount={String(plan.price)} currencyCode="USD" />
|
||||
<li key={plan.key} className="flex w-32">
|
||||
<button
|
||||
onClick={() => setSelectedOptions(plan.key)}
|
||||
className={cn(
|
||||
'flex w-full flex-col flex-wrap items-center justify-center space-y-2 rounded-md border p-2 text-center text-xs font-medium',
|
||||
{
|
||||
'ring-2 ring-secondary': plan.key === selectedOptions
|
||||
}
|
||||
)}
|
||||
>
|
||||
{plan.template}
|
||||
<Price amount={String(plan.price)} currencyCode="USD" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
@ -29,3 +29,6 @@ export const TAGS = {
|
||||
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
|
||||
export const DEFAULT_OPTION = 'Default Title';
|
||||
export const SHOPIFY_GRAPHQL_API_ENDPOINT = '/api/2024-04/graphql.json';
|
||||
|
||||
export const CORE_WAIVER = 'core-waiver';
|
||||
export const CORE_VARIANT_ID_KEY = 'coreVariantId';
|
||||
|
@ -52,6 +52,9 @@ const productFragment = /* GraphQL */ `
|
||||
waiverAvailable: metafield(namespace: "custom", key: "waiver_available") {
|
||||
value
|
||||
}
|
||||
coreVariantId: metafield(namespace: "custom", key: "coreVariant") {
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -183,8 +183,9 @@ const reshapeImages = (images: Connection<Image>, productTitle: string) => {
|
||||
const reshapeVariants = (variants: ShopifyProductVariant[]): ProductVariant[] => {
|
||||
return variants.map((variant) => ({
|
||||
...variant,
|
||||
coreCharge: parseMetaFieldValue<Money>(variant.coreCharge),
|
||||
waiverAvailable: parseMetaFieldValue<boolean>(variant.waiverAvailable)
|
||||
waiverAvailable: parseMetaFieldValue<boolean>(variant.waiverAvailable),
|
||||
coreVariantId: variant.coreVariantId?.value || null,
|
||||
coreCharge: parseMetaFieldValue<Money>(variant.coreCharge)
|
||||
}));
|
||||
};
|
||||
|
||||
@ -404,6 +405,18 @@ export async function getProduct(handle: string): Promise<Product | undefined> {
|
||||
return reshapeProduct(res.body.data.product, false);
|
||||
}
|
||||
|
||||
export async function getProductVariant(handle: string): Promise<Product | undefined> {
|
||||
const res = await shopifyFetch<ShopifyProductOperation>({
|
||||
query: getProductQuery,
|
||||
tags: [TAGS.products],
|
||||
variables: {
|
||||
handle
|
||||
}
|
||||
});
|
||||
|
||||
return reshapeProduct(res.body.data.product, false);
|
||||
}
|
||||
|
||||
export async function getProductRecommendations(productId: string): Promise<Product[]> {
|
||||
const res = await shopifyFetch<ShopifyProductRecommendationsOperation>({
|
||||
query: getProductRecommendationsQuery,
|
||||
|
@ -87,11 +87,16 @@ export type ProductVariant = {
|
||||
waiverAvailable: boolean | null;
|
||||
barcode: string | null;
|
||||
sku: string | null;
|
||||
coreVariantId: string | null;
|
||||
};
|
||||
|
||||
export type ShopifyProductVariant = Omit<ProductVariant, 'coreCharge' | 'waiverAvailable'> & {
|
||||
coreCharge: { value: string } | null;
|
||||
export type ShopifyProductVariant = Omit<
|
||||
ProductVariant,
|
||||
'coreCharge' | 'waiverAvailable' | 'coreVariantId'
|
||||
> & {
|
||||
waiverAvailable: { value: string };
|
||||
coreVariantId: { value: string } | null;
|
||||
coreCharge: { value: string } | null;
|
||||
};
|
||||
|
||||
export type SEO = {
|
||||
|
Loading…
x
Reference in New Issue
Block a user