Frequently Bought Together
blockcommerceE-commerce frequently bought together bundle builder with interactive product preview cards, checkbox selection, dynamic bundle discount calculation, and one-click cart checkout.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/frequently-bought-together.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/frequently-bought-together.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/frequently-bought-together.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/frequently-bought-together.jsonManifest:
https://uipkge.dev/r/svelte/frequently-bought-together.jsonInstalls to:src/lib/components/blocks/frequently-bought-together/Variants
Loading interactive previews…
Loading interactive previews…
npm dependencies
Used by
Files installed (1)
src/lib/components/blocks/frequently-bought-together/FrequentlyBoughtTogether.svelte7.8 kB
<script lang="ts" module> export interface BundleItem { id: string name: string subtitle: string price: number image: string isMain?: boolean } export interface FrequentlyBoughtTogetherProps { className?: string } </script> <script lang="ts"> import { Plus, ShoppingBag, Truck } from '@lucide/svelte' import { Badge } from '$lib/components/ui/badge' import { Button } from '$lib/components/ui/button' import { Card } from '$lib/components/ui/card' import { Checkbox } from '$lib/components/ui/checkbox' import { cn } from '$lib/utils' let { className }: FrequentlyBoughtTogetherProps = $props() const items: BundleItem[] = [ { id: 'headphones', name: 'Pro Studio Headphones', subtitle: 'Space Black · Over-Ear Wireless ANC', price: 299.0, image: 'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=500&auto=format&fit=crop&q=80', isMain: true, }, { id: 'case', name: 'Premium Hard Shell Case', subtitle: 'Weather-resistant EVA shell with plush velvet lining', price: 45.0, image: 'https://images.unsplash.com/photo-1583394838336-acd977736f90?w=500&auto=format&fit=crop&q=80', }, { id: 'cable', name: 'Braided 3.5mm Audiophile Cable', subtitle: '1.5m Silver-plated OFC copper with gold-plated jacks', price: 25.0, image: 'https://images.unsplash.com/photo-1546435770-a3e426bf472b?w=500&auto=format&fit=crop&q=80', }, ] let selectedIds = $state<string[]>(['headphones', 'case', 'cable']) function toggleItem(id: string) { if (selectedIds.includes(id)) { selectedIds = selectedIds.filter((itemId) => itemId !== id) } else { selectedIds = [...selectedIds, id] } } const selectedItems = $derived(items.filter((item) => selectedIds.includes(item.id))) const selectedCount = $derived(selectedItems.length) const originalTotal = $derived(selectedItems.reduce((sum, item) => sum + item.price, 0)) const discountRate = $derived(selectedCount >= 2 ? 0.15 : 0) const discountAmount = $derived(originalTotal * discountRate) const finalPrice = $derived(originalTotal - discountAmount) const hasDiscount = $derived(discountRate > 0) const buttonText = $derived.by(() => { if (selectedCount === items.length) return `Add all ${items.length} to Cart` if (selectedCount > 1) return `Add ${selectedCount} selected to Cart` if (selectedCount === 1) return 'Add 1 selected to Cart' return 'Select items to add' }) const deliveryNote = $derived.by(() => { if (selectedCount >= 2) return 'Free express delivery included with this bundle' if (selectedCount === 1) return 'Standard delivery included' return 'Select items to view delivery options' }) function formatCurrency(val: number) { return '$' + val.toFixed(2) } </script> <Card data-slot="frequently-bought-together" class={cn('mx-auto w-full max-w-4xl space-y-6 p-6 shadow-xs', className)}> <!-- Header --> <div class="space-y-1.5"> <h2 class="text-foreground text-xl font-bold tracking-tight sm:text-2xl">Frequently Bought Together</h2> <p class="text-muted-foreground text-sm"> Pair with these recommended essentials for maximum performance and save 15% on the bundle </p> </div> <!-- Product Visual Row --> <div class="grid grid-cols-1 items-center gap-3 sm:grid-cols-[1fr_auto_1fr_auto_1fr] sm:gap-4"> {#each items as item, index (item.id)} <div class={cn( 'border-border bg-muted/20 relative flex flex-col rounded-xl border p-3 text-left transition-colors duration-200 sm:p-4', selectedIds.includes(item.id) ? 'opacity-100' : 'opacity-40 grayscale', )} > <div class="border-border bg-background relative mb-3 aspect-square w-full overflow-hidden rounded-lg border"> <img src={item.image} alt={item.name} class="size-full object-cover" /> {#if item.isMain} <Badge variant="secondary" class="bg-background/90 text-foreground absolute top-2 left-2 text-xs font-medium backdrop-blur-xs" > This item </Badge> {/if} </div> <span class="text-foreground line-clamp-1 text-xs leading-tight font-semibold sm:text-sm"> {item.name} </span> <span class="text-foreground mt-1 text-xs font-semibold tabular-nums sm:text-sm"> {formatCurrency(item.price)} </span> </div> {#if index < items.length - 1} <div class="border-border/50 text-muted-foreground bg-muted mx-auto flex size-8 shrink-0 items-center justify-center rounded-full border" aria-hidden="true" > <Plus class="size-4" /> </div> {/if} {/each} </div> <!-- Item Selection Checkbox List --> <div class="space-y-2.5"> {#each items as item (item.id)} <div role="button" tabindex={0} class={cn( 'border-border focus-visible:ring-ring flex cursor-pointer items-start justify-between gap-3 rounded-lg border p-3.5 transition-colors select-none focus-visible:ring-2 focus-visible:outline-none sm:items-center', selectedIds.includes(item.id) ? 'bg-card hover:border-primary/40' : 'border-border/60 bg-muted/10 opacity-60 hover:opacity-80', )} onclick={() => toggleItem(item.id)} onkeydown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault() toggleItem(item.id) } }} > <div class="flex items-start gap-3 sm:items-center"> <Checkbox id={item.id} checked={selectedIds.includes(item.id)} class="pointer-events-none mt-0.5 sm:mt-0" tabindex={-1} /> <div class="space-y-0.5"> <div class="flex flex-wrap items-center gap-2"> <span class="text-foreground text-sm font-medium"> {#if item.isMain}<span class="font-semibold">This item: </span>{/if} {item.name} </span> {#if item.isMain}<Badge variant="secondary" class="text-xs"> Main Item </Badge>{/if} </div> <p class="text-muted-foreground text-xs">{item.subtitle}</p> </div> </div> <span class="text-foreground shrink-0 text-sm font-semibold tabular-nums"> {formatCurrency(item.price)} </span> </div> {/each} </div> <!-- Price & Bundle Checkout Summary Box --> <div class="border-border bg-muted/30 flex flex-col gap-4 rounded-xl border p-4 sm:p-5 md:flex-row md:items-center md:justify-between" > <div class="space-y-1.5"> <div class="text-muted-foreground text-xs font-medium tracking-wider uppercase"> {selectedCount >= 2 ? 'Bundle Price' : 'Total Price'} </div> <div class="flex flex-wrap items-baseline gap-2.5"> <span class="text-foreground text-2xl font-bold tracking-tight tabular-nums sm:text-3xl"> {formatCurrency(finalPrice)} </span> {#if hasDiscount} <span class="text-muted-foreground text-sm font-medium tabular-nums line-through sm:text-base"> {formatCurrency(originalTotal)} </span> <Badge variant="outline" class="border-success/30 bg-success/10 text-success text-xs font-medium"> Save {formatCurrency(discountAmount)} (15% Bundle Discount) </Badge> {/if} </div> <div class="text-muted-foreground flex items-center gap-1.5 text-xs"> <Truck class="text-success size-3.5 shrink-0" /> <span>{deliveryNote}</span> </div> </div> <Button size="lg" disabled={selectedCount === 0} class="w-full gap-2 font-semibold shadow-xs md:w-auto"> <ShoppingBag class="size-4" /> <span>{buttonText}</span> </Button> </div> </Card>
Raw manifest:https://uipkge.dev/r/svelte/frequently-bought-together.json