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_remainingset to the package'stotal_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.