Dynamic Pricing 2.0 — Demand & Capacity (Frontend PRD)
Companion to
Dynamic Pricing — Capacity & Demand(the product PRD). This document captures the frontend side of the Demand & Capacity rule: what the partner sees inbook-bite, what the diner sees inhh-pegasus, and the FE engineering contract we will implement.
| Owner | Frontend (Partner FE: book-bite; Customer FE: hh-pegasus) |
| Backend PRD | Dynamic_Pricing_Capacity_Demand_20260701150000.md |
| Backend engineering spec | PricingEngine/013-DEMAND-CAPACITY-CONDITIONS.md |
| Figma | Dynamic-Pricing 2.0 (N8ga1PN2r8ef76438s9OEi), node 139:4137, frame 151:8161 |
| Status | Draft — awaiting backend payload & response finalisation |
1. Scope (v1)
Team decision (2026-07-03): the v1 of Demand & Capacity supports only one seat metric:
| Field | Value | Notes |
|---|---|---|
condition_type | total_seats_booked | The single supported metric in v1 |
relation_type | >, >=, =, <, <=, between | All six remain available |
value / value_min / value_max | positive integer (count of seats) | Stored as integer; see §6 for unit |
action_type | increase / decrease | |
value_type | percentage / fixed | |
scope (time) | per time window (aggregate across 15-minute slots) | Inherited from the underlying Specific Date & Time rule shape; capacity is summed across all 15-minute slots whose start_time falls inside the rule window |
Explicitly out of scope for v1:
remaining_seatscondition typepct_seats_booked/pct_seats_remainingpercentage conditions- Multi-tier “Action / Subaction” structure described in the Figma text annotation (frame
991:11072) is_anytime/ open-ended threshold semantics (these are PRD design tokens; v1 is single threshold per rule)
If/when the team decides to broaden the metric set, the FE form already supports showing more options (§4). The path to broadening is to flip a
showAllConditionsflag inbook-bite/src/pages/pricing-rules/PricingRules.js.
2. Personas & flows
2.1 Partner flow (book-bite, restaurant manager)
- Manager opens Pricing Rules for their restaurant
(
book-bite/src/pages/pricing-rules/PricingRules.vue). - Clicks + Add Rule. A rule-type picker appears in
RulesAndRestrictionsForm.vue(filters viaRULE_TYPE_LIST.filter(item => item.show)). - Selects “Demand & Capacity-Based”.
- Lands on
RuleForm.vue(line 3) which renders the matching form template. For Demand & Capacity, this routes toRuleItemDemandAndCapacity.vue→RuleDemandAndCapacity.vue. - Manager configures:
- Rule name (free text)
- Date/time window (when this rule is in effect — every day, specific date, specific time, specific day-of-week, etc. — same vocabulary as the Specific Date & Time rule)
- Capacity condition (the new block at lines 320-378 of
RuleDemandAndCapacity.vue):- Relation: pick one of
>/>=/=/</<=/between - Value: a single number, or two numbers if
between
- Relation: pick one of
- Action:
increaseordecreasebypercentageorfixed amount - Packages: which packages the rule applies to
- Saves. The rule is POSTed to the partner-pricing-engine API
(
pricing-engine’spartner/v1/pricing_rules_controller.rb). - Manager then sees the new rule card in the rule list (Figma frame
151:8161shape — icon + name + meta + Edit/Reject/Approve buttons).
2.2 Diner flow (hh-pegasus)
- Diner opens a restaurant’s booking page (e.g.
/booking/[restaurantId]). - Selects a date.
SelectedDateTime.vuecallsgetAvailableRulesByTime(line 484) which POSTs topricing-engine/api/v1/get_available_rules_by_time.- For each 15-minute slot, the response includes an
availableTime.pricethat is already adjusted by any active rules (including Demand & Capacity). - The slot renders in
TimePickerButton2.vuewith the adjusted price. - Currently the slot shows
low-price-fontstyling (green) for any available slot — see §5.2 for the bug to fix in this same release.
2.3 Where conditions fire (server-side, for FE context)
The Conditions check on real-time aggregate occupancy across the rule’s time window at the moment the diner opens the time picker. For example, a rule “11am–3pm, > 48 pax booked” sums the booked seats from every 15-minute slot between 11:00 and 14:45; if the total exceeds 48, the price adjustment applies to every API slot whose start_time lies inside that window. As other diners book or cancel, the aggregate may cross the threshold and the rule may flip from “fire” to “don’t fire” between two consecutive fetches. The FE does not compute this — the engine does.
3. UI surface — Partner (book-bite)
3.1 Rule-type picker
Currently hidden (RULE_TYPE_DEMAND_CAPACITY: show: false in
book-bite/src/pages/pricing-rules/PricingRules.js). The pick list
filters via RulesAndRestrictionsForm.vue.
FE action: change show: false → show: true once §6 form contract
is delivered and verified.
3.2 Form layout
The form is a date/time scaffold plus an inline capacity block:
┌─────────────────────────────────────────────────────────────────┐
│ Rule Name │
│ [_______________________________________________________] │
│ │
│ WHEN ┌────────────────────────┐ │
│ │ Date Type ▼ │ (everyday / specific date / │
│ └────────────────────────┘ specific time / day of week /│
│ day+time of week) │
│ │
│ [time-window form repeats for the chosen date type] │
│ │
│ ───────────────────────── ✱ Capacity ──────────────────────── │
│ │
│ WHEN (capacity) │
│ ┌─────────────┐ ┌─────────────┐ ┌──────────┐ │
│ │ Total Seats │ │ [≥] ▼ │ │ [N] │ │ ← relation + value
│ │ Booked │ │ │ │ │ │
│ └─────────────┘ └─────────────┘ └──────────┘ │
│ │
│ DO THIS │
│ ┌────────────┐ ┌──────────────┐ ┌────────────┐ │
│ │ Increase ▼ │ │ Percentage ▼ │ │ [5] % │ │ ← action + value
│ └────────────┘ └──────────────┘ └────────────┘ │
│ │
│ Packages (selected via separate component, not in this file) │
│ │
│ [Save Rule] [Cancel] │
└─────────────────────────────────────────────────────────────────┘
The Condition select renders only one option in v1: “Total Seats
Booked” (label from $vuetify.pricingRules.totalSeatsBooked).
Per the team decision: drop the other 3 condition options entirely from
the select list rather than rendering them grayed-out. Reason: keeps the
form simple, avoids the trap of selecting a metric that won’t survive v2.
3.3 Existing components to reuse
| Concern | File | Notes |
|---|---|---|
| Date type / period / day-of-week | RuleDemandAndCapacity.vue | Reuse as-is — these are inherited from the Specific Date & Time rule shape. |
| Relation select | RuleDemandAndCapacity.vue | Already wired to RULE_RELATION_LIST. No change. |
| Value (single number) | RuleDemandAndCapacity.vue | Already wired. No change. |
| Range value | RuleDemandAndCapacity.vue | Already wired for between. No change. |
| Action / price type / value | RuleDemandAndCapacity.vue | Already wired. No change. |
3.4 Components to modify
| Component | Change | Why |
|---|---|---|
PricingRules.js (RULE_CONDITION_SEAT_LIST) | Reduce to a single option { title: 'Total Seats Booked', value: 'total_seats_booked' }. The form’s v-model condition becomes effectively a constant in v1. | Team decision: drop unused metrics. |
RuleDemandAndCapacity.vue (condition select) | The InputSelect for condition is still useful (validates that the rule has a seat condition selected) but reads from the new single-item list. Optionally hide it (set :items="[]" and default the value to total_seats_booked). | The select on its own is still a valid UI invariant — it asserts the rule has a capacity metric even if there’s only one metric to choose. We keep it to allow future broadening without rewriting. |
PricingRules.js (RULE_TYPE_DEMAND_CAPACITY.show) | Flip show: false to show: true. | Expose the rule type once the form is verified end-to-end. |
RuleDemandAndCapacity.vue (everyday/everyDayOfWeek etc.) | Leave alone — these are inherited from Specific Date & Time and are correctly wired. | No change needed. |
3.5 Validation rules
- The rule must have at least one valid date/time window (already enforced by the date/time validators).
- A relation + (single value or two values for
between) is required. Already enforced by the:rulesprop on each field — no new validation needed. - The action + price type + value fields are required and validated.
valuemust be a positive integer. Today’s v-model bindstype="number"but doesn’t enforcemin="1"— propose adding:min="1"and avalue < 1validator in §6.
3.6 Empty / error / loading states
- Empty rule name → existing form validation surfaces red text.
- Missing packages → existing form validation. Books-bite should refuse to save with zero packages selected.
- Network failure on save → existing partner-portal error toast.
- Network failure on load (rules list) → existing empty-state.
No new states are required by this PRD.
4. UI surface — Diner (hh-pegasus)
4.1 What the diner sees
v1 expectation: zero behavioural change for the diner. The price they see per slot is whatever the engine returns. The engine will include Demand & Capacity adjustments automatically.
4.2 Components (for the FE engineer’s reference; no new code required)
getAvailableRulesByTime.ts— calls the engine’s POST/api/v1/get_available_rules_by_time. Already in place; consumes whatever the engine returns.TimePicker.vue— async loadsTimePickerButton2.vuewhenisDynamicPricingV2is true.TimePickerButton2.vue— rendersstartTime+pricetext per slot.DynamicPricingV2Schemainhh-pegasus/src/types/Pack.ts— the per-rule JSON shape. Verify with backend that the response includes a flag (e.g.isLowPrice/isHighPrice) that the FE can use to choose styling. If not already present, request it from backend.
4.3 Out of scope for v1
- Adding a “high demand” inline label. Figma’s “Remove High Demand Label”
canvas (
1706:12664) is an explicit “do not add” signal. We will not introduce a new label in this release. - Color changes beyond the existing
low-price-fontgreen.
5. Customer-display bug surfaced by this work (fix-in-same-release)
5.1 Bug
TimePickerButton2.vue:
:class="{ 'low-price-font': availableTime.availability, ... }"
This binding applies low-price-font (green) to every available slot,
whether or not the price is actually reduced. Once Demand & Capacity
adjustments are live, an unsold slot and a sold-out + surcharged slot
will look identical — both green.
5.2 Fix (proposed)
Bind the class to a real price-direction flag that the engine provides. Two candidate contracts:
| Option | Engine contract | FE change |
|---|---|---|
A. Engine emits isLowPrice: boolean (and isHighPrice: boolean) per slot | Confirms whether adjusted price is below/above base price | :class="{ 'low-price-font': availableTime.isLowPrice }" — remove the availability check |
B. Engine returns both basePrice and adjustedPrice per slot | FE computes direction locally | :class="{ 'low-price-font': availableTime.price < availableTime.basePrice }" |
Either is acceptable. Decide based on the final API response shape from backend. Default to Option A — it requires less FE state and the engine already has the comparison context handy.
5.3 Acceptance criteria for the fix
- An empty + unpriced slot renders with default text color (no green).
- A reduced slot renders green.
- A surcharged slot renders red (using the existing but-unused
.high-demand-fontrule defined at line 84). - All three states are verified via a small unit test or manual QA on a staging restaurant with multiple rules configured.
6. Form-to-engine payload contract
Canonical contract (pending BE + FE sign-off). This section documents the agreed wire format for Demand & Capacity rules. The shape mirrors the existing Specific Date & Time nested-attributes pattern. book-bite must map its internal UI values to this contract at the API boundary before POST/PATCH to pricing-engine.
6.1 Canonical enum values (FE posts these in the payload)
| Concept | Canonical wire format | Source |
|---|---|---|
rule_type | "demand_capacity" | book-bite/src/config/pricingRules.config.js exports RULE_TYPE_DEMAND_CAPACITY |
condition_type | "total_seats_booked" (the only one in v1) | kb PRD §1; team decision |
relation_type | One of: "gt", "gte", "eq", "lt", "lte", "between" | kb PRD; different from book-bite’s current "greater_than_or_equal" etc. — see §6.2 |
action_type | "increase" / "decrease" | kb PRD |
value_type | "percentage" / "fixed" | kb PRD; matches book-bite |
value | integer, basis points for %, cents for fixed (matches pricing-engine convention) | kb PRD; different unit from book-bite’s current raw-percent / raw-currency values — see §6.2 |
6.2 Mapping from book-bite internal form values to canonical wire values
book-bite keeps its existing UI-friendly names internally; the mapping below is applied only at the API boundary when building the pricingRule payload.
| Field | book-bite internal value | Canonical wire value | Mapping |
|---|---|---|---|
condition_type (v1 only) | total_seats_booked | total_seats_booked | Same |
relation_type: gt | greater_than | gt | Rename |
relation_type: gte | greater_than_or_equal | gte | Rename |
relation_type: eq | equal_to | eq | Rename |
relation_type: lt | less_than | lt | Rename |
relation_type: lte | less_than_or_equal | lte | Rename |
relation_type: between | between | between | Same |
value (percentage) | raw percent as 5 for 5% | basis points as 500 for 5% | Multiply by 100 |
value (fixed) | raw major-unit amount as 10000 for IDR 10,000 | cents as 1000000 | Multiply by 100 |
Out of scope in v1:
pct_seats_booked,pct_seats_remaining,remaining_seatscondition types are hidden in the UI and do not need mapping until v2.
6.3 Payload envelope (for POST/PATCH
/api/partner/v1/pricing_rules)
{
"pricing_rule": {
"name": "Friday 7pm surge — total seats >= 20",
"restaurant_id": 12345,
"rule_type": "demand_capacity",
"active": true,
"requires_approval": false,
"priority": 1,
"apply_to_all_packages": false,
// Date/time window that gates rule eligibility (no action/value here):
"date_time_conditions_attributes": [
{
"date_type": "specific_time",
"start_time": "19:00",
"end_time": "21:00",
"days_of_week": [5], // Friday
"is_anytime": false,
"is_no_end_time": false,
"condition_items_attributes": [
{ "start_time": "19:00", "end_time": "21:00" }
]
}
],
// NEW block for demand-capacity (v1):
"demand_capacity_conditions_attributes": [
{
"condition_type": "total_seats_booked",
"action_type": "increase",
"value_type": "percentage",
"value": 500, // 5% uplift, basis points
"capacity_condition_items_attributes": [
{ "relation_type": "gte", "value_min": 20 }
]
}
]
}
}
Composition model:
date_time_conditions_attributesanddemand_capacity_conditions_attributesare parallel, top-level keys on the samepricing_rule. For ademand_capacityrule, the nesteddate_time_conditions_attributesentry carries only window metadata (date type, times, days of week); it does not carryaction_type,value_type, orvalue. The capacity condition indemand_capacity_conditions_attributesholds the action and adjustment value. See the product PRD for cross-rule semantics with standalone Date & Time rules.
6.4 Predicted response envelope (the part the FE renders)
This is exactly the existing DynamicPricingV2Schema in
hh-pegasus/src/types/Pack.ts — already accepted as the canonical
shape by getAvailableRulesByTime. No change required to FE rendering
when the engine starts emitting Demand & Capacity adjustments; the
adjusted price is already returned as price per slot.
6.5 Mapping implementation guidance
The smallest blast radius for v1 is to keep form state in book-bite’s existing verbose names internally and remap only at the API boundary.
Recommended location for the mapper: a new helper such as
book-bite/src/pages/pricing-rules/helpers/demandCapacityPayload.js that
accepts the demandCapacityBasedRules form state and returns an array of
demand_capacity_conditions_attributes ready for pricing-engine.
If the backend contract changes after this PRD is signed, update only the mapper and this table — the form component stays unchanged.
7. Work plan (FE only)
See companion execution plan: docs/FE-PRD-EXECUTION-PLAN.md.
The execution plan is structured as 5 sequenced tasks (T1-T5) with explicit go/no-go gates between each, so the work can be paused or rescoped after any phase.
8. Open questions for the team
- Endpoint confirmation: is the form POST/PATCH to
pricing-engine/app/controllers/api/partner/v1/pricing_rules_controller.rb, or does the engineer add ademand_capacity_rulescontroller? Default (matches kb PRD): nested attributes on the same endpoint. - Composes-with date/time: documented in the product PRD (parallel top-level keys; date/time window gates eligibility, capacity threshold gates firing). Confirm in writing before T3 if the backend contract differs.
- Diner-side
isLowPrice/isHighPriceflag: does the engine emit this, or does FE compute? Default (proposed in §5.2): FE computes, but we may flip to engine-emits if backend prefers. - Figma card reviews: Figma has a Demand & Capacity card design
(
151:8161) but no detailed form design. Designer should review and contribute the form layout before T3, even as a fast follow-up.
9. Success criteria
This PRD is “done” when:
- A partner can create a Demand & Capacity rule from
book-bitethat:- saves via the partner API
- shows in the rule list with a correctly-formatted card
- takes effect for matching time slots in hh-pegasus
- The diner sees the adjusted price in the time picker with correct high/low price styling (per §5.2 fix).
- No regression on Specific Date & Time or AI-Driven rules.
10. Related reading
- Companion product PRD:
Dynamic_Pricing_Capacity_Demand_20260701150000.md - Backend engineering spec:
PricingEngine/013-DEMAND-CAPACITY-CONDITIONS.md - FE / Figma alignment audit:
docs/FE-FIGMA-ALIGNMENT.md - Monorepo learning cheatsheet:
docs/LEARNING.md