# Customer · Discover — Parity Matrix

**Scope:** Discover is a **mobile customer-app** screen. In this repo the customer
client is the **mobile app**, which consumes this Yii app as an **API**. Parity is
assessed against `api/controllers/ShopsController.php` + `AdsController.php` (the data
contract). The actual screen UI lives in the mobile-app repo (out of scope) — rows
about pure UI are marked accordingly.

| Demo behavior | Demo ref | Our ref (file:line) | Status | Note |
|---|---|---|---|---|
| List only `active` shops | Discover.tsx:25 | ShopsController.php:59,148 | done | Exact match |
| Scope listing to one data world (demo/live) | implicit | ShopsController.php:60-64,149-153 | done | Ours stronger (`is_demo` partition) |
| Tabbed Discover / Deals screen | Discover.tsx:23,50-63 | — | missing | UI = mobile-app scope, not this repo |
| Decorative search box | Discover.tsx:42-48 | ShopsController.php:92-95 (`name` filter) | done | API supports real search; demo box is inert |
| "N open" active count | Discover.tsx:72 | ActiveDataProvider total (ShopsController.php:109) | partial | Total count available via pagination meta; no dedicated field |
| Shop rating + reviews on card | Discover.tsx:9-13,148 | ShopsResource.php:23-30 | done | Demo mock; ours real `rate`+`total_rates` |
| Distance "near you" (km) | Discover.tsx:10,154 | Shop.php:91-149; ShopsResource.php:117 | done | Real Haversine (needs lat/long); demo mock |
| Order by distance | implicit | Shop.php:118-119 | done | Ours orders by computed distance |
| Service count per shop | Discover.tsx:120,157 | — (ShopServiceResource has no aggregate) | partial | Not pre-aggregated on shop list item |
| "from {minPrice} SAR" | Discover.tsx:121-123,160 | ShopServiceResource.php:14-25 | partial | Per-service price exists; no min-price aggregate on list |
| Min price uses discounted price | seed.ts:884-896 | ShopServiceResource.php:19-25 | done | `price` + `price_before_discount` |
| Shop type label Women/Men/Unisex | Discover.tsx:15-19,148 | Shop.php:239 (`gender` raw) | partial | Raw gender exists; no display-label field |
| Inclusive gender filter / personalisation | (not on screen) | Shop.php:239-272 | done | Ours ahead (screen has none) |
| Logo hue / generated avatar tint | Discover.tsx:129-139 | ShopsResource.php:88 (`image`) | done | N/A server-side; real image served |
| Book now (stub toast) | Discover.tsx:79 | BookingController.php (separate) | done | Both: not initiated from Discover |
| Deals feed (discount offers) | Discover.tsx:88-110; seed.ts:1136-1167 | AdsController.php:27 (banners only) | partial | Ads = image banners; no structured deal feed |
| Deal: type/value/cap/count/expiry | seed.ts:1136-1167; types.ts | AdsResource.php:8-19 | missing | Ads expose only id/shop/image |
| Platform-wide deal (no shopId) | Discover.tsx:91-104 | AdsController.php:32-34 (shop-scoped) | missing | No platform-wide promo concept |
| Promo banners gated to active shops | implicit | AdsController.php:32-35 | done | Ours encodes the gate explicitly |
| Category filter | (not on screen) | ShopsController.php:66-74 | done | API ahead |
| Popular / top-rated flags | (not on screen) | ShopsController.php:75-79 | done | API ahead |
| City filter | (not on screen) | ShopsController.php:96-99 | done | API ahead |
| Pagination | none | ShopsController.php:18-20,109-116 | done | API ahead |
| Bilingual (ar/en) | English-only on screen | AdsController.php:19-25 | done | API ahead |

### New in dev demo (absent on our side)
- **Structured "Deals for you" feed** with discount type/value/usage cap/usage
  count/expiry, including **platform-wide (shopId-null) deals** — we only have
  image-only Ads banners + booking-time promo codes.
