Skip to content
Brok UI

Loading…

No results

Bundle Builder

Open source

Build-your-own-bundle pickers — selectable products with a running total and unlock-a-discount progress.

Preview

resources/views/blocks/bundle-builder-box.blade.php Blade
@props([
    'heading' => 'Build your bundle',
    'subheading' => 'Pick :count items and save :discount% — mix and match your perfect box.',
    'bundleSize' => 4,
    'discount' => 15,
    'products' => null,
])

@php
    $products ??= [
        ['id' => 'oat-bar', 'name' => 'Oat & Almond Bars', 'price' => 9.0],
        ['id' => 'cold-brew', 'name' => 'Cold Brew Concentrate', 'price' => 14.5],
        ['id' => 'trail-mix', 'name' => 'Sea Salt Trail Mix', 'price' => 7.25],
        ['id' => 'dark-choc', 'name' => 'Dark Chocolate Squares', 'price' => 11.0],
        ['id' => 'green-tea', 'name' => 'Matcha Green Tea', 'price' => 12.75],
        ['id' => 'fruit-chews', 'name' => 'Real Fruit Chews', 'price' => 6.5],
        ['id' => 'kombucha', 'name' => 'Ginger Kombucha', 'price' => 13.0],
        ['id' => 'protein', 'name' => 'Plant Protein Scoop', 'price' => 19.0],
    ];

    // Translate the subheading tokens up front so the JS layer never has to.
    $resolvedSubheading = __($subheading, [
        'count' => (int) $bundleSize,
        'discount' => (int) $discount,
    ]);
@endphp

<x-ui.section
    data-slot="bundle-builder-01"
    x-data="{
        bundleSize: {{ (int) $bundleSize }},
        discount: {{ (float) $discount }},
        products: {{ Illuminate\Support\Js::from(array_map(fn ($p) => [
            'id' => (string) $p['id'],
            'name' => __($p['name']),
            'price' => (float) $p['price'],
        ], $products)) }},
        selected: {},
        toggle(id) {
            if (this.selected[id]) {
                delete this.selected[id];
            } else if (this.count < this.bundleSize) {
                this.selected[id] = true;
            }
        },
        isSelected(id) {
            return !! this.selected[id];
        },
        isFull(id) {
            return this.count >= this.bundleSize && ! this.selected[id];
        },
        get count() {
            return Object.keys(this.selected).length;
        },
        get subtotal() {
            return this.products
                .filter((p) => this.selected[p.id])
                .reduce((sum, p) => sum + p.price, 0);
        },
        get savings() {
            return this.subtotal * (this.discount / 100);
        },
        get total() {
            return this.subtotal - this.savings;
        },
        get pct() {
            return this.bundleSize > 0 ? Math.min(100, (this.count / this.bundleSize) * 100) : 0;
        },
        get reached() {
            return this.count >= this.bundleSize;
        },
        money(value) {
            return '$' + Number(value).toFixed(2);
        },
    }"
    space="lg" {{ $attributes }}
