PRD: Booking-Based Package Type

Overview

NeetoCal packages currently support only duration-based limits (e.g., "120 minutes of coaching"). This enhancement introduces a second package type: booking-based packages (e.g., "4 coaching sessions"). Both types coexist. A host selects the type when creating or editing a package.


Motivation

Duration-based packages work well for variable-length appointments, but many use cases are session-oriented — a client purchases N sessions and redeems them one at a time. Supporting booking-based packages removes the need for hosts to mentally calculate total minutes and gives customers a clearer understanding of what they are purchasing.


Feature Details

1. Package Creation & Editing (Admin)

A new required "Package type" section is added to the package form, implemented as two radio button cards with a title and description each:

  • Duration based — existing behavior. Description: "Clients can use this package until the total package duration is exhausted."

  • Bookings based — new. Description: "Clients can make a fixed number of bookings using this package."

The section header reads: "Choose whether this package is redeemed by total minutes or by a fixed number of bookings."

Neither option is selected by default on the create page. When the user attempts to save without selecting, an error message appears: "Select how clients can redeem this package before saving."

The selected card is highlighted with a green border and background (neeto-ui-border-success-500, neeto-ui-bg-success-100). The radio indicator fills with green when selected. Clicking a card selects it and expands an inline section with the relevant input fields.

Depending on the selected type, the expanded section shows:

Package Type

Fields Shown

Duration based

"Duration" — number input + hours/minutes dropdown, plus a "Notes" field

Bookings based

"Total bookings" — number input, plus a "Notes" field

Validation rules:

  • Package type is required; an error is shown if saved without selecting.

  • Duration-based: Duration is required, must be a positive integer.

  • Booking-based: Total bookings is required, must be a positive integer (minimum 1).

  • Duration-specific validations (e.g., "duration must be evenly divisible by meeting duration" for Stripe packages, and "all meetings must have the same duration") are skipped for booking-based packages.

  • The save button is always enabled (not disabled when the form is pristine); it is only disabled while a submission is in progress.

  • On failed submission, the page scrolls smoothly to the first visible error message.

2. Booking Deduction

  • Duration-based (unchanged): Each booking deducts the meeting's duration (in minutes) from the purchase's remaining duration.

  • Booking-based: Each booking deducts 1 booking from the purchase's remaining bookings, regardless of the meeting's duration.

3. Recurring Bookings

For recurring bookings with N occurrences, N bookings are deducted (one per occurrence), not 1.

4. Multi-Spot Group Bookings

When a customer reserves multiple spots in a group meeting, the deduction is multiplied by the number of spots reserved. This applies to both package types and all flows (booking, cancellation, rejection, recurring).

Scenario

Duration-based

Booking-based

Single-spot booking (30-min meeting)

30 minutes deducted

1 booking deducted

3-spot group booking (30-min meeting)

90 minutes deducted (30 × 3)

3 bookings deducted

Recurring booking: 4 occurrences, 1 spot each

120 minutes deducted (30 × 4)

4 bookings deducted

Recurring booking: 4 occurrences, 2 spots each

240 minutes deducted (30 × 4 × 2)

8 bookings deducted

Cancellation of a 3-spot group booking

90 minutes restored

3 bookings restored

Rejection of a 3-spot group booking

90 minutes restored

3 bookings restored

5. Cancellation

When a booking is cancelled:

  • Duration-based: The meeting's duration (× number of spots) is restored to the purchase.

  • Booking-based: The number of spots is restored to the purchase.

6. Booking Rejection (Approval Flow)

When a host rejects a booking that required approval:

  • Duration-based: The meeting's duration (× number of spots) is restored to the purchase.

  • Booking-based: The number of spots is restored to the purchase.

7. Awaiting Approval

When a booking is created that requires host approval:

  • The booking count (or duration) is deducted at the time of booking creation, not at the time of approval. This is the same behavior as confirmed bookings.

  • If the host later rejects the booking, the value is refunded (see above).

8. Purchase Code Validation

When a customer enters a package purchase code on the scheduling link:

  • Duration-based: Code is valid if remaining duration >= meeting's duration.

  • Booking-based: Code is valid if remaining bookings >= 1.

9. Admin Purchase Management

On the admin purchase detail page, hosts can manually adjust a purchase's remaining value:

Package Type

Available Actions

Input Label

Duration-based

"Add minutes" / "Deduct minutes"

"Number of minutes"

Booking-based

"Add bookings" / "Deduct bookings"

"Number of bookings"

Validation: Cannot deduct more than the remaining value.

10. Package Purchase (Customer Side)

When a customer purchases a booking-based package:

  • The purchase is created with bookings_remaining set to the package's total_bookings.

  • Code expiry works identically for both package types (independent of type).

UI Changes

Package Form (Create/Edit) — Admin

Element

Before

After

Package type selector

Did not exist

Two radio button cards ("Duration based" / "Bookings based"), neither selected by default on create

Package type selector style

Selected card highlighted with green border + background; radio indicator fills green

Package type section title

Did not exist

"Package type" (body2 semibold) with description below

Package type section description

Did not exist

"Choose whether this package is redeemed by total minutes or by a fixed number of bookings."

