diff --git a/core/app/[locale]/(default)/cart/_actions/update-line-item.ts b/core/app/[locale]/(default)/cart/_actions/update-line-item.ts index 2d01a290a8..d2e305b3d8 100644 --- a/core/app/[locale]/(default)/cart/_actions/update-line-item.ts +++ b/core/app/[locale]/(default)/cart/_actions/update-line-item.ts @@ -53,7 +53,7 @@ export const updateLineItem = async ( } switch (submission.value.intent) { - case 'increment': { + case 'update': { const parsedSelectedOptions = cartLineItem.selectedOptions.reduce( (accum, option) => { let multipleChoicesOptionInput; @@ -175,7 +175,7 @@ export const updateLineItem = async ( productEntityId: cartLineItem.productEntityId, variantEntityId: cartLineItem.variantEntityId, selectedOptions: parsedSelectedOptions, - quantity: cartLineItem.quantity + 1, + quantity: submission.value.quantity, }); } catch (error) { // eslint-disable-next-line no-console @@ -197,165 +197,11 @@ export const updateLineItem = async ( return { ...prevState, lastResult: submission.reply({ formErrors: [String(error)] }) }; } - const item = submission.value; + const { id, quantity } = submission.value; return { lineItems: prevState.lineItems.map((lineItem) => - lineItem.id === item.id ? { ...lineItem, quantity: lineItem.quantity + 1 } : lineItem, - ), - lastResult: submission.reply({ resetForm: true }), - }; - } - - case 'decrement': { - const parsedSelectedOptions = cartLineItem.selectedOptions.reduce( - (accum, option) => { - let multipleChoicesOptionInput; - let checkboxOptionInput; - let numberFieldOptionInput; - let textFieldOptionInput; - let multiLineTextFieldOptionInput; - let dateFieldOptionInput; - - switch (option.__typename) { - case 'CartSelectedMultipleChoiceOption': - multipleChoicesOptionInput = { - optionEntityId: option.entityId, - optionValueEntityId: option.valueEntityId, - }; - - if (accum.multipleChoices) { - return { - ...accum, - multipleChoices: [...accum.multipleChoices, multipleChoicesOptionInput], - }; - } - - return { - ...accum, - multipleChoices: [multipleChoicesOptionInput], - }; - - case 'CartSelectedCheckboxOption': - checkboxOptionInput = { - optionEntityId: option.entityId, - optionValueEntityId: option.valueEntityId, - }; - - if (accum.checkboxes) { - return { - ...accum, - checkboxes: [...accum.checkboxes, checkboxOptionInput], - }; - } - - return { ...accum, checkboxes: [checkboxOptionInput] }; - - case 'CartSelectedNumberFieldOption': - numberFieldOptionInput = { - optionEntityId: option.entityId, - number: option.number, - }; - - if (accum.numberFields) { - return { - ...accum, - numberFields: [...accum.numberFields, numberFieldOptionInput], - }; - } - - return { ...accum, numberFields: [numberFieldOptionInput] }; - - case 'CartSelectedTextFieldOption': - textFieldOptionInput = { - optionEntityId: option.entityId, - text: option.text, - }; - - if (accum.textFields) { - return { - ...accum, - textFields: [...accum.textFields, textFieldOptionInput], - }; - } - - return { ...accum, textFields: [textFieldOptionInput] }; - - case 'CartSelectedMultiLineTextFieldOption': - multiLineTextFieldOptionInput = { - optionEntityId: option.entityId, - text: option.text, - }; - - if (accum.multiLineTextFields) { - return { - ...accum, - multiLineTextFields: [ - ...accum.multiLineTextFields, - multiLineTextFieldOptionInput, - ], - }; - } - - return { - ...accum, - multiLineTextFields: [multiLineTextFieldOptionInput], - }; - - case 'CartSelectedDateFieldOption': - dateFieldOptionInput = { - optionEntityId: option.entityId, - date: new Date(String(option.date.utc)).toISOString(), - }; - - if (accum.dateFields) { - return { - ...accum, - dateFields: [...accum.dateFields, dateFieldOptionInput], - }; - } - - return { ...accum, dateFields: [dateFieldOptionInput] }; - } - - return accum; - }, - {}, - ); - - try { - await updateQuantity({ - lineItemEntityId: cartLineItem.id, - productEntityId: cartLineItem.productEntityId, - variantEntityId: cartLineItem.variantEntityId, - selectedOptions: parsedSelectedOptions, - quantity: cartLineItem.quantity - 1, - }); - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - - if (error instanceof BigCommerceGQLError) { - return { - ...prevState, - lastResult: submission.reply({ - formErrors: error.errors.map(({ message }) => message), - }), - }; - } - - if (error instanceof Error) { - return { ...prevState, lastResult: submission.reply({ formErrors: [error.message] }) }; - } - - return { ...prevState, lastResult: submission.reply({ formErrors: [String(error)] }) }; - } - - const item = submission.value; - - return { - lineItems: prevState.lineItems.map((lineItem) => - lineItem.id === item.id ? { ...lineItem, quantity: lineItem.quantity - 1 } : lineItem, + lineItem.id === id ? { ...lineItem, quantity } : lineItem, ), lastResult: submission.reply({ resetForm: true }), }; diff --git a/core/vibes/soul/sections/cart/client.tsx b/core/vibes/soul/sections/cart/client.tsx index 6b8c4c5e85..f56c639f26 100644 --- a/core/vibes/soul/sections/cart/client.tsx +++ b/core/vibes/soul/sections/cart/client.tsx @@ -1,6 +1,6 @@ 'use client'; -import { getFormProps, getInputProps, SubmissionResult, useForm } from '@conform-to/react'; +import { SubmissionResult, useForm } from '@conform-to/react'; import { parseWithZod } from '@conform-to/zod'; import { clsx } from 'clsx'; import { ArrowRight, Minus, Plus, Trash2 } from 'lucide-react'; @@ -10,6 +10,8 @@ import { useActionState, useEffect, useOptimistic, + useRef, + useState, } from 'react'; import { useFormStatus } from 'react-dom'; @@ -198,20 +200,10 @@ export function CartClient({ if (submission.status !== 'success') return prevState; switch (submission.value.intent) { - case 'increment': { - const { id } = submission.value; - - return prevState.map((item) => - item.id === id ? { ...item, quantity: item.quantity + 1 } : item, - ); - } + case 'update': { + const { id, quantity } = submission.value; - case 'decrement': { - const { id } = submission.value; - - return prevState.map((item) => - item.id === id ? { ...item, quantity: item.quantity - 1 } : item, - ); + return prevState.map((item) => (item.id === id ? { ...item, quantity } : item)); } case 'delete': { @@ -307,7 +299,6 @@ export function CartClient({ ({ const intent = formData.get('intent'); - if (intent === 'increment') { - formData.set('quantity', '1'); - - events.onAddToCart?.(formData); - } - - if (intent === 'decrement') { - formData.set('quantity', '1'); - - events.onRemoveFromCart?.(formData); + if (intent === 'update') { + // formData.get() can return null if field doesn't exist, though it should + // always exist here. Default to current quantity as safety fallback to prevent + // erroneous analytics events + const submittedQuantity = formData.get('quantity'); + const targetQuantity = + submittedQuantity && typeof submittedQuantity === 'string' + ? parseInt(submittedQuantity, 10) + : lineItem.quantity; + + // Calculate the net change between old and new quantity for analytics tracking + // since we only want to track the final result, not individual button clicks + const netChange = targetQuantity - lineItem.quantity; + + if (netChange !== 0) { + const analyticsFormData = new FormData(); + + analyticsFormData.append('intent', 'update'); + analyticsFormData.append('id', lineItem.id); + analyticsFormData.append('quantity', Math.abs(netChange).toString()); + + if (netChange > 0) { + events.onAddToCart?.(analyticsFormData); + } else { + events.onRemoveFromCart?.(analyticsFormData); + } + } } if (intent === 'delete') { formData.set('quantity', lineItem.quantity.toString()); - events.onRemoveFromCart?.(formData); } }); @@ -350,7 +357,6 @@ export function CartClient({ function CounterForm({ lineItem, - action, onSubmit, incrementLabel = 'Increase count', decrementLabel = 'Decrease count', @@ -360,89 +366,126 @@ function CounterForm({ incrementLabel?: string; decrementLabel?: string; deleteLabel?: string; - action: (payload: FormData) => void; onSubmit: (formData: FormData) => void; }) { - const [form, fields] = useForm({ - defaultValue: { id: lineItem.id }, - shouldValidate: 'onBlur', - shouldRevalidate: 'onInput', - onValidate({ formData }) { - return parseWithZod(formData, { schema: cartLineItemActionFormDataSchema }); - }, - onSubmit(event, { formData }) { - event.preventDefault(); + const [quantity, setQuantity] = useState(lineItem.quantity); + const [isSubmitting, setIsSubmitting] = useState(false); + const debounceTimeout = useRef(null); + const originalQuantity = useRef(lineItem.quantity); - onSubmit(formData); - }, - }); + useEffect(() => { + originalQuantity.current = lineItem.quantity; + setQuantity(lineItem.quantity); + }, [lineItem.quantity]); + + useEffect(() => { + if (quantity !== originalQuantity.current && !isSubmitting) { + if (debounceTimeout.current) { + clearTimeout(debounceTimeout.current); + } + + debounceTimeout.current = setTimeout(() => { + setIsSubmitting(true); + + const formData = new FormData(); + + formData.append('intent', 'update'); + formData.append('id', lineItem.id); + formData.append('quantity', quantity.toString()); + + onSubmit(formData); + }, 500); + } + + return () => { + if (debounceTimeout.current) { + clearTimeout(debounceTimeout.current); + } + }; + }, [quantity, lineItem.id, onSubmit, isSubmitting]); + + useEffect(() => { + if (isSubmitting) { + const resetTimeout = setTimeout(() => { + setIsSubmitting(false); + }, 1000); + + return () => clearTimeout(resetTimeout); + } + }, [isSubmitting]); return ( -
- -
- {lineItem.price} - - {/* Counter */} -
- - - {lineItem.quantity} - - -
- + size={18} + strokeWidth={1.5} + /> + + + {quantity} +
-
+ + + ); } diff --git a/core/vibes/soul/sections/cart/schema.ts b/core/vibes/soul/sections/cart/schema.ts index e94936851b..5529ea3e88 100644 --- a/core/vibes/soul/sections/cart/schema.ts +++ b/core/vibes/soul/sections/cart/schema.ts @@ -2,12 +2,9 @@ import { z } from 'zod'; export const cartLineItemActionFormDataSchema = z.discriminatedUnion('intent', [ z.object({ - intent: z.literal('increment'), - id: z.string(), - }), - z.object({ - intent: z.literal('decrement'), + intent: z.literal('update'), id: z.string(), + quantity: z.coerce.number().min(1), }), z.object({ intent: z.literal('delete'),