# Shop · Services catalogue — Parity Matrix

Canonical = React demo `portals/shop/Services.tsx`. Ours = Yii2 `frontend/.../shop-service/*`.

| # | Demo behavior | Demo ref | Our ref | Status | Note |
|---|---|---|---|---|---|
| 1 | Unified 5-tab catalogue (services/routines/add-ons/bundles/packages) | Services.tsx:124-134 | ShopServiceController.php:76; index.php:188 | partial | Only services tab; packages on a separate page |
| 2 | Services list, shop-scoped | Services.tsx:80 | index.php:188-262; ShopServiceController.php:82 | done | Scoped via search + shop_id stamp |
| 3 | Routines (freebie kind) tab + CRUD | Services.tsx:81,479,529 | — | missing | No freebie concept |
| 4 | Free add-ons tab + CRUD | Services.tsx:82 | — | missing | No freebie concept |
| 5 | Bundles tab + CRUD | Services.tsx:83,364 | — | missing | No bundle model/surface |
| 6 | Packages tab on same surface | Services.tsx:84,425 | frontend/controllers/PackageController.php | partial | Exists but separate route/page |
| 7 | Tab labels with live counts | Services.tsx:128-132 | — | missing | No tabs |
| 8 | showInactive toggle | Services.tsx:135-138; selectors.ts:112 | — | missing | No inactive concept in UI |
| 9 | Card: thumbnail | Services.tsx:312 | index.php:203-209 | done | Gradient/glyph fallback |
| 10 | Card: category badge | Services.tsx:316 | index.php:215-217 | partial | Shows linked Service name as pill |
| 11 | Card: Inactive / Hidden status badges | Services.tsx:263-276 | — | missing | No status/hidden |
| 12 | Card: duration + "+N" freebie minutes | Services.tsx:334-338 | index.php:249-253 | partial | Duration only, no roll-up |
| 13 | Card: struck base price + final price | Services.tsx:339-344 | index.php:246-248 | partial | Final price chip only, no strike |
| 14 | Card: per-item VAT line (VAT shops) | Services.tsx:346-350 | — | missing | Not on card (form only) |
| 15 | Card: specialist names | Services.tsx:351-356 | index.php:255-258 | partial | Shows agent count, not names |
| 16 | Lifecycle: visibility toggle (no confirm) | Services.tsx:197-200,226-233 | — | missing | — |
| 17 | Lifecycle: active toggle (confirm on deactivate) | Services.tsx:201-213 | — | missing | — |
| 18 | Lifecycle: edit action | Services.tsx:242-249 | index.php:220-228 | done | Goes to full-page form |
| 19 | Lifecycle: delete (confirm) | Services.tsx:214-222 | index.php:229-241; ShopServiceController.php:379 | done | Soft-archive + package-link guard (stronger) |
| 20 | Discount: none/fixed/percent, clamp/floor | finance.ts applyServiceDiscount | ShopServiceController.php:200-204; ShopService.php:16-17 | done | Math matches; no server clamp |
| 21 | VAT-inclusive base/vat split | finance.ts vatBreakdown | ShopService.php:56-67; ShopServiceController.php:208-220 | done | Equivalent formula |
| 22 | Live "saves X (Y%)" + "zeroes price" warning | Services.tsx:758-760,984-991 | — | missing | No feedback |
| 23 | 3-card pricing layout (before/discount/after) | Services.tsx:898-997 | _form.php:226-337 | done | 3-panel via AJAX calc |
| 24 | Pricing computed client-side (pure fn) | finance.ts | ShopServiceController.php:183 (AJAX) | partial | Ours is a server round-trip |
| 25 | Duration dropdown 15–240 step 15 | Services.tsx:67 | _form.php:344 | done | + slot-multiple validation (stronger) |
| 26 | Service category optgroup (group→category) | Services.tsx:1020-1040 | _form.php (category dropdown) | partial | Different model (linked Service / assignment) |
| 27 | Bilingual name | Services.tsx:876-883 | base/ShopService.php:270-280 | done | MultiLanguageBehavior |
| 28 | Bilingual description | Services.tsx:884-892 | — | missing | No description field in form |
| 29 | Image picker w/ cropper + in-app preview | Services.tsx:1210-1280 | _form.php:142-167 | partial | filekit upload, server fit; no crop/preview |
| 30 | Image gallery | — | _form.php:170-187 | done | Extra in ours (gallery manager) |
| 31 | Variant picker (images + bilingual names) | Services.tsx:1286-1355 | — | missing | — |
| 32 | Attached routines-before/after + free add-ons | Services.tsx:1090-1124 | — | missing | — |
| 33 | Agents read-only (managed in Team) | Services.tsx:1128-1147 | _form.php:372 | partial | Ours edits agents here (divergent) |
| 34 | Save gate w/ "missing fields" banner | Services.tsx:763-767,1149-1153 | base/ShopService.php:84-107 | partial | Yii required rules; no missing-list banner |
| 35 | Auto-renew + billing period (packages) | Services.tsx:1067-1086 | (Package feature) | partial | Separate Package surface |
| 36 | Toasts on add/edit/delete/toggle | Services.tsx:582,822 etc | session flashes | partial | Flash messages, not inline toasts |
| 37 | Service Categories cover customiser | — | index.php:67-186 | extra | No demo equivalent |
| 38 | Print/PDF + detail view | — | view.php, _pdf.php | extra | No demo equivalent |

