Menu

Change language

GitHub repo

Frequently Bought Together

blockcommerce

E-commerce frequently bought together bundle builder with interactive product preview cards, checkbox selection, dynamic bundle discount calculation, and one-click cart checkout.

Installation

$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/frequently-bought-together.json
Manifest:https://uipkge.dev/r/svelte/frequently-bought-together.jsonInstalls to:src/lib/components/blocks/frequently-bought-together/

Variants

Loading interactive previews…

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