- A single **shop-list summary** of `service count` + `from {minPrice}` on the list
  item (demo computes client-side; our list payload doesn't aggregate it).
- A ready-made **Women/Men/Unisex display label** field.

(All UI/visual aspects — tabs, brand header, card layout, RTL of this screen — are
mobile-app-repo scope and not expected to exist in this Yii repo.)

**Area score: 78%**

Reasoning: The core discovery contract (active-only listing, ratings, distance,
search, gender/category/city filters, pagination, i18n) is fully present and in
several respects exceeds the demo. The gaps are (a) no structured/platform-wide
**deals** feed — only image Ads (the demo's entire second tab), and (b) missing
list-level **service-count / min-price** aggregation and a type display label. UI
rendering is correctly out of scope (mobile app).

---

## Verified verdict (adversarial)

Re-read both sides for every `done` / high claim (`api/controllers/ShopsController.php`,
`api/controllers/AdsController.php`, `api/resources/{Shops,ShopService,Ads}Resource.php`,
`common/models/Shop.php` + `base/Shop.php`, `api/controllers/MyActiveController.php`,
`common/helpers/LangHelper.php`; demo `portals/customer/Discover.tsx`, `store/seed.ts`,
`types.ts`). Most `done` rows survive. Findings below.

### Confirmed (behavior truly matches / exceeds demo)
- **List only active shops** — `ShopsController.php:59,148` both `andWhere(['status'=>STATUS_ACTIVE])`; demo `Discover.tsx:25`. Exact.
- **Data-world scope (demo/live)** — `ShopsController.php:60-64,149-153`. Real `is_demo` partition keyed off user; demo has no equivalent.
- **Search by name** — `ShopsController.php:92-95` real `LIKE title`; demo box inert. (Caveat: matches shop `title` only, not services — demo placeholder says "salons, services…" but is non-functional, so no behavior to fail against.)
- **Rating + reviews** — `ShopsResource.php:24-30` returns real `rate_average` (`rate` col) + `total_rates` (review count); demo `RATINGS` is hardcoded mock.
- **Min price uses discounted price** — `ShopServiceResource.php:22-24` `price = service_amount` (current/discounted), `price_before_discount = service_amount_before`. Matches demo `s.price` semantics at the per-service level.
- **Inclusive gender filter / personalisation** — `Shop.php:239-272`. Real, ahead of demo.
- **Category / popular / city / pagination** — `ShopsController.php:66-99,18-20,109-116`. Ahead of demo.
- **Book now** — both are stubs (demo toast `Discover.tsx:79`; ours routed through separate `BookingController`). Match.
- **Bilingual (ar/en)** — STRONGER than the analyst's ref. The claim is not limited to `AdsController.php:19-25`; `ShopsController extends MyActiveController`, whose `beforeAction` (`MyActiveController.php:51-57`) calls `LangHelper::applyFromRequest()` with allow-list `['en','ar']` (`LangHelper.php:32,51-56`). The primary shop listing IS language-aware (city/district/category `->name`), not just the Ads endpoint.

### Overclaims / downgrades & caveats

| Feature | Was | Now | Evidence |
|---|---|---|---|
| Promo banners gated to active shops | done | **done (with scoping leak)** | Gate itself is real — `AdsController.php:32-35` `innerJoinWith('shop')->andWhere(['{{%shop}}.status'=>STATUS_ACTIVE])`. BUT, unlike the shop listing, `AdsController::actionIndex` applies **no `is_demo` partition**. The "data world" strength claimed for shops (rows 1-2) does **not** extend to the Deals/Ads feed: a demo-mode user's Discover second-tab data would surface live-shop banners. Inconsistent scoping. |
| Distance "near you" + order by distance | done | **done (conditional)** | Real Haversine `Shop.php:114-119`, but `distance` is only SELECTed/ordered when `applyLocationFilter` succeeds (`ShopsController.php:81`; valid lat/long, `Shop.php:128-148`). With no/invalid coords the `distance` field is emitted as the default empty string (`base/Shop.php:116` `public $distance = ''`) and ordering stays `id DESC`. Demo always renders a (mock) distance. Honest "needs lat/long" caveat already present. |
| Order by distance overrides rate sort | — | **note** | When `top_rated`/`popular` is combined with location, `applyDistanceFilter`'s `orderBy('distance')` (`Shop.php:119`) replaces the earlier `addOrderBy(['rate'=>SORT_DESC])` (`ShopsController.php:76`). Edge-case ordering quirk; not a demo-parity item. |

### Deals-feed rows (no change — confirmed against demo)
- **Deals feed (structured)** — `partial`: confirmed. `AdsResource.php:8-20` exposes only `id/shop/image`; demo `Deal` (`types.ts:497-507`, `seed.ts:1136-1167`) is a structured offer. Image banners ≠ deal feed.
- **Deal attributes (type/value/cap/count/expiry)** — `missing`: confirmed. None of `discountType/discountValue/usageCap/usageCount/expiry` exist on `Ads`/`AdsResource`; the `common\models\Ads` model is an empty stub (`Ads.php:10-21`).
- **Platform-wide deal (no shopId)** — `missing`: confirmed. Demo `Discover.tsx:91-104` + `seed.ts` `DEAL-3 shopId: undefined`; ours `innerJoinWith('shop')` makes a shop mandatory — a null-shop promo cannot exist.
- **Service count per shop** / **`from {minPrice}` aggregate** — `partial`: confirmed, no list-level aggregate.
- **Shop type label (Women/Men/Unisex)** — `partial`: confirmed. `Shop.php` exposes raw `gender` int; no ready display-label field matching demo `TYPE_LABEL` (`Discover.tsx:15-19`).

### Net
No row flips `done → missing`. One material defect surfaced (Ads/Deals feed lacks the
`is_demo` partition that the shop listing has, undercutting the "data world scope"
strength for the second tab) plus the conditional-distance edge case. These are minor
relative to the (already-counted) structural deals gap. Adjusting the analyst's 78 down
slightly for the scoping leak.

**Adjusted area score: 75%**