>
    <x-ui.container gutter="sm">
        <div class="flex flex-col gap-4 text-center">
            <h2 class="text-pretty text-2xl font-semibold tracking-tight sm:text-3xl">
                {{ __($heading) }}
            </h2>
            <p class="mx-auto max-w-2xl text-pretty text-sm leading-relaxed text-muted-foreground sm:text-base">
                {{ $resolvedSubheading }}
            </p>
            <div class="flex justify-center">
                <x-ui.badge variant="secondary" class="gap-2">
                    <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
                        <path d="M20 12v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-8" />
                        <rect width="20" height="5" x="2" y="7" rx="1" />
                        <path d="M12 22V7M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7ZM12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7Z" />
                    </svg>
                    {{ __('Save :discount% on every bundle', ['discount' => (int) $discount]) }}
                </x-ui.badge>
            </div>
        </div>

        <div class="mt-10 grid grid-cols-1 gap-8 lg:grid-cols-[1fr_22rem]">
            {{-- LEFT: selectable product grid --}}
            <div>
                <ul role="list" class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
                    <template x-for="product in products" :key="product.id">
                        <li>
                            <x-ui.card
                                class="flex h-full flex-col gap-4 p-4 transition-shadow"
                                x-bind:class="isSelected(product.id) ? 'ring-2 ring-primary' : 'ring-0'"
                            >
                                {{-- Image placeholder --}}
                                <div class="relative flex aspect-square items-center justify-center rounded-md bg-muted text-muted-foreground">
                                    <svg class="size-10 opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
                                        <rect width="18" height="18" x="3" y="3" rx="2" />
                                        <circle cx="9" cy="9" r="2" />
                                        <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
                                    </svg>
                                    <span
                                        x-show="isSelected(product.id)"
                                        x-cloak
                                        class="absolute end-2 top-2 flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground"
                                        aria-hidden="true"
                                    >
                                        <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
                                            <path d="M20 6 9 17l-5-5" />
                                        </svg>
                                    </span>
                                </div>

                                <div class="flex flex-1 flex-col gap-2">
                                    <h3 class="text-sm font-medium leading-tight text-foreground" x-text="product.name"></h3>
                                    <p class="text-sm font-semibold text-foreground" x-text="money(product.price)"></p>
                                </div>

                                <x-ui.button
                                    type="button"
                                    variant="outline"
                                    size="sm"
                                    class="w-full"
                                    x-on:click="toggle(product.id)"
                                    x-bind:aria-pressed="isSelected(product.id) ? 'true' : 'false'"
                                    x-bind:disabled="isFull(product.id)"
                                    x-bind:class="isSelected(product.id) ? 'border-primary bg-primary/10 text-primary' : ''"
                                >
                                    <span x-show="! isSelected(product.id)">{{ __('Add to bundle') }}</span>
                                    <span x-show="isSelected(product.id)" x-cloak>{{ __('Remove') }}</span>
                                </x-ui.button>
                            </x-ui.card>
                        </li>
                    </template>
                </ul>
            </div>

            {{-- RIGHT: sticky summary --}}
            <aside aria-label="{{ __('Bundle summary') }}">
                <div class="sticky top-6">
                    <x-ui.card class="flex flex-col gap-6 p-6">
                        <div class="flex flex-col gap-2">
                            <div class="flex items-baseline justify-between gap-2">
                                <h3 class="text-base font-semibold tracking-tight text-foreground">
                                    {{ __('Your bundle') }}
                                </h3>
                                <span class="text-sm text-muted-foreground">
                                    <span x-text="count"></span><span aria-hidden="true"> / </span><span class="sr-only">{{ __('of') }}</span><span x-text="bundleSize"></span>
                                </span>
                            </div>

                            {{-- The progress primitive supplies the accessible track; the
                                 Alpine fill overlays it so the width can track selection live
                                 (a spliced width class would be invisible to the JIT). --}}
                            <div class="relative">
                                <x-ui.progress
                                    :value="0"
                                    x-bind:aria-valuenow="count"
                                    x-bind:aria-valuemax="bundleSize"
                                    :label="__('Bundle progress')"
                                />
                                <div
                                    data-slot="bundle-builder-01-fill"
                                    class="pointer-events-none absolute inset-y-0 start-0 rounded-full bg-primary transition-[width] duration-300 ease-out"
                                    x-bind:style="'width: ' + pct + '%'"
                                    aria-hidden="true"
                                ></div>
                            </div>

                            <p class="text-sm text-muted-foreground" aria-live="polite">
                                <span x-show="! reached">
                                    <span x-text="bundleSize - count"></span> {{ __('more to unlock your discount') }}
                                </span>
                                <span x-show="reached" x-cloak class="font-medium text-primary">
                                    {{ __('Bundle complete — discount applied!') }}
                                </span>
                            </p>
                        </div>

                        <dl class="flex flex-col gap-2 text-sm">
                            <div class="flex items-center justify-between gap-2">
                                <dt class="text-muted-foreground">{{ __('Items') }}</dt>
                                <dd class="font-medium text-foreground" x-text="count"></dd>
                            </div>
                            <div class="flex items-center justify-between gap-2">
                                <dt class="text-muted-foreground">{{ __('Subtotal') }}</dt>
                                <dd class="font-medium text-foreground" x-text="money(subtotal)"></dd>
                            </div>
                            <div class="flex items-center justify-between gap-2">
                                <dt class="text-muted-foreground">
                                    {{ __('Bundle discount') }}
                                    <span class="text-muted-foreground">(<span x-text="discount"></span>%)</span>
                                </dt>
                                <dd class="font-medium text-primary" x-text="'-' + money(savings)"></dd>
                            </div>
                            <div class="mt-2 flex items-center justify-between gap-2 border-t border-border pt-4">
                                <dt class="text-base font-semibold text-foreground">{{ __('Total') }}</dt>
                                <dd class="text-base font-semibold text-foreground" x-text="money(total)"></dd>
                            </div>
                        </dl>

                        <x-ui.button
                            type="button"
                            size="lg"
                            class="w-full"
                            x-bind:disabled="! reached"
                            x-bind:aria-disabled="! reached ? 'true' : 'false'"
                        >
                            <span x-show="reached">{{ __('Add bundle to cart') }}</span>
                            <span x-show="! reached" x-cloak>
                                {{ __('Add') }} <span x-text="bundleSize - count"></span> {{ __('more') }}
                            </span>
                        </x-ui.button>

                        <p class="text-pretty text-center text-sm text-muted-foreground">
                            {{ __('Free shipping on every bundle. Swap items anytime before checkout.') }}
                        </p>
                    </x-ui.card>
                </div>
            </aside>
        </div>
    </x-ui.container>
