# Shop · Marketing — Parity table

Demo = canonical (`portals/shop/Marketing.tsx`, `portals/marketing/Deals.tsx`). Ours =
Yii2 promo-code CRUD. The demo's "Marketing" surface is **deals/promo codes only**; it has
**no** invitations feature (only a simulated toast).

| Demo behavior | Demo ref | Our ref | Status | Note |
|---|---|---|---|---|
| Marketing page with deals manager | `Marketing.tsx:5-13` | `PromoCodeController.php:34-48` + `views/promo-code/index.php` | done | Titled "Promo codes", not "Marketing" |
| Shop-scoped list | `Deals.tsx:21` | `PromoCodeSearch.php:45` | done | Ours server-enforced (stronger) |
| Discount type percent/fixed | `types.ts:495`, `Deals.tsx:30-37` | `base/PromoCode.php:39-40`, `index.php:33-44` | done | |
| Discount badge `{v}%` / `SAR {v}` | `Deals.tsx:30-37` | `index.php:33-44` | done | |
| Usage as progress bar (count/cap) | `Deals.tsx:38-46` | `index.php:159-166` | partial | We show text not a `<Bar>`; data present |
| Expiry column | `Deals.tsx:51` | `index.php:151-167` | done | |
| Delete deal (confirm + toast) | `Deals.tsx:55-66`, `store.ts:1850` | `PromoCodeController.php:207-214`, `index.php:179-190` | done | Ours POST-only + ownership guard |
| Create via modal | `Deals.tsx:101-200`, `store.ts:1848` | `PromoCodeController.php:72-124`, `_form.php` | done | Ours full-page (deliberate reskin choice) |
| Create fields: desc/type/value/cap/expiry | `Deals.tsx:147-196` | `_form.php:74-142` | done+ | Ours adds code, status, max_uses |
| Submit guard (desc && value) | `Deals.tsx:130` | `base/PromoCode.php:67` (required rules) | done+ | Ours real server validation |
| Edit existing deal | — (demo has no edit) | `PromoCodeController.php:132-199` | done+ | Navagoo-only capability |
| Empty state (Ticket icon + CTA copy) | `Deals.tsx:80-86` | `index.php:138` | partial | Ours = plain "No results found." cell |
| Platform-wide deal (`shopId` undefined) | `Deals.tsx:18`, seed `DEAL-3` | — | missing | Not modelled on shop side |
| Promo **code** string | — | `index.php:156`, `_form.php:78-85` | n/a (ours-only) | Demo deals have no code |
| VAT-inclusive fixed-amount math | — | `PromoCodeController.php:88-99` | n/a (ours-only) | Real Navagoo rule absent from demo |
| Active/Expired/Inactive status | — | `index.php:48-69`, `base/PromoCode.php:37-38` | n/a (ours-only) | Demo: live-until-expiry only |
| Filter (code + status) & pagination | — | `index.php:91-119,200-211` | n/a (ours-only) | Ours richer than demo |
| **Customer invitations campaigns** | — (only simulated toast `Customers.tsx:89`) | `CustomerInvitationsController.php`, `views/customer-invitations/index.php` | n/a (ours-only) | Whole Navagoo feature; no demo target |

## New in demo (absent / weaker on our side)
- **Usage progress bar** in the list (we render text). — partial
- **Illustrated empty state** with marketing CTA copy ("Create a promo code…"). — partial
- **Platform-wide deals** (`shopId` undefined). — missing on shop side.
- Modal-based create (we use full-page; intentional, not a true gap).

## New on our side (no demo equivalent)
- Promo **code** strings; **VAT** handling for fixed amounts; **active/inactive/expired**
  status; max_uses/remaining_uses tracking; code+status **filtering** & **pagination**;
  per-record **ownership** guard; **edit** flow; and the entire **Customer Invitations**
  campaign feature (batch ≤50, dedupe, WhatsApp/SMS, AR/EN templates, admin-approval gate).

## Caveats / risks worth flagging
- Promo `code` has **no per-shop uniqueness rule** (`base/PromoCode.php:67` only requires it).
  Two codes with the same string can coexist. Worth a `unique` validator scoped by shop_id.

Area score: 88%

