From 0a13a9536470a8ac277b8d264aa89a25158fdf3e Mon Sep 17 00:00:00 2001 From: Alan Pledger Date: Thu, 16 Jan 2025 11:56:20 -0500 Subject: [PATCH 1/3] fix: toaster position --- core/vibes/soul/primitives/toaster/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/vibes/soul/primitives/toaster/index.tsx b/core/vibes/soul/primitives/toaster/index.tsx index 2b963ef712..cb7b167bf5 100644 --- a/core/vibes/soul/primitives/toaster/index.tsx +++ b/core/vibes/soul/primitives/toaster/index.tsx @@ -23,7 +23,7 @@ export const Toaster = ({ ...props }: ToasterProps) => { toastOptions={{ unstyled: true, classNames: { - toast: 'group focus-visible:ring-0', + toast: 'group focus-visible:ring-0 right-0', }, }} {...props} From bab0c6663da645b5e9d2e9293e768c0a9a19dfb4 Mon Sep 17 00:00:00 2001 From: Alan Pledger Date: Thu, 16 Jan 2025 11:56:34 -0500 Subject: [PATCH 2/3] refactor: use streamable in cart --- .../cart/_actions/update-line-item.ts | 2 +- core/app/[locale]/(default)/cart/page.tsx | 49 +- core/vibes/soul/sections/cart/client.tsx | 336 ++++++++++++ core/vibes/soul/sections/cart/index.tsx | 519 ++++-------------- core/vibes/soul/sections/cart/types.ts | 43 -- 5 files changed, 459 insertions(+), 490 deletions(-) create mode 100644 core/vibes/soul/sections/cart/client.tsx delete mode 100644 core/vibes/soul/sections/cart/types.ts 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 b89ecd5a86..49efba85c6 100644 --- a/core/app/[locale]/(default)/cart/page.tsx +++ b/core/app/[locale]/(default)/cart/page.tsx @@ -3,6 +3,7 @@ import { getFormatter, getTranslations } from 'next-intl/server'; import { Cart as CartComponent, CartEmptyState } from '@/vibes/soul/sections/cart'; import { getCartId } from '~/lib/cart'; +import { exists } from '~/lib/utils'; import { redirectToCheckout } from './_actions/redirect-to-checkout'; import { updateLineItem } from './_actions/update-line-item'; @@ -89,7 +90,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