Duration input label

"Package duration"

"Duration" (shown inline inside selected card)

Duration input

Number input + hours/minutes dropdown

Number input + hours/minutes dropdown (inside selected card)

Bookings input

Did not exist

"Total bookings" — number input (shown inline inside selected card)

Package type validation error

None

"Select how clients can redeem this package before saving."

Package List Table — Admin

Element

Before

After

Column header

"Duration"

"Package limit"

Column value

Always showed formatted duration (e.g., "2 hours")

Duration-based: formatted duration (e.g., "2 hours" or "90 minutes"). Booking-based: "X bookings"

Package Show Page (Details Card) — Admin

Element

Before

After

Section header

"Duration"

"Package limit"

Value displayed

Formatted duration only

Duration-based: formatted duration (e.g., "2 hours"). Booking-based: "X booking(s)"

Purchase History Table (Package Show Page) — Admin

Element

Before

After

"Remaining limit" column

Shows duration remaining only

Duration-based: formatted duration remaining (e.g., "2 hours"). Booking-based: bookings remaining (e.g., "3 bookings")

Admin Purchase Detail Page

Element

Before

After

Total label

"Total duration"

"Package limit"

Total value

Formatted duration

Duration-based: formatted duration (e.g., "2 hours"). Booking-based: "X bookings"

Remaining label

"Remaining duration"

"Remaining limit"

Update pane title

"Update remaining duration"

"Update remaining limit"

Update pane actions

"Add minutes" / "Deduct minutes"

Booking-based: "Add bookings" / "Deduct bookings"

Update pane input

"Number of minutes"

Booking-based: "Number of bookings"

Public Package Page (Live Booking Page)

Element

Before

After

Icon

Always Clock icon

Duration-based: Clock. Booking-based: Calendar icon

Value text

Always showed duration (e.g., "2 hours")

Duration-based: formatted duration (e.g., "2 hours" or "90 minutes"). Booking-based: "X bookings"

Intro Page (Package Cards)

Element

Before

After

Package value

Always showed "X minutes" next to price

Duration-based: formatted duration (e.g., "2 hours"). Booking-based: "X bookings"

Purchase Confirmation Page (Customer-Facing)

Element

Before

After

Package value label

"Package duration"

"Package limit"

Package value

Duration only

Duration-based: formatted duration (e.g., "2 hours"). Booking-based: "X bookings"

Meeting cards

Showed only meeting name + "Book now" button

Now also shows meeting duration (with clock icon), and price or "Free" below the meeting name

"Book now" button

Opened the scheduling link

Now pre-fills the customer's name and email on the scheduling link, so the customer does not have to re-enter them

Discount / Purchase Code Section (Live Booking Payment Page)

Element

Before

After

Remaining balance message

Always showed remaining duration

Duration-based: remaining duration. Booking-based: "X booking(s) remaining"


Activity Tracking

New Activity Types

Activity

When Logged

Details Shown

Bookings added

Admin manually adds bookings to a purchase

"Bookings added: X", "Remaining bookings: Y", Notes

Bookings deducted

Admin manually deducts bookings from a purchase

"Bookings deducted: X", "Remaining bookings: Y", Notes

Modified Activity

Activity

Before

After

Booking confirmed

Shows "Remaining duration: 2 hours"

Duration-based: same. Booking-based: shows "Remaining bookings: X"


Email Changes

Purchase-related emails (purchase confirmation to customer and host):

Element

Before

After

Label

"Package duration" (always)

"Package limit"

Value

Formatted duration (e.g., "2 hours")

Duration-based: formatted duration (e.g., "2 hours"). Booking-based: "X bookings"


Text & Label Changes (Complete List)

Location

Old Text

New Text

Package form: type selector style

Dropdown ("Redeemable for")

Radio button cards ("Package type" section)

Package form: type section description

— (new)

"Choose whether this package is redeemed by total minutes or by a fixed number of bookings."

Package form: type card 1 label

— (new)

"Duration based"

Package form: type card 1 description

— (new)

"Clients can use this package until the total package duration is exhausted."

Package form: type card 2 label

— (new)

"Bookings based"

Package form: type card 2 description

— (new)

"Clients can make a fixed number of bookings using this package."

Package form: type validation error

— (new)

"Select how clients can redeem this package before saving."

Package form: code expiry label

"Code expiry in days"

"Package validity"

Package form: code expiry description

— (new)

"Set how many days the package code remains valid after purchase."

Package form: code expiry placeholder

"Enter days"

"e.g. 30"

Package form: duration label

"Package duration"

"Duration" (shown inline inside selected card)

Package form: duration input

Number + hours/minutes dropdown

Number input + hours/minutes dropdown (inside selected card)

Package form: bookings label

— (new)

"Total bookings" (shown inline inside selected card)

Package form: bookings input

— (new)

Number input (inside selected card)

Package list: column header

"Duration"

"Package limit"

Package show: section header

"Duration"

"Package limit"

Purchase detail: total label

"Total duration"

"Package limit"

Purchase detail: remaining label

"Remaining duration"

"Remaining limit"

Purchase detail: add action (booking-based)