## New in dev demo (absent on our side)
- Routines (freebie `routine`) and Free add-ons (`addon`) as first-class catalogue items.
- Service **bundles** (member services + bundled price + attached free add-ons).
- Packages **unified onto the same catalogue surface** (we have them only as a separate page).
- **Active/Hidden lifecycle** (toggle active, toggle in-app visibility) distinct from archive.
- `showInactive` filtering of the shop list.
- Service **image variants** (named, bilingual, customer-selectable).
- Attached **routines-before / routines-after / free add-ons** with duration roll-up ("+N min").
- Live discount feedback (savings %, "discount zeroes the price" warning).
- Bilingual **description** field.
- Specialist names rendered on the card; agents managed read-only (Team-side).
- Modal-based, single-surface CRUD (vs our full-page forms + separate pages).

## Our extras (not in demo)
- Service-category cover-image customiser (`ServiceCategoryAssignment`).
- Image gallery manager (multi-image samples).
- Slot-size duration validation, package-link delete guard, platform-commission price check,
  PDF/detail views — all reasonable backend hardening.

---

**Area score: 38%**

Rationale: core single-service pricing/VAT/discount, duration, bilingual name, edit/delete, and
shop-scoping are present and in places stronger than the demo. But the demo's defining shape for
this area — a unified 5-type catalogue (services + routines + add-ons + bundles + packages) with
active/hidden lifecycle, image variants, attached freebies, and modal CRUD — is largely missing:
four of five item types are absent or off-surface, and the lifecycle/variant/freebie/feedback
layers do not exist.

---

## Verified verdict (adversarial)

Re-checked every "done" / high claim against both the demo and our code. Two of the
analyst's "done" rows do not survive an edge-case read and are downgraded; the rest hold.

### Downgraded overclaims

| # | Feature | Analyst | Verified | Evidence |
|---|---------|---------|----------|----------|
| 20 | Discount none/fixed/percent math | done | **partial** | Demo `lib/finance.ts:43-52 applyServiceDiscount` clamps percent to 0–100 and floors fixed at 0 (`Math.max(0, base - value)`). Our `ShopServiceController.php:200-204` does neither: percentage `($total*$discountValue)/100` and fixed are applied raw. `discount`/`discount_type` are `safe` rules only (`base/ShopService.php:103`) with no range validation and no `beforeSave` clamp. A >100% percent or a fixed > price yields a negative price both in the AJAX preview and on save. Math matches the demo only inside the valid range; edge-case behavior diverges. |
| 25 | Duration dropdown 15–240 step 15 | done | **partial** | Demo `Services.tsx:67` is a fixed list `15…240 step 15` regardless of shop. Ours (`Shop.php:735-749 getValidServicePeriods`) builds `slotSize * i` (i=1..16, capped at 4h). It equals the demo's list ONLY when `slot_time_step == 15`; for a 30-min slot shop the options are 30,60,…240 (step 30, no 15/45/etc.). Behaviorally a different option set, not a step-15 dropdown. Stronger for slot-integrity, but not the same control. |

### Confirmed (claims that hold)

- **#2 Services list, shop-scoped — done.** `ShopServiceSearch::search` hard-filters `['shop_id' => current shop]` (`ShopServiceSearch.php:46`). Note the qualifier already in the analyst's data is real: `findModel` (`ShopServiceController.php:425-432`) is NOT shop-scoped — it leans on `checkOwnership` (`FrontEndController.php:120-143`) in view/update/delete, and `ShopService::find()` restricts to `STATUS_ACTIVE` (`ShopService.php:77-84`). List scoping itself is correct.
- **#19 Delete (confirm) — done.** Soft-archive (`ShopService.php:114-133 softDelete`) + package-link guard (`ShopServiceController.php:385-414`) + JS confirm in the card action (`index.php:236-239`). Stronger than the demo's plain confirm.
- **#21 VAT-inclusive base/vat split — done.** `ShopService::calculateVat` (`ShopService.php:56-67`) `vat = net*rate/(1+rate)` is algebraically identical to the demo's `vatBreakdown` `base = inclusive/(1+pct)` (`finance.ts:67-71`). Equivalent.
- **#18 Edit action — done.** `index.php:220-228` links to the full-page form (vs demo modal). Behaviorally equivalent entry point.
- **#27 Bilingual name — done.** `MultiLanguageBehavior` on `name` (`base/ShopService.php:270-280`).

### Other verified rows (no change to analyst status)
- **#13 card price — partial confirmed.** `index.php:246-248` renders `service_amount` raw: no struck base price, no discount applied to the displayed figure, no per-item VAT line. Demo `Services.tsx:339-350` shows struck `basePrice`, discounted `price`, and a VAT line for VAT shops. Matches analyst "partial".
- **#15 specialist names — partial confirmed.** `index.php:255-258` shows an agent count only; demo joins specialist names (`Services.tsx:351-356`). Matches.
- **#24 pricing client-side — partial confirmed.** Ours is a server AJAX round-trip (`actionCalculateVat`), demo is a pure client fn. Matches.
- Rows 1,3,4,5,6,8,16,17,22,28,31,32 (missing/partial multi-type catalogue, lifecycle, variants, freebies, feedback, description) — all confirmed absent on our side.

### Adjusted area score

Two "done" rows fall to "partial" (discount edge-case clamp; duration control shape).
Both are quality regressions on otherwise-present features, not whole missing surfaces, so
the headline barely moves. The dominant gap is unchanged: 4 of 5 catalogue item types and the
lifecycle/variant/freebie/feedback layers are missing.

**Adjusted area score: 36%** (was 38%).