</x-ui.section>

Installation

terminal
php artisan ui:add blocks/bundle-builder-box

Installs these dependencies automatically:

Registry contract

Install confidence

php artisan ui:add blocks/bundle-builder-box writes only the generated targets below. The CLI validates each file hash before writing and prompts before replacing local changes unless --force is used.

Version
1.0.4
License
open
Stability
stable
Contract
v1
Foundation
≥ 1.0.0
Type Generated target
blade resources/views/blocks/bundle-builder-box.blade.php

Registry dependencies

badge button card container progress section

Package dependencies

composer: jml/brok:^0.2

Use with AI

A brief for your coding agent: what the block is, the install command, how to render it, its props and the rules. Copy it, or open a prompt about this block in an assistant.

bundle-builder-box.md
# Brok UI block: Bundle Builder (`bundle-builder-box`)

An interactive build-a-box bundle builder with a responsive grid of selectable product cards on the left (image placeholder, title, price, and an add/remove toggle) and a sticky summary aside on the right showing a progress bar toward the target bundle size, a running item count, subtotal, discount, and total, plus an add-to-cart button that stays disabled until the minimum is reached.

Brok UI is a Laravel Blade registry. `ui:add` copies this block into the app as plain Blade the app owns; it composes installed `<brok:*>` primitives and semantic design tokens.

## Install

```bash
php artisan ui:add blocks/bundle-builder-box
```

## Render it

```blade
<x-blocks.bundle-builder-box />
```

## Props

- `heading` (string, default `Build your bundle`)
- `subheading` (string, default `Pick :count items and save :discount% — mix and match your perfect box.`)
- `bundle-size` (int, default `4`)
- `discount` (int, default `15`)
- `products` (mixed|null, default `null`)

## Use when

- Letting a shopper build a fixed-size product bundle by selecting items from a grid until the target count is reached.
- Showing a sticky summary with a progress bar, running subtotal, discount and total that update as items are toggled.

## Avoid when

- Use compare-products or frequently-bought-together for a fixed, non-configurable product grouping instead of a build-your-own bundle.

## Rules

- Render the installed block with `<x-blocks.bundle-builder-box />` and pass data through its props; edit the copied file only for structural changes.
- Keep the semantic design tokens (`bg-background`, `text-muted-foreground`); never swap in raw colour utilities.
- Keep the `data-slot` attributes and the logical (start/end) spacing so the markup still mirrors under `dir="rtl"`.

## Links

- Docs: https://brokui.dev/blocks/bundle-builder-box
- Registry JSON (files, props, contract): https://brokui.dev/r/open/blocks/bundle-builder-box.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Guidance

Use when

  • Letting a shopper build a fixed-size product bundle by selecting items from a grid until the target count is reached.
  • Showing a sticky summary with a progress bar, running subtotal, discount and total that update as items are toggled.

Avoid when

  • Use compare-products or frequently-bought-together for a fixed, non-configurable product grouping instead of a build-your-own bundle.

Anti-patterns

  • Do not replace semantic props with conflicting utility classes.
  • Do not remove labels, focus styles, or state attributes.
Anatomy
root bundle-builder-01 bundle-builder-01-fill

Usage

Render the block as a component, passing data where useful:

resources/views/welcome.blade.php Blade
<x-blocks.bundle-builder-box />

Built from primitives

Composes <brok:*> Semantic tokens Re-themeable RTL-ready

This block composes installed <brok:*> primitives and semantic design tokens only — it does not reimplement any primitive. Re-theme it (light, dark, admin, customer) by editing CSS variables; flip the preview to RTL to confirm it mirrors.

Source

The exact, editable file ui:add writes into your app. The preview above renders this same source — there are no preview-only blocks.

resources/views/blocks/bundle-builder-box.blade.php Blade
@props([
    'heading' => 'Build your bundle',
    'subheading' => 'Pick :count items and save :discount% — mix and match your perfect box.',
    'bundleSize' => 4,
    'discount' => 15,
    'products' => null,
])