— (new)

"Add bookings"

Purchase detail: deduct action (booking-based)

— (new)

"Deduct bookings"

Purchase detail: input label (booking-based)

— (new)

"Number of bookings"

Purchase confirmation page: value label

"Package duration"

"Package limit"

Purchase confirmation page: title

(was corrupted)

"You've bought a package but your meetings aren't booked yet."

Booking count display (singular)

— (new)

"1 booking"

Booking count display (plural)

— (new)

"X bookings"

Validation: package name required

"Package is required"

"Package name is required"


Edge Cases & Scenarios

Scenario

Expected Behavior

Customer books a 30-min meeting using a booking-based package

1 booking is deducted (not 30 minutes)

Customer books a group meeting with 3 spots using a booking-based package

3 bookings are deducted

Customer books a group meeting with 3 spots using a duration-based package (30-min meeting)

90 minutes (30 × 3) are deducted

Customer books a recurring meeting with 4 occurrences using a booking-based package

4 bookings are deducted (one per occurrence)

Customer books a recurring group meeting with 4 occurrences and 2 spots each (booking-based)

8 bookings are deducted (2 per occurrence)

Customer cancels a single-spot booking made with a booking-based package

1 booking is restored

Customer cancels a group booking (3 spots) made with a booking-based package

3 bookings are restored

Host rejects a single-spot booking (approval flow) made with a booking-based package

1 booking is restored

Host rejects a group booking (3 spots, approval flow) made with a booking-based package

3 bookings are restored

Customer enters a purchase code when bookings_remaining is 0

Code is rejected as exhausted

Customer enters a purchase code when bookings_remaining >= 1

Code is accepted regardless of meeting duration

Admin creates a booking-based package with Stripe payment

Duration-divisibility validation is skipped

Admin creates a booking-based package with meetings of different durations

"All meetings must have same duration" validation is skipped

Package list shows a mix of duration-based and booking-based packages

Each row shows the correct format ("2 hours" or "90 minutes" vs. "4 bookings")

Existing duration-based packages

Unaffected. Default package type is "duration"; all existing packages remain duration-based

Code expiry on booking-based package

Works identically to duration-based; code expires after the configured number of days regardless of package type

Customer clicks "Book now" on purchase confirmation page

Redirects to scheduling link with name and email pre-filled

Meeting on purchase confirmation page has no price set

"Free" is shown instead of a price

Meeting on purchase confirmation page has no duration

Duration is not shown (graceful fallback)


Design Decisions

1. Unified terminology: "Package limit" and "Remaining limit": All labels use unified terms regardless of package type. "Package limit" is used for the total value (list table, show page, purchase detail, purchase confirmation). "Remaining limit" is used for the remaining value. "Update remaining limit" is used for the modal title. The values themselves are type-specific (e.g., "2 hours" for duration, "5 bookings" for booking-based), but the labels are always the same.

2. Duration display supports minutes and hours: Package durations are displayed in the most natural unit. Values evenly divisible by 60 are shown in hours (e.g., 120 minutes → "2 hours"), otherwise in minutes (e.g., 90 minutes → "90 minutes"). This matches the behavior of meeting duration display elsewhere in the application.

3. Radio button cards instead of dropdown: The package type selector uses two radio button cards rather than a dropdown. Cards remain fully visible at all times, showing the type name and a short description. The selected card is highlighted with a green border and background; its inline input fields expand below the description. This avoids the "hidden option" problem of a collapsed dropdown and gives users immediate visual context about both options before choosing.

Neither card is pre-selected on the create page. If the user tries to save without selecting, an inline error message is shown beneath the cards.

4. Duration input retains hours/minutes dropdown: The duration input keeps the hours/minutes dropdown from the existing form, since durations are now displayed in both minutes and hours.

5. Calendar icon for booking-based: On the live package page, the clock icon is semantically tied to time/duration. For booking-based packages, a Calendar icon is used instead to represent discrete sessions.

6. Duration and price on purchase confirmation scheduling links: The meeting cards on the purchase confirmation page now show the meeting's duration (with a clock icon) and either the price or "Free" below the meeting name. Previously only the name and "Book now" button were shown. This gives the customer enough context to choose which scheduling link to book without leaving the page.

7. Pre-fill via URL params: When a customer clicks "Book now" on the purchase confirmation page, their name and email are passed as URL parameters to the scheduling link. The scheduling link's pre-fill feature handles the rest, so the customer doesn't have to re-enter their information.

8. Booking deduction at creation time: For bookings that require approval, the booking count is deducted when the booking is created (not when approved). If the host rejects it, the count is restored. This prevents overbooking during the approval wait period.

9. Per-occurrence deduction for recurring: Each occurrence of a recurring booking deducts from the booking count based on the number of spots reserved. A 4-session recurring booking with 1 spot deducts 4 bookings; with 2 spots it deducts 8. This ensures the booking count accurately reflects the number of sessions consumed.

10. Group booking spots multiply deductions: For group meetings where a customer reserves multiple spots, the deduction is always multiplied by the number of spots — for both duration-based and booking-based packages. This applies consistently across booking, cancellation, rejection, and recurring flows.