feat: add cart operations

This commit is contained in:
alex.saiannyi 2023-05-22 15:22:52 +02:00 committed by Volodymyr Krasnoshapka
parent b17652b26b
commit c2ec7d05b2
8 changed files with 97 additions and 44 deletions

View File

@ -10,15 +10,43 @@ function formatErrorMessage(err: Error): string {
export async function POST(req: NextRequest): Promise<Response> { export async function POST(req: NextRequest): Promise<Response> {
const cartId = cookies().get('cartId')?.value; const cartId = cookies().get('cartId')?.value;
const { merchandiseId, isBigCommerceAPI } = await req.json(); const { merchandiseId, isBigCommerceAPI, lineId, productId, quantity, variantId } = await req.json();
if ((!isBigCommerceAPI && !cartId?.length) || !merchandiseId?.length) { if (!isBigCommerceAPI && (!cartId?.length || !merchandiseId?.length)) {
return NextResponse.json({ error: 'Missing cartId or variantId' }, { status: 400 }); return NextResponse.json({ error: 'Missing cartId or variantId' }, { status: 400 });
} else if (isBigCommerceAPI && !merchandiseId?.length) { } else if (!isBigCommerceAPI && !merchandiseId?.length) {
return NextResponse.json({ error: 'Missing variantId' }, { status: 400 }); return NextResponse.json({ error: 'Missing variantId' }, { status: 400 });
} }
if (cartId && isBigCommerceAPI && lineId && !variantId || quantity === 0) {
try {
await removeFromCart(cartId!, [lineId]);
return NextResponse.json({ status: 204 });
} catch (e) {
if (isVercelCommerceError(e)) {
return NextResponse.json({ message: formatErrorMessage(e.message) }, { status: e.status });
}
return NextResponse.json({ status: 500 });
}
}
try { try {
await addToCart(cartId || '', [{ merchandiseId, quantity: 1 }]); const { id: cartEntityId } = await addToCart(cartId || '', [{ merchandiseId, quantity: 1, productId }]);
if ( isBigCommerceAPI && cartEntityId) {
const response = NextResponse.json({ status: 204 });
response.cookies.set('cartId', cartEntityId, {
path: '/',
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production'
});
return response;
}
return NextResponse.json({ status: 204 }); return NextResponse.json({ status: 204 });
} catch (e) { } catch (e) {
if (isVercelCommerceError(e)) { if (isVercelCommerceError(e)) {
@ -31,7 +59,7 @@ export async function POST(req: NextRequest): Promise<Response> {
export async function PUT(req: NextRequest): Promise<Response> { export async function PUT(req: NextRequest): Promise<Response> {
const cartId = cookies().get('cartId')?.value; const cartId = cookies().get('cartId')?.value;
const { variantId, quantity, lineId } = await req.json(); const { variantId, quantity, lineId, productId } = await req.json();
if (!cartId || !variantId || !quantity || !lineId) { if (!cartId || !variantId || !quantity || !lineId) {
return NextResponse.json( return NextResponse.json(
@ -44,9 +72,11 @@ export async function PUT(req: NextRequest): Promise<Response> {
{ {
id: lineId, id: lineId,
merchandiseId: variantId, merchandiseId: variantId,
quantity quantity,
productId
} }
]); ]);
return NextResponse.json({ status: 204 }); return NextResponse.json({ status: 204 });
} catch (e) { } catch (e) {
if (isVercelCommerceError(e)) { if (isVercelCommerceError(e)) {
@ -57,6 +87,8 @@ export async function PUT(req: NextRequest): Promise<Response> {
} }
} }
// NOTE: delete route handler fails
// https://github.com/vercel/next.js/issues/48096
export async function DELETE(req: NextRequest): Promise<Response> { export async function DELETE(req: NextRequest): Promise<Response> {
const cartId = cookies().get('cartId')?.value; const cartId = cookies().get('cartId')?.value;
const { lineId } = await req.json(); const { lineId } = await req.json();

View File

@ -14,9 +14,10 @@ export default function DeleteItemButton({ item }: { item: CartItem }) {
setRemoving(true); setRemoving(true);
const response = await fetch(`/api/cart`, { const response = await fetch(`/api/cart`, {
method: 'DELETE', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
lineId: item.id lineId: item.id,
isBigCommerceAPI: true,
}) })
}); });
const data = await response.json(); const data = await response.json();

View File

@ -21,11 +21,13 @@ export default function EditItemQuantityButton({
setEditing(true); setEditing(true);
const response = await fetch(`/api/cart`, { const response = await fetch(`/api/cart`, {
method: type === 'minus' && item.quantity - 1 === 0 ? 'DELETE' : 'PUT', method: type === 'minus' && item.quantity - 1 === 0 ? 'POST' : 'PUT',
body: JSON.stringify({ body: JSON.stringify({
lineId: item.id, lineId: item.id,
productId: item.merchandise.product.handle,
variantId: item.merchandise.id, variantId: item.merchandise.id,
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1 quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1,
isBigCommerceAPI: true
}) })
}); });

View File

@ -6,8 +6,8 @@ import Link from 'next/link';
import CloseIcon from 'components/icons/close'; import CloseIcon from 'components/icons/close';
import ShoppingBagIcon from 'components/icons/shopping-bag'; import ShoppingBagIcon from 'components/icons/shopping-bag';
import Price from 'components/price'; import Price from 'components/price';
import { DEFAULT_OPTION } from 'lib/constants';
import type { VercelCart as Cart } from 'lib/bigcommerce/types'; import type { VercelCart as Cart } from 'lib/bigcommerce/types';
import { DEFAULT_OPTION } from 'lib/constants';
import { createUrl } from 'lib/utils'; import { createUrl } from 'lib/utils';
import DeleteItemButton from './delete-item-button'; import DeleteItemButton from './delete-item-button';
import EditItemQuantityButton from './edit-item-quantity-button'; import EditItemQuantityButton from './edit-item-quantity-button';
@ -81,8 +81,10 @@ export default function CartModal({
<ul className="flex-grow overflow-auto p-6"> <ul className="flex-grow overflow-auto p-6">
{cart.lines.map((item, i) => { {cart.lines.map((item, i) => {
const merchandiseSearchParams = {} as MerchandiseSearchParams; const merchandiseSearchParams = {} as MerchandiseSearchParams;
let subTitleWithSelectedOptions = '';
item.merchandise.selectedOptions.forEach(({ name, value }) => { item.merchandise.selectedOptions.forEach(({ name, value }) => {
subTitleWithSelectedOptions += `${name}: ${value} `;
if (value !== DEFAULT_OPTION) { if (value !== DEFAULT_OPTION) {
merchandiseSearchParams[name.toLowerCase()] = value; merchandiseSearchParams[name.toLowerCase()] = value;
} }
@ -118,14 +120,14 @@ export default function CartModal({
</span> </span>
{item.merchandise.title !== DEFAULT_OPTION ? ( {item.merchandise.title !== DEFAULT_OPTION ? (
<p className="text-sm" data-testid="cart-product-variant"> <p className="text-sm" data-testid="cart-product-variant">
{item.merchandise.title} {item.merchandise.title === item.merchandise.product.title && subTitleWithSelectedOptions ? subTitleWithSelectedOptions : item.merchandise.title}
</p> </p>
) : null} ) : null}
</div> </div>
<Price <Price
className="flex flex-col justify-between space-y-2 text-sm" className="flex flex-col justify-between space-y-2 text-sm"
amount={item.cost.totalAmount.amount} amount={item.cost.totalAmount.amount}
currencyCode={item.cost.totalAmount.currencyCode} currencyCode={item.cost.totalAmount.currencyCode || 'USD'}
/> />
</Link> </Link>
<div className="flex h-9 flex-row"> <div className="flex h-9 flex-row">
@ -146,7 +148,7 @@ export default function CartModal({
<Price <Price
className="text-right" className="text-right"
amount={cart.cost.subtotalAmount.amount} amount={cart.cost.subtotalAmount.amount}
currencyCode={cart.cost.subtotalAmount.currencyCode} currencyCode={cart.cost.subtotalAmount.currencyCode || 'USD'}
/> />
</div> </div>
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
@ -154,7 +156,7 @@ export default function CartModal({
<Price <Price
className="text-right" className="text-right"
amount={cart.cost.totalTaxAmount.amount} amount={cart.cost.totalTaxAmount.amount}
currencyCode={cart.cost.totalTaxAmount.currencyCode} currencyCode={cart.cost.totalTaxAmount.currencyCode || 'USD'}
/> />
</div> </div>
<div className="mb-2 flex items-center justify-between border-b border-gray-200 pb-2"> <div className="mb-2 flex items-center justify-between border-b border-gray-200 pb-2">
@ -166,7 +168,7 @@ export default function CartModal({
<Price <Price
className="text-right" className="text-right"
amount={cart.cost.totalAmount.amount} amount={cart.cost.totalAmount.amount}
currencyCode={cart.cost.totalAmount.currencyCode} currencyCode={cart.cost.totalAmount.currencyCode || 'USD'}
/> />
</div> </div>
</div> </div>

View File

@ -14,8 +14,10 @@ export function AddToCart({
variants: ProductVariant[]; variants: ProductVariant[];
availableForSale: boolean; availableForSale: boolean;
}) { }) {
const productEntityId = variants[0]?.parentId || variants[0]?.id; const productEntityId = variants[0]?.parentId;
const [selectedVariantId, setSelectedVariantId] = useState(productEntityId); const varianEntitytId = variants[0]?.id;
const [selectedVariantId, setSelectedVariantId] = useState(varianEntitytId);
const [selectedProductId, setSelectedProductId] = useState(productEntityId);
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@ -29,7 +31,8 @@ export function AddToCart({
); );
if (variant) { if (variant) {
setSelectedVariantId(variant.parentId || variant.id); setSelectedVariantId(variant.id);
setSelectedProductId(variant.parentId);
} }
}, [searchParams, variants, setSelectedVariantId]); }, [searchParams, variants, setSelectedVariantId]);
@ -44,6 +47,7 @@ export function AddToCart({
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
merchandiseId: selectedVariantId, merchandiseId: selectedVariantId,
productId: selectedProductId,
isBigCommerceAPI: true isBigCommerceAPI: true
}) })
}); });

View File

@ -167,7 +167,7 @@ const getBigCommerceProductsWithCheckout = async (
) => { ) => {
const bigCommerceProducts = await Promise.all( const bigCommerceProducts = await Promise.all(
lines.map(async ({ merchandiseId }) => { lines.map(async ({ merchandiseId }) => {
const productId = Number(merchandiseId); const productId = parseInt(merchandiseId, 10);
const resp = await bigcommerceFetch<BigCommerceProductOperation>({ const resp = await bigcommerceFetch<BigCommerceProductOperation>({
query: getProductQuery, query: getProductQuery,
@ -191,10 +191,24 @@ const getBigCommerceProductsWithCheckout = async (
}, },
cache: 'no-store' cache: 'no-store'
}); });
const checkout = resCheckout.body.data.site.checkout ?? {
subtotal: {
value: 0,
currencyCode: '',
},
grandTotal: {
value: 0,
currencyCode: '',
},
taxTotal: {
value: 0,
currencyCode: '',
},
};
return { return {
productsByIdList: bigCommerceProducts, productsByIdList: bigCommerceProducts,
checkout: resCheckout.body.data.site.checkout checkout,
}; };
}; };
@ -225,7 +239,7 @@ export async function createCart(): Promise<VercelCart> {
export async function addToCart( export async function addToCart(
cartId: string, cartId: string,
lines: { merchandiseId: string; quantity: number }[] lines: { merchandiseId: string; quantity: number, productId?: string }[]
): Promise<VercelCart> { ): Promise<VercelCart> {
let bigCommerceCart: BigCommerceCart; let bigCommerceCart: BigCommerceCart;
@ -236,8 +250,9 @@ export async function addToCart(
addCartLineItemsInput: { addCartLineItemsInput: {
cartEntityId: cartId, cartEntityId: cartId,
data: { data: {
lineItems: lines.map(({ merchandiseId, quantity }) => ({ lineItems: lines.map(({ merchandiseId, quantity, productId }) => ({
productEntityId: parseInt(merchandiseId, 10), productEntityId: parseInt(productId!, 10),
variantEntityId: parseInt(merchandiseId, 10),
quantity quantity
})) }))
} }
@ -252,8 +267,9 @@ export async function addToCart(
query: createCartMutation, query: createCartMutation,
variables: { variables: {
createCartInput: { createCartInput: {
lineItems: lines.map(({ merchandiseId, quantity }) => ({ lineItems: lines.map(({ merchandiseId, quantity, productId }) => ({
productEntityId: parseInt(merchandiseId, 10), productEntityId: parseInt(productId!, 10),
variantEntityId: parseInt(merchandiseId, 10),
quantity quantity
})) }))
} }
@ -303,12 +319,12 @@ export async function removeFromCart(cartId: string, lineIds: string[]): Promise
// Update on selected options requires variantEntityId, optionEntityId // Update on selected options requires variantEntityId, optionEntityId
export async function updateCart( export async function updateCart(
cartId: string, cartId: string,
lines: { id: string; merchandiseId: string; quantity: number }[] lines: { id: string; merchandiseId: string; quantity: number, productId?: string}[]
): Promise<VercelCart> { ): Promise<VercelCart> {
let cartState: { status: number; body: BigCommerceUpdateCartItemOperation } | undefined; let cartState: { status: number; body: BigCommerceUpdateCartItemOperation } | undefined;
for (let updates = lines.length; updates > 0; updates--) { for (let updates = lines.length; updates > 0; updates--) {
const { id, merchandiseId, quantity } = lines[updates - 1]!; const { id, merchandiseId, quantity, productId } = lines[updates - 1]!;
const res = await bigcommerceFetch<BigCommerceUpdateCartItemOperation>({ const res = await bigcommerceFetch<BigCommerceUpdateCartItemOperation>({
query: updateCartLineItemMutation, query: updateCartLineItemMutation,
variables: { variables: {
@ -317,8 +333,9 @@ export async function updateCart(
lineItemEntityId: id, lineItemEntityId: id,
data: { data: {
lineItem: { lineItem: {
quantity, productEntityId: parseInt(productId!, 10),
productEntityId: Number(merchandiseId) variantEntityId: parseInt(merchandiseId, 10),
quantity
} }
} }
} }
@ -335,7 +352,6 @@ export async function updateCart(
return bigcommerceToVercelCart(updatedCart, productsByIdList, checkout); return bigcommerceToVercelCart(updatedCart, productsByIdList, checkout);
} }
// NOTE: DONE & review if it works
export async function getCart(cartId: string): Promise<VercelCart | null> { export async function getCart(cartId: string): Promise<VercelCart | null> {
const res = await bigcommerceFetch<BigCommerceCartOperation>({ const res = await bigcommerceFetch<BigCommerceCartOperation>({
query: getCartQuery, query: getCartQuery,
@ -351,11 +367,11 @@ export async function getCart(cartId: string): Promise<VercelCart | null> {
const lines = vercelFromBigCommerceLineItems(cart.lineItems); const lines = vercelFromBigCommerceLineItems(cart.lineItems);
const { productsByIdList, checkout } = await getBigCommerceProductsWithCheckout(cartId, lines); const { productsByIdList, checkout } = await getBigCommerceProductsWithCheckout(cartId, lines);
return bigcommerceToVercelCart(cart, productsByIdList, checkout); return bigcommerceToVercelCart(cart, productsByIdList, checkout);;
} }
export async function getCollection(handle: string): Promise<VercelCollection> { export async function getCollection(handle: string): Promise<VercelCollection> {
const entityId = await getCategoryEntityIdbyHandle(handle); // NOTE: check if this approach suits us const entityId = await getCategoryEntityIdbyHandle(handle);
const res = await bigcommerceFetch<BigCommerceCollectionOperation>({ const res = await bigcommerceFetch<BigCommerceCollectionOperation>({
query: getCategoryQuery, query: getCategoryQuery,
variables: { variables: {
@ -481,7 +497,7 @@ export async function getMenu(handle: string): Promise<VercelMenu[]> {
title: name, title: name,
path: createVercelCollectionPath(verceLTitle!) path: createVercelCollectionPath(verceLTitle!)
}; };
// NOTE: for NavBar we probably should keep it only high level categories // NOTE: keep only high level categories for NavBar
// if (hasChildren && children) { // if (hasChildren && children) {
// return configureVercelMenu(children, hasChildren); // return configureVercelMenu(children, hasChildren);
// } // }
@ -504,7 +520,6 @@ export async function getMenu(handle: string): Promise<VercelMenu[]> {
return []; return [];
} }
// TODO: replace with BC API next Page(s) Methods
export async function getPage(handle: string): Promise<VercelPage> { export async function getPage(handle: string): Promise<VercelPage> {
const entityId = await getEntityIdByHandle(handle); const entityId = await getEntityIdByHandle(handle);
const res = await bigcommerceFetch<BigCommercePageOperation>({ const res = await bigcommerceFetch<BigCommercePageOperation>({
@ -528,7 +543,6 @@ export async function getPages(): Promise<VercelPage[]> {
} }
export async function getProduct(handle: string): Promise<VercelProduct | undefined> { export async function getProduct(handle: string): Promise<VercelProduct | undefined> {
// const productId = await getEntityIdByHandle(handle); // NOTE: check of this approach work
const res = await bigcommerceFetch<BigCommerceProductOperation>({ const res = await bigcommerceFetch<BigCommerceProductOperation>({
query: getProductQuery, query: getProductQuery,
variables: { variables: {

View File

@ -22,8 +22,8 @@ type ProductsList = { productId: number; productData: BigCommerceProduct }[];
const vercelFromBigCommerceLineItems = (lineItems: BigCommerceCart['lineItems']) => { const vercelFromBigCommerceLineItems = (lineItems: BigCommerceCart['lineItems']) => {
const { physicalItems, digitalItems, customItems } = lineItems; const { physicalItems, digitalItems, customItems } = lineItems;
const cartItemMapper = ({ entityId, quantity }: DigitalOrPhysicalItem | CartCustomItem) => ({ const cartItemMapper = ({ entityId, quantity, productEntityId }: DigitalOrPhysicalItem | CartCustomItem) => ({
merchandiseId: entityId.toString(), merchandiseId: productEntityId ? productEntityId.toString() : entityId.toString(),
quantity quantity
}); });
@ -201,17 +201,17 @@ const bigcommerceToVercelCartItems = (
} }
return { return {
id: item.entityId.toString(), id: item.entityId.toString(), // NOTE: used as lineId || lineItemId
quantity: item.quantity, quantity: item.quantity,
cost: { cost: {
totalAmount: { totalAmount: {
amount: amount:
item.extendedListPrice.value.toString() || item.listPrice.value.toString() || '0', item.extendedListPrice.value.toString() || item.listPrice.value.toString() || '0',
currencyCode: item.extendedListPrice.currencyCode || item.listPrice.currencyCode || '' currencyCode: item.extendedListPrice.currencyCode || item.listPrice.currencyCode || ''
} }
}, },
merchandise: { merchandise: {
id: item.entityId.toString(), id: isCustomItem ? item.entityId.toString() : (item as DigitalOrPhysicalItem).variantEntityId!.toString(),
title: `${item.name}`, title: `${item.name}`,
selectedOptions, selectedOptions,
product product
@ -238,9 +238,8 @@ const bigcommerceToVercelCart = (
): VercelCart => { ): VercelCart => {
return { return {
id: cart.entityId, id: cart.entityId,
checkoutUrl: '', // NOTE: where to get checkoutUrl?? checkoutUrl: '', // TODO: add later
cost: { cost: {
// NOTE: these props lay down in checkout not cart
subtotalAmount: { subtotalAmount: {
amount: checkout.subtotal.value.toString(), amount: checkout.subtotal.value.toString(),
currencyCode: checkout.subtotal.currencyCode currencyCode: checkout.subtotal.currencyCode

View File

@ -111,7 +111,6 @@ const updateCartLineItemMutation = /* GraphQL */ `
updatedAt { updatedAt {
utc utc
} }
totalQuantity
lineItems { lineItems {
totalQuantity totalQuantity
physicalItems { physicalItems {