Spa Day Package Builder

Build your day

Design your spa day

Pick as many treatments as you like. Mix and match across categories — bundle three or more and save 15% automatically.

Swedish Massage

Gentle full-body massage to release tension

1h$89

Deep Tissue

Targeted pressure on chronic muscle knots

1h$120

Hot Stone Therapy

Heated basalt stones for deep relaxation

1h 15m$140

Your package

Your package is empty

Pick treatments above to build your day

About this block

Spa Day Package BuilderPRO

Multi-service booking composer. Customers assemble their own wellness day by adding services across categories (massage/facial/body/nails/add-ons), with live package summary, duration totals, animated bundle-discount progress bar, quick-start presets, and remove-from-package controls.

Booking22: Spa Day Package Builder

A multi-service booking composer that renders a category-tabbed catalog of services (each with an icon, name, description, duration, and price) above a live "Your package" panel, where each pick gets a quantity stepper, a running subtotal and total-duration tally, an animated bundle-discount progress bar that applies a percentage off once a minimum item count is reached, per-line remove controls, and a final book button.

Upgrade to Pro

Pro blocks install through the shadcn CLI with your license key and ship their full source. Docs and live previews stay open to everyone, so you can read every block's details first.

Upgrade Now

Installation

Swap YOUR_EMAIL and YOUR_KEY for the email and license key on your account. Find your license key on your account page.

Radix flavor

bash
npx shadcn add "https://ui.beste.co/r/booking22?email=YOUR_EMAIL&license_key=YOUR_KEY"

Base UI flavor

bash
npx shadcn add "https://ui.beste.co/r-base/booking22?email=YOUR_EMAIL&license_key=YOUR_KEY"

This installs the block to components/beste/block/booking22.tsx, plus the badge, button, and tabs shadcn/ui primitives it uses for the eyebrow badge, the add/book buttons, and the category filter tabs.

Quick start

The installed file exports booking22Demo alongside the block: the exact props behind the preview above. Spread it to get a working package builder in one line.

tsx
import { Booking22, booking22Demo } from "@/components/beste/block/booking22";

export default function Page() {
  return <Booking22 {...booking22Demo} />;
}

Then replace the demo with your own props. Written out, a trimmed setup looks like this:

tsx
import { Hand, Heart, Sparkles } from "lucide-react";
import { Booking22 } from "@/components/beste/block/booking22";

export default function Page() {
  return (
    <Booking22
      badge={{ label: "Build your day", variant: "secondary" }}
      heading="Design your <strong>spa day</strong>"
      description="Mix and match across categories, bundle three or more and save."
      currency="$"
      categories={[
        { value: "massage", label: "Massage", icon: Hand },
        { value: "facial", label: "Facial", icon: Heart },
      ]}
      services={[
        {
          value: "swedish",
          category: "massage",
          name: "Swedish Massage",
          description: "Gentle full-body massage to release tension",
          duration: 60,
          price: 89,
          icon: Hand,
        },
        {
          value: "aromatherapy",
          category: "facial",
          name: "Aromatherapy",
          description: "Essential oil upgrade for any service",
          duration: 0,
          price: 25,
          icon: Sparkles,
          maxQty: 5,
        },
      ]}
      bundleDiscount={{
        minItems: 3,
        percent: 15,
        progressTemplate: "Add {remaining} more to save {percent}%",
        activeTemplate: "Bundle discount applied · −{percent}%",
      }}
    />
  );
}

Props

PropTypeDefaultDescription
badge{ label: string; variant?: "default" | "secondary" | "outline" }–Optional eyebrow badge above the heading
headingstring–Section heading, supports inline <strong>
descriptionstring–Paragraph rendered under the heading
currencystring"$"Symbol prefixed to every price and total
categoriesPackageCategory[][]Tabs that filter the service list
servicesPackageService[][]All bookable services, shown by active category
bundleDiscountBundleDiscountConfig–Optional minimum-items discount and progress bar
labelsBooking22Labels{}Overrides for every piece of UI text
classNamestring–Extra classes for the outer <section>
ts
type PackageCategory = {
  value: string;
  label: string;
  icon: LucideIcon;
};

type PackageService = {
  value: string;
  category: string;
  name: string;
  description: string;
  duration: number; // minutes; 0 hides the duration line
  price: number;
  icon: LucideIcon;
  maxQty?: number; // caps the stepper; defaults to 10
};

type BundleDiscountConfig = {
  minItems: number;
  percent: number;
  progressTemplate: string; // supports {remaining} and {percent}
  activeTemplate: string; // supports {percent}
};

type Booking22Labels = {
  addButton?: string;
  addedLabel?: string;
  packageHeading?: string;
  emptyPackage?: string;
  emptyHint?: string;
  subtotalLabel?: string;
  discountLabel?: string;
  totalLabel?: string;
  totalDurationLabel?: string;
  bookButton?: string;
  clearButton?: string;
  singleServiceSuffix?: string;
  multipleServicesSuffix?: string;
};

Behavior notes

  • All state is internal useState: activeCategory (initialized to categories[0]?.value) and a counts record keyed by service.value. Nothing is lifted out, so the selected package is not observable via props or callbacks.
  • The service list is filtered strictly by s.category === activeCategory; services whose category does not match a tab value are never shown, and there is no "all" tab.
  • Each row toggles between an outline "Add" Button (count 0) and a stepper (count > 0); the stepper + is disabled at maxQty (default 10), and pressing − at count 1 removes the item entirely. The package-panel per-line button also removes the whole line in one click via removeAll.
  • bundleDiscount drives the progress bar: bundleActive flips true once totalItems >= minItems, discountAmount is Math.round(subtotal * percent / 100), and the discount row only renders when discountAmount > 0. progressTemplate/activeTemplate are plain string replacements of {remaining} and {percent}.
  • heading is injected with dangerouslySetInnerHTML, so inline <strong> is styled bold/primary; pass only trusted markup.
  • A service with duration: 0 hides its clock line entirely and contributes nothing to the total-time tally (formatDuration renders "0m" only for the aggregate total when empty).
  • The bookButton early-returns when totalItems === 0 and otherwise only calls console.log("Package booking:", ...); wiring the actual booking submission is left to the integrator. Note that the addedLabel label exists in the Booking22Labels type but is never destructured or rendered, and the "quick-start presets" mentioned in the block description are not implemented in this code.

More Booking blocks

View all Booking
FREE

booking4

Service Selector

PRO

booking24

Service And Slot Picker

PRO

booking3

Appointment List

Get Pro access