# Shop · Marketing — Logic & Flows parity

Scope: the demo's "Marketing" surface for a shop = **deals / promo codes** only.
Demo: `portals/shop/Marketing.tsx` → `portals/marketing/Deals.tsx` (`DealsManager`).
Ours: `frontend/controllers/PromoCodeController.php` + `frontend/views/promo-code/*`,
model `common/models/PromoCode.php` (base `common/models/base/PromoCode.php`),
search `backend/models/search/PromoCodeSearch.php`.

> NOTE: Our `CustomerInvitationsController` (WhatsApp/SMS bulk invite campaigns) has **no
> counterpart** in the dev demo. The demo only fires a *simulated* toast
> (`portals/shop/Customers.tsx:89`, `portals/shop/Dashboard.tsx:278`). It is NOT a managed
> feature there. See parity.md / business.md for how we treat it.

## 1. Demo data shape (`types.ts:495-507`)
```ts
type DiscountType = 'percent' | 'fixed'
interface Deal {
  id; shopId?            // undefined = platform-wide deal
  description            // free-text label shown as the primary column
  discountType          // 'percent' | 'fixed'
  discountValue: number  // raw number; percent => %, fixed => "SAR N"
  usageCap: number       // ceiling
  usageCount: number     // consumed
  expiry: string         // ISO datetime
}
```
A deal is a single object that conflates "promo code" and "deal/percentage". There is **no
`code` string** in the demo Deal — it is description-led. There is no separate active/inactive
status: a deal is implicitly live until `expiry`.

## 2. Demo list logic (`Deals.tsx:19-95`)
- `rows = state.deals.filter(d => shopId ? d.shopId === shopId : !d.shopId)`
  → shop-scoped when `shopId` is passed (it is, from `Marketing.tsx:8`). This is the demo's
  shop-scoping rule (client-side filter).
- Columns: description, discount badge (`{value}%` or `SAR {value}`), **usage bar**
  (`usageCount/usageCap` rendered as a `<Bar value={count/cap*100}/>`, `Deals.tsx:38-46`,
  `components/ui/Donut.tsx:45`), expiry date (`lib/format.date`), delete button.
- Empty state: `EmptyState` icon=Ticket, "No deals yet" (`Deals.tsx:80-86`).

### Ours (`frontend/views/promo-code/index.php`)
- List comes from `PromoCodeSearch::search()` which **hard-scopes** to the logged-in shop:
  `$query->where(['shop_id' => Yii::$app->user->identity->shop->id])`
  (`backend/models/search/PromoCodeSearch.php:45`) — server-side, stronger than the demo's
  client filter. ✅ parity + better.
- Columns: **code** (we have a real code string; demo doesn't), discount type label, discount
  amount chip (`index.php:33-44`), usage as text `uses / max_uses` or "Unlimited"
  (`index.php:143-166`), expiry date, **status chip** (active / expired / not-active,
  `index.php:48-69`), edit + delete actions.
- We DO NOT render the demo's usage **progress bar** — usage is text only. Minor UI gap
  (see ui.md). The data exists (`uses`, `max_uses`, `remaining_uses`).
- We add a **filter row** (code + status GET filter, `index.php:91-119`) and **pagination**
  (`LinkPager`) — neither exists in the demo. ✅ ours is richer.

## 3. Create flow
### Demo (`Deals.tsx:97-200`, store `store.ts:1848`)
- `AddDealModal`: description, type (percent/fixed), value, usage cap, expiry date.
- Submit disabled unless `desc && value` (`Deals.tsx:130`).
- `addDeal` simply pushes `{...d, id: newDealId(), usageCount: 0}` — **no VAT math**, no
  server validation, cap defaults to 100, expiry defaults to `2026-12-31` if blank
  (`Deals.tsx:139-145`).
- Toast success, modal closes, fields reset.

### Ours (`PromoCodeController::actionCreate` 72-124, `_form.php`)
- Full-page Tailwind ActiveForm (NOT a modal). Fields: **code** (required), discount value
  (`actual_discount_value`), discount type, status, expiry date (kartik DatePicker),
  max_uses.
- `shop_id` is forced to the current user's shop (`PromoCodeController.php:80`) — cannot be
  spoofed. ✅
- **VAT logic (NEW vs demo):** for `TYPE_FIXED_AMOUNT`, the user-entered VAT-inclusive
  `actual_discount_value` is converted to a VAT-exclusive `discount_value` via
  `discount_value = actual / (1 + taxes/100)` using `Settings::findOne(1)->taxes`
  (`PromoCodeController.php:88-99`). Percentage → both equal. The demo has **no concept of
  VAT** — this is a real Navagoo business rule absent from the demo.
- On save: `remaining_uses = max_uses` is seeded (`:102-103`).
- Server-side validation via model `rules()` (code/discount_value/expiry_date required,
  `base/PromoCode.php:67`). Demo has none.

## 4. Update flow (`actionUpdate` 132-199)
- Mirror of create. Back-computes `actual_discount_value` from stored `discount_value` for
  legacy rows that predate the VAT split (`:140-152`). No demo analogue (demo just edits the
  object in store — actually the demo has **no edit at all**, only create + delete).

## 5. Delete flow
- Demo: `deleteDeal(id)` filters it out of the store + `confirm()` dialog + toast
  (`Deals.tsx:55-66`, `store.ts:1850`).
- Ours: `actionDelete` POST-only (VerbFilter), `checkOwnership()` then `deleteWithRelated()`
  (`PromoCodeController.php:207-214`); JS `data-confirm` on the trash link
  (`index.php:185-188`). ✅ parity, with ownership guard the demo lacks.

## 6. Usage counter
- Demo: `usageCount` is seeded static; nothing in the demo increments it (no checkout wiring
  in this slice).
- Ours: `uses` / `remaining_uses` are real columns; decrement/consumption happens elsewhere
  (booking redemption path — out of scope for this view). The list reads them
  (`index.php:144-150`).

## 7. Platform-wide deals
- Demo supports `shopId === undefined` → a **platform-wide** deal (seed `DEAL-3`,
  `seed.ts:1160`; `DealsManager` called with no shopId from the marketing/admin portal).
- Ours: `promo_code.shop_id` is **NOT nullable in practice** — the shop controller always sets
  it. Platform-wide promo codes are not modelled on the shop side (would belong to a backend
  admin surface). Gap, but arguably out of the *shop* portal's scope.
