Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions apps/web/vibes/soul/docs/wishlist-item-card.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
---
title: Wishlist Item Card
preview: wishlist-item-card-example
previewSize: md
---

## Usage

{/* prettier-ignore-start */}

<CodeBlock lang="ts">{`
import { WishlistItemCard } from '@/vibes/soul/primitives/wishlist-item-card';

function Usage() {
return (
<WishlistItemCard
wishlistId="1"
action={action}
item={{
itemId: '1',
productId: '1',
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,
},
callToAction: {
label: 'Add to cart',
},
}}
/>
);
}
`}
</CodeBlock>

{/* 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<S, P> = (state: Awaited<S>, payload: P) => S | Promise<S>;

interface State {
lastResult: SubmissionResult | null;
successMessage?: React.ReactNode;
errorMessage?: string;
}

export type AddWishlistItemToCartAction = Action<State, FormData>;
```

### RemoveWishlistItemAction

```ts
type Action<State, Payload> = (state: Awaited<State>, payload: Payload) => State | Promise<State>;

interface RemoveWishlistItemState {
lastResult: SubmissionResult | null;
errorMessage?: string;
}

export type RemoveWishlistItemAction = Action<RemoveWishlistItemState, FormData>;
```
7 changes: 7 additions & 0 deletions apps/web/vibes/soul/examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Original file line number Diff line number Diff line change
@@ -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<State> {
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<State> {
await new Promise((resolve) => setTimeout(resolve, 1000));

return {
lastResult: { status: 'success' },
successMessage: 'Item removed from wishlist!',
};
}
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<div className="bg-background p-8 @container">
<div className="m-auto flex max-w-screen-lg flex-col gap-8 @md:flex-row">
<WishlistItemCard
wishlistId="1"
action={action}
removeAction={removeAction}
item={{
itemId: '1',
productId: '1',
product: product1,
callToAction: {
label: 'Add to cart',
disabled: false,
},
}}
/>
<WishlistItemCard
wishlistId="1"
action={action}
removeAction={removeAction}
item={{
itemId: '2',
productId: '2',
product: product2,
callToAction: {
label: 'Out of stock',
disabled: true,
},
}}
/>
</div>
</div>
</div>
);
}
6 changes: 6 additions & 0 deletions apps/web/vibes/soul/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]] = [
Expand Down
74 changes: 74 additions & 0 deletions apps/web/vibes/soul/primitives/wishlist-item-card/index.tsx
Original file line number Diff line number Diff line change
@@ -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<ProductCardProps, 'product' | 'showCompare'> {
wishlistId: string;
item: WishlistItem;
action: AddWishlistItemToCartAction;
removeAction?: RemoveWishlistItemAction;
}

export const WishlistItemCard = ({
wishlistId,
item: { itemId, productId, variantId, callToAction, product },
action,
removeAction,
...props
}: WishlistItemCardProps) => {
return (
<div
className="relative flex basis-[calc(100%-1rem)] max-w-md flex-col justify-between gap-3 @md:basis-[calc(50%-0.75rem)] @lg:basis-[calc(33%-0.5rem)] @2xl:basis-[calc(25%-0.25rem)]"
key={product.id}
>
<ProductCard aspectRatio="3:4" product={product} showCompare={false} {...props} />
{callToAction && (
<WishlistItemAddToCart
action={action}
callToAction={callToAction}
productId={productId}
variantId={variantId}
/>
)}
{removeAction && (
<div className="absolute -right-3 -top-3 rounded-full transition-shadow duration-100 hover:shadow-md">
<RemoveWishlistItemButton action={removeAction} itemId={itemId} wishlistId={wishlistId} />
</div>
)}
</div>
);
};

export function WishlistItemSkeleton({ className = '' }: { className?: string }) {
return (
<div
className={clsx(
'flex basis-[calc(100%-1rem)] flex-col justify-between gap-3 @md:basis-[calc(50%-0.75rem)] @lg:basis-[calc(33%-0.5rem)] @2xl:basis-[calc(25%-0.25rem)]',
className,
)}
>
<ProductCardSkeleton aspectRatio="3:4" />
<Skeleton.Box className="min-h-10 rounded-full" />
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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, Payload> = (state: Awaited<State>, payload: Payload) => State | Promise<State>;

interface RemoveWishlistItemState {
lastResult: SubmissionResult | null;
errorMessage?: string;
}

export type RemoveWishlistItemAction = Action<RemoveWishlistItemState, FormData>;

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 (
<form action={(formData) => startTransition(() => formAction(formData))}>
<input name="wishlistId" type="hidden" value={wishlistId} />
<input name="wishlistItemId" type="hidden" value={itemId} />
<Button loading={isPending} shape="circle" size="x-small" type="submit" variant="tertiary">
<XIcon size={20} />
</Button>
</form>
);
};
Loading
Loading