(Core deals CRUD is fully covered and our side is generally a superset — VAT, status, code,
filtering, ownership, edit. Deductions: missing usage progress bar, weak empty state, and no
platform-wide-deal support. The invitations divergence is not scored — it's Navagoo-only.)

## Verified verdict (adversarial)

Re-opened both demo and our refs. Most "done" rows hold; two were over-stated and one
carries a latent data-integrity caveat. Corrected rows:

| Feature | Analyst | Verified | Evidence |
|---|---|---|---|
| Marketing page with deals/promo manager | done | **done** (with note) | Demo titles the page "Marketing" (`portals/shop/Marketing.tsx:9`); ours is "Promo codes List" (`views/promo-code/index.php:78`). Surface is functionally equivalent. |
| Shop-scoped list | done | **done (confirmed stronger)** | `PromoCodeSearch.php:45` hard-codes `->where(['shop_id' => user->shop->id])` before any filter. Server-enforced, not client filter. |
| Discount type percent/fixed + badge | done | **done (caveat)** | Badge logic correct (`index.php:37-44`, `TYPE_PERCENTAGE=1`). BUT `base/PromoCode.php:64` declares `discount_type` as `string` while the column/constants are ints (0/1), and `PromoCodeSearch.php:73` filters it with `['like','discount_type',…]`. Works via loose casting today; fragile. |
| Usage shown as progress bar | partial | **partial (confirmed)** | `index.php:159-166` renders text "uses / max_uses" + "Remaining"; no `<Bar>` equivalent of `Deals.tsx:48`. |
| Delete deal (confirm + toast) | done | **done (confirmed stronger)** | POST-only `VerbFilter` (`PromoCodeController.php:24`) + `checkOwnership` (`:210`) + JS confirm (`index.php:185-188`). No toast, but redirect+flash. |
| Create deal/promo (fields desc/type/value/cap/expiry) | done | **DOWNGRADE → partial** | Demo's headline "Deal" field is `description` free-text ("20% off first visit", `Deals.tsx:28,147`; `types.ts:500`). Our schema has **no description column at all** (verified: no `description` in `base/PromoCode.php`, migrations); the create form (`_form.php`) substitutes `code`/value/type/status/expiry/max_uses. The demo's primary field has **no equivalent** — partial, not done. |
| Edit existing deal | done | **done (Navagoo-only)** | `PromoCodeController.php:132-199`; demo has no edit. |
| Illustrated empty state with CTA | partial | **partial (confirmed)** | Ours = plain `colspan` cell "No results found." (`index.php:138`) vs demo `EmptyState` w/ Ticket icon + CTA copy (`Deals.tsx:84-89`). |
| Platform-wide deal (shopId undefined) | missing | **missing (confirmed)** | Demo seed `DEAL-3 shopId:undefined` (`seed.ts:1158-1167`), filter `!d.shopId` (`Deals.tsx:21`). Ours always sets `shop_id = user->shop->id` (`PromoCodeController.php:80`); no platform-wide path. |
| VAT-inclusive fixed-amount math | done | **done (Navagoo-only)** | `PromoCodeController.php:88-99` divides by `(1+VAT)`; absent from demo. |
| Active/Expired/Inactive lifecycle | done | **done (Navagoo-only)** | `index.php:48-69` derives expired from `expiry_date`; demo is live-until-expiry. |
| Per-record ownership guard | done | **done (confirmed stronger)** | `checkOwnership` on view/update/delete (`PromoCodeController.php:60,137,210`; `FrontEndController.php:120-143`). Demo has none. |
| Customer invitation campaigns | done | **n/a (Navagoo-only, not scored)** | Demo only fires a simulated toast (`Customers.tsx:89`); not a parity target. |

### Additional risks confirmed
- **No per-shop uniqueness on `code`** (`base/PromoCode.php:68` only `required`) — duplicate codes can coexist. (Already flagged; confirmed.)
- **`discount_type` type mismatch** (string rule vs int column, `like` filter) — see badge row. Latent.
- **No `description` field** — genuine demo field with zero parity; drove the create-fields downgrade.

### Adjusted area score: **83%**

Rationale: the analyst's 88 did not account for the demo's `description` field having no
counterpart on our side (the create-fields row should be *partial*, not *done*). With usage-bar
(partial), empty-state (partial), platform-wide (missing), create-fields (partial now), and the
two latent caveats, 83% is the defensible figure. Our superset advantages (VAT, status, code,
filtering, ownership, edit) remain real and keep the score high.