@php
    $products ??= [
        ['id' => 'oat-bar', 'name' => 'Oat & Almond Bars', 'price' => 9.0],
        ['id' => 'cold-brew', 'name' => 'Cold Brew Concentrate', 'price' => 14.5],
        ['id' => 'trail-mix', 'name' => 'Sea Salt Trail Mix', 'price' => 7.25],
        ['id' => 'dark-choc', 'name' => 'Dark Chocolate Squares', 'price' => 11.0],
        ['id' => 'green-tea', 'name' => 'Matcha Green Tea', 'price' => 12.75],
        ['id' => 'fruit-chews', 'name' => 'Real Fruit Chews', 'price' => 6.5],
        ['id' => 'kombucha', 'name' => 'Ginger Kombucha', 'price' => 13.0],
        ['id' => 'protein', 'name' => 'Plant Protein Scoop', 'price' => 19.0],
    ];

    // Translate the subheading tokens up front so the JS layer never has to.
    $resolvedSubheading = __($subheading, [
        'count' => (int) $bundleSize,
        'discount' => (int) $discount,
    ]);
@endphp

<x-ui.section
    data-slot="bundle-builder-01"
    x-data="{
        bundleSize: {{ (int) $bundleSize }},
        discount: {{ (float) $discount }},
        products: {{ Illuminate\Support\Js::from(array_map(fn ($p) => [
            'id' => (string) $p['id'],
            'name' => __($p['name']),
            'price' => (float) $p['price'],
        ], $products)) }},
        selected: {},
        toggle(id) {
            if (this.selected[id]) {
                delete this.selected[id];
            } else if (this.count < this.bundleSize) {
                this.selected[id] = true;
            }
        },
        isSelected(id) {
            return !! this.selected[id];
        },
        isFull(id) {
            return this.count >= this.bundleSize && ! this.selected[id];
        },
        get count() {
            return Object.keys(this.selected).length;
        },
        get subtotal() {
            return this.products
                .filter((p) => this.selected[p.id])
                .reduce((sum, p) => sum + p.price, 0);
        },
        get savings() {
            return this.subtotal * (this.discount / 100);
        },
        get total() {
            return this.subtotal - this.savings;
        },
        get pct() {
            return this.bundleSize > 0 ? Math.min(100, (this.count / this.bundleSize) * 100) : 0;
        },
        get reached() {
            return this.count >= this.bundleSize;
        },
        money(value) {
            return '$' + Number(value).toFixed(2);
        },
    }"
    space="lg" {{ $attributes }}
