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 25725eb599..8097879117 100644 --- a/core/app/[locale]/(default)/cart/_actions/update-line-item.ts +++ b/core/app/[locale]/(default)/cart/_actions/update-line-item.ts @@ -6,8 +6,8 @@ import { parseWithZod } from '@conform-to/zod'; import { FragmentOf } from 'gql.tada'; import { getTranslations } from 'next-intl/server'; +import { CartLineItem } from '@/vibes/soul/sections/cart'; import { cartLineItemActionFormDataSchema } from '@/vibes/soul/sections/cart/schema'; -import { CartLineItem } from '@/vibes/soul/sections/cart/types'; import { DigitalItemFragment, PhysicalItemFragment } from '../page-data'; diff --git a/core/app/[locale]/(default)/cart/page.tsx b/core/app/[locale]/(default)/cart/page.tsx index a1a6ff5085..fe73277925 100644 --- a/core/app/[locale]/(default)/cart/page.tsx +++ b/core/app/[locale]/(default)/cart/page.tsx @@ -2,6 +2,7 @@ import { cookies } from 'next/headers'; import { getFormatter, getTranslations } from 'next-intl/server'; import { Cart as CartComponent, CartEmptyState } from '@/vibes/soul/sections/cart'; +import { exists } from '~/lib/utils'; import { redirectToCheckout } from './_actions/redirect-to-checkout'; import { updateLineItem } from './_actions/update-line-item'; @@ -88,7 +89,32 @@ export default async function Cart() { return ( <> = (state: Awaited, payload: Payload) => State | Promise; + +export interface CartLineItem { + id: string; + image: { alt: string; src: string }; + title: string; + subtitle: string; + quantity: number; + price: string; +} + +export interface CartSummaryItem { + label: string; + value: string; +} + +export interface CartState { + lineItems: LineItem[]; + lastResult: SubmissionResult | null; +} + +export interface Cart { + lineItems: LineItem[]; + summaryItems: CartSummaryItem[]; + total: string; + totalLabel?: string; +} + +export interface Props { + title?: string; + summaryTitle?: string; + emptyState?: CartEmptyState; + lineItemAction: Action, FormData>; + checkoutAction: Action; + checkoutLabel?: string; + deleteLineItemLabel?: string; + decrementLineItemLabel?: string; + incrementLineItemLabel?: string; + cart: Cart; +} + +const defaultEmptyState = { + title: 'Your cart is empty', + subtitle: 'Add some products to get started.', + cta: { label: 'Continue shopping', href: '#' }, +}; + +export function CartClient({ + title, + cart, + decrementLineItemLabel, + incrementLineItemLabel, + deleteLineItemLabel, + lineItemAction, + checkoutAction, + checkoutLabel = 'Checkout', + emptyState = defaultEmptyState, + summaryTitle, +}: Props) { + const [state, formAction] = useActionState(lineItemAction, { + lineItems: cart.lineItems, + lastResult: null, + }); + + const [form] = useForm({ lastResult: state.lastResult }); + + useEffect(() => { + if (form.errors) { + form.errors.forEach((error) => { + toast.error(error); + }); + } + }, [form.errors]); + + const [optimisticLineItems, setOptimisticLineItems] = useOptimistic( + state.lineItems, + (prevState, formData) => { + const submission = parseWithZod(formData, { schema: cartLineItemActionFormDataSchema }); + + 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 'decrement': { + const { id } = submission.value; + + return prevState.map((item) => + item.id === id ? { ...item, quantity: item.quantity - 1 } : item, + ); + } + + case 'delete': { + const { id } = submission.value; + + return prevState.filter((item) => item.id !== id); + } + + default: + return prevState; + } + }, + ); + + const optimisticQuantity = optimisticLineItems.reduce((total, item) => total + item.quantity, 0); + + if (optimisticQuantity === 0) { + return ; + } + + return ( + +

+ {summaryTitle} +

+
+
+ {cart.summaryItems.map((summaryItem, index) => ( +
+
{summaryItem.label}
+
{summaryItem.value}
+
+ ))} +
+ +
+
{cart.totalLabel ?? 'Total'}
+
{cart.total}
+
+
+ + + {checkoutLabel} + + + + } + sidebarPosition="after" + sidebarSize="1/3" + > +
+

+ {title} + + {optimisticQuantity} + +

+ + {/* Cart Items */} +
    + {optimisticLineItems.map((lineItem) => ( +
  • +
    + {lineItem.image.alt} +
    +
    +
    + {lineItem.title} + + {lineItem.subtitle} + +
    + { + startTransition(() => { + formAction(formData); + setOptimisticLineItems(formData); + }); + }} + /> +
    +
  • + ))} +
+
+
+ ); +} + +function CounterForm({ + lineItem, + action, + onSubmit, + incrementLabel = 'Increase count', + decrementLabel = 'Decrease count', + deleteLabel = 'Remove item', +}: { + lineItem: CartLineItem; + 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(); + + onSubmit(formData); + }, + }); + + return ( +
+ +
+ {lineItem.price} + + {/* Counter */} +
+ + + {lineItem.quantity} + + +
+ + +
+
+ ); +} + +function CheckoutButton({ + action, + ...rest +}: { action: Action } & React.ComponentPropsWithoutRef< + typeof Button +>) { + const [lastResult, formAction] = useActionState(action, null); + + useEffect(() => { + if (lastResult?.error) { + console.log(lastResult.error); + } + }, [lastResult?.error]); + + return ( +
+ + + ); +} + +function SubmitButton(props: React.ComponentPropsWithoutRef) { + const { pending } = useFormStatus(); + + return - - {lineItem.quantity} - - - - - - - - ); -} - -function CheckoutButton({ - action, - ...rest -}: { action: Action } & React.ComponentPropsWithoutRef< - typeof Button ->) { - const [lastResult, formAction] = useActionState(action, null); - - useEffect(() => { - if (lastResult?.error) { - console.log(lastResult.error); - } - }, [lastResult?.error]); - - return ( -
- - - ); -} - -function SubmitButton(props: React.ComponentPropsWithoutRef) { - const { pending } = useFormStatus(); - - return