diff --git a/apps/web/vibes/soul/docs/wishlist-item-card.mdx b/apps/web/vibes/soul/docs/wishlist-item-card.mdx new file mode 100644 index 000000000..1ea0749bc --- /dev/null +++ b/apps/web/vibes/soul/docs/wishlist-item-card.mdx @@ -0,0 +1,95 @@ +--- +title: Wishlist Item Card +preview: wishlist-item-card-example +previewSize: md +--- + +## Usage + +{/* prettier-ignore-start */} + +{` +import { WishlistItemCard } from '@/vibes/soul/primitives/wishlist-item-card'; + +function Usage() { + return ( + + ); +} +`} + + +{/* prettier-ignore-end */} + +## API Reference + +This comoponent uses the Sonner toast component. For more information, please refer to the [Sonner documentation](https://sonner.emilkowal.ski/). + +### WishlistItemCardProps + +| Prop | Type | Default | +| -------------- | ----------------------------- | ------- | +| `wishlistId*` | `string` | | +| `item*` | `WishlistItem` | | +| `action*` | `AddWishlistItemToCartAction` | | +| `removeAction` | `RemoveWishlistItemAction` | | + +### WishlistItem + +| Prop | Type | Default | +| -------------- | --------------------------------------- | ------- | +| `itemId*` | `string` | | +| `productId*` | `string` | | +| `variantId` | `string` | | +| `callToAction` | `{ label: string; disabled?: boolean }` | | +| `product*` | `ProductCardWithId` | | + +### AddWishlistItemToCartAction + +```ts +type Action = (state: Awaited, payload: P) => S | Promise; + +interface State { + lastResult: SubmissionResult | null; + successMessage?: React.ReactNode; + errorMessage?: string; +} + +export type AddWishlistItemToCartAction = Action; +``` + +### RemoveWishlistItemAction + +```ts +type Action = (state: Awaited, payload: Payload) => State | Promise; + +interface RemoveWishlistItemState { + lastResult: SubmissionResult | null; + errorMessage?: string; +} + +export type RemoveWishlistItemAction = Action; +``` diff --git a/apps/web/vibes/soul/examples.ts b/apps/web/vibes/soul/examples.ts index 55917156b..06f290496 100644 --- a/apps/web/vibes/soul/examples.ts +++ b/apps/web/vibes/soul/examples.ts @@ -1240,5 +1240,12 @@ export const examples = [ files: ['examples/sections/sticky-sidebar-layout/luxury.tsx'], component: lazy(() => import('./examples/sections/sticky-sidebar-layout/luxury')), }, + { + name: 'wishlist-item-card-example', + dependencies: [], + registryDependencies: ['wishlist-item-card'], + files: ['examples/primitives/wishlist-item-card/index.tsx'], + component: lazy(() => import('./examples/primitives/wishlist-item-card')), + }, // PLOP: Append new component here ] satisfies Components; diff --git a/apps/web/vibes/soul/examples/primitives/wishlist-item-card/action.ts b/apps/web/vibes/soul/examples/primitives/wishlist-item-card/action.ts new file mode 100644 index 000000000..4f8ac7148 --- /dev/null +++ b/apps/web/vibes/soul/examples/primitives/wishlist-item-card/action.ts @@ -0,0 +1,27 @@ +'use server'; + +import { SubmissionResult } from '@conform-to/react'; + +interface State { + lastResult: SubmissionResult | null; + successMessage?: React.ReactNode; + errorMessage?: string; +} + +export async function action(prevState: State, formData: FormData): Promise { + await new Promise((resolve) => setTimeout(resolve, 1000)); + + return { + lastResult: { status: 'success' }, + successMessage: 'Item added to wishlist!', + }; +} + +export async function removeAction(prevState: State, formData: FormData): Promise { + await new Promise((resolve) => setTimeout(resolve, 1000)); + + return { + lastResult: { status: 'success' }, + successMessage: 'Item removed from wishlist!', + }; +} diff --git a/apps/web/vibes/soul/examples/primitives/wishlist-item-card/index.tsx b/apps/web/vibes/soul/examples/primitives/wishlist-item-card/index.tsx new file mode 100644 index 000000000..603053cac --- /dev/null +++ b/apps/web/vibes/soul/examples/primitives/wishlist-item-card/index.tsx @@ -0,0 +1,68 @@ +import { WishlistItemCard } from '@/vibes/soul/primitives/wishlist-item-card'; +import { action, removeAction } from './action'; +import { type Product } from '@/vibes/soul/primitives/product-card'; + +const product1: Product = { + id: '1', + title: 'Jada Square Toe Ballet Flat', + subtitle: '', + badge: 'Bestseller', + price: '$350', + image: { + src: 'https://rstr.in/monogram/vibes/9vu9tSw1WdA', + alt: 'Jada Square Toe Ballet Flat', + }, + href: '#', + rating: 4.5, +}; + +const product2: Product = { + id: '2', + href: '#', + title: 'Product Name', + subtitle: 'Blue/Black/Green', + price: { + type: 'sale', + previousValue: '$123.99', + currentValue: '$99.99', + }, +}; + +export default function Preview() { + return ( +
+
+
+ + +
+
+
+ ); +} diff --git a/apps/web/vibes/soul/navigation.ts b/apps/web/vibes/soul/navigation.ts index 8b4cfdc4b..86500e7dd 100644 --- a/apps/web/vibes/soul/navigation.ts +++ b/apps/web/vibes/soul/navigation.ts @@ -159,6 +159,12 @@ const primitivePages: [Page, ...Page[]] = [ file: 'docs/toaster.mdx', component: 'toaster', }, + { + title: 'Wishlist Item Card', + slug: 'wishlist-item-card', + file: 'docs/wishlist-item-card.mdx', + component: 'wishlist-item-card', + }, ]; const sectionPages: [Page, ...Page[]] = [ diff --git a/apps/web/vibes/soul/primitives/wishlist-item-card/index.tsx b/apps/web/vibes/soul/primitives/wishlist-item-card/index.tsx new file mode 100644 index 000000000..49af11207 --- /dev/null +++ b/apps/web/vibes/soul/primitives/wishlist-item-card/index.tsx @@ -0,0 +1,74 @@ +import { clsx } from 'clsx'; + +import { + ProductCard, + Product, + ProductCardSkeleton, + ProductCardProps, +} from '@/vibes/soul/primitives/product-card'; +import * as Skeleton from '@/vibes/soul/primitives/skeleton'; + +import { RemoveWishlistItemAction, RemoveWishlistItemButton } from './remove-wishlist-item'; +import { AddWishlistItemToCartAction, WishlistItemAddToCart } from './wishlist-item-add-to-cart'; + +export interface WishlistItem { + itemId: string; + productId: string; + variantId?: string; + callToAction?: { + label: string; + disabled?: boolean; + }; + product: Product; +} + +interface WishlistItemCardProps extends Omit { + wishlistId: string; + item: WishlistItem; + action: AddWishlistItemToCartAction; + removeAction?: RemoveWishlistItemAction; +} + +export const WishlistItemCard = ({ + wishlistId, + item: { itemId, productId, variantId, callToAction, product }, + action, + removeAction, + ...props +}: WishlistItemCardProps) => { + return ( +
+ + {callToAction && ( + + )} + {removeAction && ( +
+ +
+ )} +
+ ); +}; + +export function WishlistItemSkeleton({ className = '' }: { className?: string }) { + return ( +
+ + +
+ ); +} diff --git a/apps/web/vibes/soul/primitives/wishlist-item-card/remove-wishlist-item.tsx b/apps/web/vibes/soul/primitives/wishlist-item-card/remove-wishlist-item.tsx new file mode 100644 index 000000000..d1a6a811f --- /dev/null +++ b/apps/web/vibes/soul/primitives/wishlist-item-card/remove-wishlist-item.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { SubmissionResult } from '@conform-to/react'; +import { XIcon } from 'lucide-react'; +import { useActionState, useEffect, useTransition } from 'react'; + +import { Button } from '@/vibes/soul/primitives/button'; +import { toast } from '@/vibes/soul/primitives/toaster'; + +type Action = (state: Awaited, payload: Payload) => State | Promise; + +interface RemoveWishlistItemState { + lastResult: SubmissionResult | null; + errorMessage?: string; +} + +export type RemoveWishlistItemAction = Action; + +interface Props { + wishlistId: string; + itemId: string; + action: RemoveWishlistItemAction; +} + +export const RemoveWishlistItemButton = ({ wishlistId, itemId, action }: Props) => { + const [isPending, startTransition] = useTransition(); + const [state, formAction] = useActionState(action, { + lastResult: null, + }); + + useEffect(() => { + if (state.lastResult?.status === 'error' && Boolean(state.errorMessage)) { + toast.error(state.errorMessage); + } + }, [state]); + + return ( +
startTransition(() => formAction(formData))}> + + + +
+ ); +}; diff --git a/apps/web/vibes/soul/primitives/wishlist-item-card/wishlist-item-add-to-cart.tsx b/apps/web/vibes/soul/primitives/wishlist-item-card/wishlist-item-add-to-cart.tsx new file mode 100644 index 000000000..7dccbe1b6 --- /dev/null +++ b/apps/web/vibes/soul/primitives/wishlist-item-card/wishlist-item-add-to-cart.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { SubmissionResult } from '@conform-to/react'; +import { useActionState, useEffect } from 'react'; +import { useFormStatus } from 'react-dom'; + +import { Button } from '@/vibes/soul/primitives/button'; +import { toast } from '@/vibes/soul/primitives/toaster'; + +import { WishlistItem } from '.'; + +type Action = (state: Awaited, payload: P) => S | Promise; + +interface State { + lastResult: SubmissionResult | null; + successMessage?: React.ReactNode; + errorMessage?: string; +} + +export type AddWishlistItemToCartAction = Action; + +interface Props extends Omit { + action: AddWishlistItemToCartAction; +} + +export const WishlistItemAddToCart = ({ + callToAction = { label: 'Add to cart' }, + productId, + variantId, + action, +}: Props) => { + const [{ lastResult, successMessage, errorMessage }, formAction] = useActionState(action, { + lastResult: null, + }); + + useEffect(() => { + if (lastResult?.status === 'success' && successMessage) { + toast.success(successMessage); + } + + if (lastResult?.status === 'error' && errorMessage) { + toast.error(errorMessage); + } + }, [lastResult, successMessage, errorMessage]); + + return ( +
+ + + + + ); +}; + +function SubmitButton({ ctaLabel, disabled }: { ctaLabel: string; disabled?: boolean }) { + const { pending } = useFormStatus(); + + return ( + + ); +}