>
    <x-ui.container gutter="sm">
        <div class="flex flex-col gap-4 text-center">
            <h2 class="text-pretty text-2xl font-semibold tracking-tight sm:text-3xl">
                {{ __($heading) }}
            </h2>
            <p class="mx-auto max-w-2xl text-pretty text-sm leading-relaxed text-muted-foreground sm:text-base">
                {{ $resolvedSubheading }}
            </p>
            <div class="flex justify-center">
                <x-ui.badge variant="secondary" class="gap-2">
                    <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
                        <path d="M20 12v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-8" />
                        <rect width="20" height="5" x="2" y="7" rx="1" />
                        <path d="M12 22V7M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7ZM12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7Z" />
                    </svg>
                    {{ __('Save :discount% on every bundle', ['discount' => (int) $discount]) }}
                </x-ui.badge>
            </div>
        </div>

        <div class="mt-10 grid grid-cols-1 gap-8 lg:grid-cols-[1fr_22rem]">
            {{-- LEFT: selectable product grid --}}
            <div>
                <ul role="list" class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
                    <template x-for="product in products" :key="product.id">
                        <li>
                            <x-ui.card
                                class="flex h-full flex-col gap-4 p-4 transition-shadow"
                                x-bind:class="isSelected(product.id) ? 'ring-2 ring-primary' : 'ring-0'"
                            >
                                {{-- Image placeholder --}}
                                <div class="relative flex aspect-square items-center justify-center rounded-md bg-muted text-muted-foreground">
                                    <svg class="size-10 opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
                                        <rect width="18" height="18" x="3" y="3" rx="2" />
                                        <circle cx="9" cy="9" r="2" />
                                        <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
                                    </svg>
                                    <span
                                        x-show="isSelected(product.id)"
                                        x-cloak
                                        class="absolute end-2 top-2 flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground"
                                        aria-hidden="true"
                                    >
                                        <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
                                            <path d="M20 6 9 17l-5-5" />
                                        </svg>
                                    </span>
                                </div>

                                <div class="flex flex-1 flex-col gap-2">
                                    <h3 class="text-sm font-medium leading-tight text-foreground" x-text="product.name"></h3>
                                    <p class="text-sm font-semibold text-foreground" x-text="money(product.price)"></p>
                                </div>

                                <x-ui.button
                                    type="button"
                                    variant="outline"
                                    size="sm"
                                    class="w-full"
                                    x-on:click="toggle(product.id)"
                                    x-bind:aria-pressed="isSelected(product.id) ? 'true' : 'false'"
                                    x-bind:disabled="isFull(product.id)"
                                    x-bind:class="isSelected(product.id) ? 'border-primary bg-primary/10 text-primary' : ''"
                                >
                                    <span x-show="! isSelected(product.id)">{{ __('Add to bundle') }}</span>
                                    <span x-show="isSelected(product.id)" x-cloak>{{ __('Remove') }}</span>
                                </x-ui.button>
                            </x-ui.card>
                        </li>
                    </template>
                </ul>
            </div>

            {{-- RIGHT: sticky summary --}}
            <aside aria-label="{{ __('Bundle summary') }}">
                <div class="sticky top-6">
                    <x-ui.card class="flex flex-col gap-6 p-6">
                        <div class="flex flex-col gap-2">
                            <div class="flex items-baseline justify-between gap-2">
                                <h3 class="text-base font-semibold tracking-tight text-foreground">
                                    {{ __('Your bundle') }}
                                </h3>
                                <span class="text-sm text-muted-foreground">
                                    <span x-text="count"></span><span aria-hidden="true"> / </span><span class="sr-only">{{ __('of') }}</span><span x-text="bundleSize"></span>
                                </span>
                            </div>

                            {{-- The progress primitive supplies the accessible track; the
                                 Alpine fill overlays it so the width can track selection live
                                 (a spliced width class would be invisible to the JIT). --}}
                            <div class="relative">
                                <x-ui.progress
                                    :value="0"
                                    x-bind:aria-valuenow="count"
                                    x-bind:aria-valuemax="bundleSize"
                                    :label="__('Bundle progress')"
                                />
                                <div
                                    data-slot="bundle-builder-01-fill"
                                    class="pointer-events-none absolute inset-y-0 start-0 rounded-full bg-primary transition-[width] duration-300 ease-out"
                                    x-bind:style="'width: ' + pct + '%'"
                                    aria-hidden="true"
                                ></div>
                            </div>

                            <p class="text-sm text-muted-foreground" aria-live="polite">
                                <span x-show="! reached">
                                    <span x-text="bundleSize - count"></span> {{ __('more to unlock your discount') }}
                                </span>
                                <span x-show="reached" x-cloak class="font-medium text-primary">
                                    {{ __('Bundle complete — discount applied!') }}
                                </span>
                            </p>
                        </div>

                        <dl class="flex flex-col gap-2 text-sm">
                            <div class="flex items-center justify-between gap-2">
                                <dt class="text-muted-foreground">{{ __('Items') }}</dt>
                                <dd class="font-medium text-foreground" x-text="count"></dd>
                            </div>
                            <div class="flex items-center justify-between gap-2">
                                <dt class="text-muted-foreground">{{ __('Subtotal') }}</dt>
                                <dd class="font-medium text-foreground" x-text="money(subtotal)"></dd>
                            </div>
                            <div class="flex items-center justify-between gap-2">
                                <dt class="text-muted-foreground">
                                    {{ __('Bundle discount') }}
                                    <span class="text-muted-foreground">(<span x-text="discount"></span>%)</span>
                                </dt>
                                <dd class="font-medium text-primary" x-text="'-' + money(savings)"></dd>
                            </div>
                            <div class="mt-2 flex items-center justify-between gap-2 border-t border-border pt-4">
                                <dt class="text-base font-semibold text-foreground">{{ __('Total') }}</dt>
                                <dd class="text-base font-semibold text-foreground" x-text="money(total)"></dd>
                            </div>
                        </dl>

                        <x-ui.button
                            type="button"
                            size="lg"
                            class="w-full"
                            x-bind:disabled="! reached"
                            x-bind:aria-disabled="! reached ? 'true' : 'false'"
                        >
                            <span x-show="reached">{{ __('Add bundle to cart') }}</span>
                            <span x-show="! reached" x-cloak>
                                {{ __('Add') }} <span x-text="bundleSize - count"></span> {{ __('more') }}
                            </span>
                        </x-ui.button>

                        <p class="text-pretty text-center text-sm text-muted-foreground">
                            {{ __('Free shipping on every bundle. Swap items anytime before checkout.') }}
                        </p>
                    </x-ui.card>
                </div>
            </aside>
        </div>
    </x-ui.container>
</x-ui.section>

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.0.4
Status
Stable
License
open
Deprecation
Not deprecated
Contract
v1
Foundation
≥ 1.0.0