diff --git a/apps/web/vibes/soul/examples/pages/cart/electric.tsx b/apps/web/vibes/soul/examples/pages/cart/electric.tsx index 3a0400e51..27c3f89ed 100644 --- a/apps/web/vibes/soul/examples/pages/cart/electric.tsx +++ b/apps/web/vibes/soul/examples/pages/cart/electric.tsx @@ -1,4 +1,4 @@ -import { getLineItems, getSubtotal } from '@/vibes/soul/data/line-items'; +import { getLineItems } from '@/vibes/soul/data/line-items'; import { locales } from '@/vibes/soul/data/locales'; import { action } from '@/vibes/soul/examples/primitives/inline-email-form/actions'; import { localeAction } from '@/vibes/soul/examples/primitives/navigation/actions'; @@ -52,7 +52,6 @@ const paymentIconsArray: React.ReactNode[] = [ export default async function Preview() { const lineItems = await getLineItems('Electric'); - const subtotal = await getSubtotal('Electric'); return ( <> @@ -73,31 +72,17 @@ export default async function Preview() { /> @@ -73,31 +72,17 @@ export default async function Preview() { /> @@ -73,31 +71,17 @@ export default async function Preview() { /> ((res) => - setTimeout(() => res(getLineItems('Electric')), 5000), - ); - const subtotal = await getSubtotal('Electric'); + const lineItems = await getLineItems('Electric'); return ( ); } diff --git a/apps/web/vibes/soul/examples/sections/cart/loading-electric.tsx b/apps/web/vibes/soul/examples/sections/cart/loading-electric.tsx index 13ea05bab..23148ab5a 100644 --- a/apps/web/vibes/soul/examples/sections/cart/loading-electric.tsx +++ b/apps/web/vibes/soul/examples/sections/cart/loading-electric.tsx @@ -1,43 +1,25 @@ -import { getLineItems, getSubtotal } from '@/vibes/soul/data/line-items'; -import { Cart, CartLineItem } from '@/vibes/soul/sections/cart'; +import { getLineItems } from '@/vibes/soul/data/line-items'; +import { Cart } from '@/vibes/soul/sections/cart'; import { checkoutAction, lineItemAction } from './actions'; -export default function Preview() { - const lineItems = new Promise((res) => - setTimeout(() => res(getLineItems('Electric')), 5000), - ); - const subtotal = new Promise((res) => - setTimeout(() => res(getSubtotal('Electric')), 10000), - ); +export default async function Preview() { + const lineItems = await getLineItems('Electric'); return ( ); } diff --git a/apps/web/vibes/soul/examples/sections/cart/loading-luxury.tsx b/apps/web/vibes/soul/examples/sections/cart/loading-luxury.tsx index 7576ee3bc..afa970d88 100644 --- a/apps/web/vibes/soul/examples/sections/cart/loading-luxury.tsx +++ b/apps/web/vibes/soul/examples/sections/cart/loading-luxury.tsx @@ -1,43 +1,24 @@ -import { getLineItems, getSubtotal } from '@/vibes/soul/data/line-items'; -import { Cart, CartLineItem } from '@/vibes/soul/sections/cart'; +import { getLineItems } from '@/vibes/soul/data/line-items'; +import { Cart } from '@/vibes/soul/sections/cart'; import { checkoutAction, lineItemAction } from './actions'; -export default function Preview() { - const lineItems = new Promise((res) => - setTimeout(() => res(getLineItems('Luxury')), 5000), - ); - const subtotal = new Promise((res) => - setTimeout(() => res(getSubtotal('Luxury')), 10000), - ); +export default async function Preview() { + const lineItems = await getLineItems('Luxury'); return ( ); } diff --git a/apps/web/vibes/soul/examples/sections/cart/loading-warm.tsx b/apps/web/vibes/soul/examples/sections/cart/loading-warm.tsx index 383121d3b..21d955c5d 100644 --- a/apps/web/vibes/soul/examples/sections/cart/loading-warm.tsx +++ b/apps/web/vibes/soul/examples/sections/cart/loading-warm.tsx @@ -1,41 +1,24 @@ -import { getLineItems, getSubtotal } from '@/vibes/soul/data/line-items'; -import { Cart, CartLineItem } from '@/vibes/soul/sections/cart'; +import { getLineItems } from '@/vibes/soul/data/line-items'; +import { Cart } from '@/vibes/soul/sections/cart'; import { checkoutAction, lineItemAction } from './actions'; -export default function Preview() { - const lineItems = new Promise((res) => - setTimeout(() => res(getLineItems('Warm')), 5000), - ); - const subtotal = new Promise((res) => setTimeout(() => res(getSubtotal('Warm')), 10000)); +export default async function Preview() { + const lineItems = await getLineItems('Warm'); return ( ); } diff --git a/apps/web/vibes/soul/examples/sections/cart/luxury.tsx b/apps/web/vibes/soul/examples/sections/cart/luxury.tsx index 4f8e356bb..afa970d88 100644 --- a/apps/web/vibes/soul/examples/sections/cart/luxury.tsx +++ b/apps/web/vibes/soul/examples/sections/cart/luxury.tsx @@ -1,39 +1,24 @@ -import { getLineItems, getSubtotal } from '@/vibes/soul/data/line-items'; +import { getLineItems } from '@/vibes/soul/data/line-items'; import { Cart } from '@/vibes/soul/sections/cart'; import { checkoutAction, lineItemAction } from './actions'; export default async function Preview() { const lineItems = await getLineItems('Luxury'); - const subtotal = await getSubtotal('Luxury'); return ( ); } diff --git a/apps/web/vibes/soul/examples/sections/cart/warm.tsx b/apps/web/vibes/soul/examples/sections/cart/warm.tsx index e259be12a..21d955c5d 100644 --- a/apps/web/vibes/soul/examples/sections/cart/warm.tsx +++ b/apps/web/vibes/soul/examples/sections/cart/warm.tsx @@ -1,39 +1,24 @@ -import { getLineItems, getSubtotal } from '@/vibes/soul/data/line-items'; +import { getLineItems } from '@/vibes/soul/data/line-items'; import { Cart } from '@/vibes/soul/sections/cart'; import { checkoutAction, lineItemAction } from './actions'; export default async function Preview() { const lineItems = await getLineItems('Warm'); - const subtotal = await getSubtotal('Warm'); return ( ); } diff --git a/apps/web/vibes/soul/sections/cart/client.tsx b/apps/web/vibes/soul/sections/cart/client.tsx new file mode 100644 index 000000000..ecfa63d61 --- /dev/null +++ b/apps/web/vibes/soul/sections/cart/client.tsx @@ -0,0 +1,336 @@ +'use client'; + +import { getFormProps, getInputProps, SubmissionResult, useForm } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; +import { clsx } from 'clsx'; +import { ArrowRight, Minus, Plus, Trash2 } from 'lucide-react'; +import Image from 'next/image'; +import { startTransition, useActionState, useEffect, useOptimistic } from 'react'; +import { useFormStatus } from 'react-dom'; + +import { Button } from '@/vibes/soul/primitives/button'; +import { toast } from '@/vibes/soul/primitives/toaster'; +import { StickySidebarLayout } from '@/vibes/soul/sections/sticky-sidebar-layout'; + +import { cartLineItemActionFormDataSchema } from './schema'; + +import { CartEmptyState } from '.'; + +type Action = (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