# Special Package (Manage · New Project Suite)

**Context:** Manage + a public board · **Nav:** New Project Suite → *Special Package*
(`/manage/inventory`, which lands on the one project's page at `/manage/inventory/{project}/special`)
· **Public:** `/offer/{project-slug}` (no login — the room's screen and the link customers get) ·
**Permissions:** `view-inventory` (see the offer and the claimed slots), `manage-inventory` (start /
stop / reset / edit it, record a payment).

## What it does

A limited-slot flash promotion for a project — "RM 299 Special Package, 10 slots, limited for today
only" — run in a **ten-minute countdown**. Agents open the page on their phones or a gallery screen;
a manager starts the countdown; as customers pay, the manager records each payment and the next open
slot flips to CLAIMED on every open screen within five seconds, with a confetti burst on the slot.
Slots fill **only** when a real payment is recorded. The feature was ported 2026-09-26 from the
ViiA booking system the owner supplied (a Next.js app, `viia-master.zip`); only its Special Package
page was wanted.

The project it belongs to (ViiA Residences @ KL Eco City first) is an `inventory_projects` row
keyed to the catalogue by `catalog_project_uuid` — the catalogue rule: a site table points at the
catalogue, never overrides it.

## How it works

- **The offer** (`inventory_offers`, one per project — unique on `project_id` since 2026-09-30 —
  created with the default terms (`InventoryOffer::DEFAULT_*`) the first time a MANAGER opens it;
  the public board is a read and shows `InventoryOffer::blank()` until then): title, description,
  price, total slots, `ends_at`. `ends_at` null = not started;
  `> now` = running; `<= now` = ended. `InventoryOffer::state()` derives claimed / left / running /
  ended / sold_out.
- **Start** sets `ends_at = now + 10 min` (refused when sold out; restarting keeps the claims).
  **Stop** sets `ends_at = now`. **Reset** deletes every claim and the feed's payment lines and
  clears `ends_at`; the terms stay. **Edit** is refused while running, and slots can never be set
  below the number already claimed.
- **Claiming a slot** (`InventoryOfferRepository::claim`) is ONE click — "Payment received — claim
  slot N": the manager confirming money arrived; the customer's details are optional (nullable since
  migration `…000013`). Refused unless the countdown is running and a slot is left; otherwise the
  LOWEST free slot number is inserted under the unique
  `(offer_id, slot_no)` key. A collision means another manager recorded a payment at the same
  moment — it retries on the next number, three times, then says "That slot was just taken". Each
  claim appends a `PAYMENT_RECEIVED` row to `inventory_activities` — actor, amount, slot — and
  **never the customer**: the feed is shown to every agent.
- **Live layer:** the page polls `GET {base}/pulse` every 5 s (paused while the tab is hidden,
  `useInventoryPulse`). A feed row that arrived after the tab opened becomes a toast and reloads
  the page's props, so the new claim lands in the slot grid with its burst. The countdown runs
  against the server's `now` (clock skew corrected), and when it reaches zero the page reloads once
  so "Offer ended" and the buttons agree with the server.
- **The public board** (`Main\Inventory\OfferController`, `Pages/Main/Inventory/Offer.vue`):
  the masthead, the hero and the slot grid, nothing else — `InventoryOfferRepository::board()`
  returns the terms, the clock and the claimed slot NUMBERS, never a name or an agent. It polls
  `/offer/{slug}/pulse` every five seconds and bursts on a slot the moment it fills. `noindex`.
  The admin page shows the link with Copy / Open.
- **Who sees the customer:** the claiming agent and a manager see the name; everyone else sees it
  masked (`M** T** A** K**`, `OfferController::mask`).
- **Rehearsal mode** (manager, client-only): a 10-minute or 1-minute run on THAT screen with
  pretend payments filling the grid down to the last two slots, each with a toast tagged
  SIMULATED and a burst. Nothing is saved; a banner says so; Exit clears it. It is disabled while
  the real countdown runs, and the real Start is disabled during a rehearsal.

## Related files

**Backend**
- `src/Inventory/InventoryProject.php`, `InventoryOffer.php`, `InventoryOfferClaim.php`,
  `InventoryActivity.php` (KINDS — verb + emoji per feed row)
- `src/Inventory/Repositories/InventoryOfferRepository.php` (findForProject — a read, forProject — Manage only, start, stop, reset,
  update, claim), `InventoryActivityRepository.php` (log)
- `src/Inventory/Services/PulseBuilder.php` (the heartbeat payload), `UserName.php`
- `app/Http/Controllers/Manage/Inventory/OfferController.php` (show, pulse, start, stop, reset,
  update, claim), `ProjectsController.php` (front door),
  `app/Http/Controllers/Main/Inventory/OfferController.php` (the public board + its pulse), the shared
  `app/Http/Controllers/Concerns/PresentsInventory.php`
- `app/Http/Requests/Manage/Inventory/Offer/{UpdateRequest,ClaimRequest}.php`
- `src/Auth/Permission.php` — `VIEW_INVENTORY`, `MANAGE_INVENTORY` (group *Special Package*)

**Frontend**
- `resources/js/Pages/Manage/Inventory/Offer.vue` (the manager's page), `Projects.vue` (only when
  more than one project is active), `resources/js/Pages/Main/Inventory/Offer.vue` (the public board)
- `resources/js/Components/Inventory/OfferHero.vue` + `SlotGrid.vue` (shared by both pages),
  `InventoryLayout.vue` (masthead + one pulse poller), `ActivityToasts.vue`
- `resources/js/composables/inventory/{useInventoryPulse,formatters,http}.js`
- `resources/js/Layouts/ManageLayout.vue` — the *Special Package* entry in `projectNav`

**Migrations** — `database/migrations/2026_09_26_000001_create_inventory_projects_table.php`,
`…000008_create_inventory_activities_table.php`, `…000011_create_inventory_offers_table.php`,
`…000012_create_inventory_offer_claims_table.php`,
`…000013_make_inventory_offer_claim_customer_optional.php`,
`2026_09_30_100100_make_inventory_offers_project_unique.php` (the numbering gaps are the storey-plan tables
that were built and then removed the same day when the scope narrowed to this page; the
`inventory_projects` chart columns and the activity table's unit columns are left for that work)

**Seeders** — `database/seeds/InventoryViiaSeeder.php` (the ViiA project; run once per database;
`RolesSeeder` for the two permissions)

**Routes** — `routes/web.php` → `manage.inventory.*` (`index`, `pulse`, `offer.show`,
`offer.start`, `offer.stop`, `offer.reset`, `offer.claim`, `offer.update`); `routes/main.php` →
`main.inventory.offer` + `main.inventory.offer.pulse` (public)
