# Shop · Customers directory + freeze — Parity matrix

Demo (canonical): `portals/shop/Customers.tsx` + `store/store.ts` + `lib/finance.ts` + `selectors.ts` + `types.ts`.
Ours: `frontend/controllers/CustomersController.php`, `frontend/views/customers/index.php`, `common/models/CustomerFreeze.php`.

| Demo behavior | Demo ref | Our ref | Status | Note |
|---|---|---|---|---|
| Directory derived from shop's booking history | `Customers.tsx:26-29` | `CustomersController.php:57-69` | done | We add `LIMIT 300` + order by last visit |
| Shop-scoped to active shop | `Customers.tsx:22` | `CustomersController.php:45-48` | done | via `identity->shop_id` |
| Per-customer booking count | `Customers.tsx:121-122` | `CustomersController.php:60` | done | `COUNT(*)` |
| Customer name + avatar | `Customers.tsx:104-109` | `index.php:64-68` | done | initials avatar vs `Avatar` |
| Mobile column (mono) | `Customers.tsx:112-114` | `index.php:70` | done | `dir=ltr` added |
| Gender column | `Customers.tsx:116` | `CustomersController.php:93` (computed, not rendered) | missing | value computed but no UI column |
| Classification badge in directory | `Customers.tsx:124-136` | — | missing | no classification concept |
| Segmented Customers/Classifications/Freeze tabs | `Customers.tsx:40-52` | — | missing | ours stacks 2 sections, no switcher |
| Classifications table (read-only, reason, override badge) | `Customers.tsx:145-188` | — | missing | entire tab absent |
| First-booking classification engine (priority order) | `lib/finance.ts:304-313`, `store.ts:1264-1294` | — | missing | no model/table/logic |
| Two classifications + `shop_owned` default | `selectors.ts:205-210` | — | missing | — |
| Marketing fee charged only to `navagoo_sourced` | `Customers.tsx:66-70`, finance | — | missing | freeze list stored but no fee engine consumes it here |
| Admin override of classification (audit stamp) | `store.ts:1719-1758` | — | missing | — |
| Grace-window detection (`now <= graceWindowEndsAt`) | `lib/finance.ts:293-294` | — | missing | no `graceWindowEndsAt` on Shop |
| Grace banner (in-grace vs ended copy) | `Customers.tsx:229-238` | — | missing | no banner |
| Booking-link card: deep link + QR | `Customers.tsx:38,57-85,306-338` | — | missing | not surfaced |
| Copy deep link (clipboard + toast) | `Customers.tsx:73-84` | — | missing | — |
| "Invite customer" (SMS/WhatsApp) | `Customers.tsx:86-92` | — | missing | separate invitations feature not linked here |
| Freeze list read, shop-scoped | `Customers.tsx:194` | `CustomersController.php:100`, `index.php:113-130` | done | — |
| Freeze list columns: Name/Mobile/Added/remove | `Customers.tsx:199-225` | `index.php:104-127` | done | — |
| Add to freeze: modal, mobile required, name optional | `Customers.tsx:264-301` | `index.php:134-160`, `CustomersController.php:109-132` | done | — |
| Inline "Freeze" button on a directory row | — | `index.php:77-80` | done | NEW on our side |
| Freeze membership flag on directory rows | — | `CustomerFreeze.php:75-82`, `CustomersController.php:79,96` | done | NEW on our side |
| Mobile normalization / dedupe on add | not in demo store | `CustomerFreeze.php:69-72`, `:49-51` | done | ours stronger than canonical |
| Remove from freeze, shop-scoped | `Customers.tsx:214-216` | `CustomersController.php:135-145`, `index.php:122-125` | done | — |
| Confirm dialog before remove | `Customers.tsx:213` | — | missing | immediate delete |
| Empty states (no customers / empty freeze) | `Customers.tsx:99,256-261` | `index.php:60-61,114-115` | done | inline rows vs `EmptyState` |
| Toast feedback on actions | `Customers.tsx:78-90,277` | — | partial | ours uses full reload + `alert()` |
| Auth + CSRF + POST-only mutations | (sim) | `CustomersController.php:31-41`, `index.php:170-173` | done | ours hardened |
| Bilingual (AR/EN) copy | EN only | `messages/{en,ar}/frontend.php` | done | ours better |
| Persistence to DB | in-memory | `m260622_140000_create_customer_freeze.php` | done | expected divergence |

## Tally
- done: 19
- partial: 2 (toast feedback; — counted as half)
- missing: 13 (gender col, classification badge, tabs switcher, classifications table, classification engine, two-class default, marketing-fee linkage, admin override, grace detection, grace banner, booking-link/QR, copy link, invite, remove-confirm)

The freeze-list CRUD half of the screen is at strong parity (and hardened). But the screen's other half — the **attribution / classification system** (the actual reason the freeze list exists), the **grace-window** behaviour, and the **booking-link/QR + invite** acquisition surface — is entirely absent on our side. These are the canonical purpose of the screen, so they weigh heavily.

**Area score: 48%**

## Verified verdict (adversarial)

Re-checked every "done"/"partial" row by opening both refs. Demo file lives at
`portals/shop/Customers.tsx` (analyst's path `Customers.tsx` resolves there).

**Confirmed (genuine match, no downgrade):**
- Directory derived from booking history — `Customers.tsx:26-29` ↔ `CustomersController.php:57-69`. Set-membership in demo; `GROUP BY customer_id` in ours. Match (our `LIMIT 300` + last-visit order is a noted, accepted divergence).
- Shop-scoped — `Customers.tsx:22` ↔ `CustomersController.php:45-48,63`. Both freeze actions also re-scope by `shop_id` (`:119,:139`). Match.
- Per-customer booking count — `Customers.tsx:121-122` ↔ `CustomersController.php:60`. `COUNT(*)` grouped, shop-scoped, no status filter on either side. Match.
- Freeze read / add (mobile required, name optional) / remove — all shop-scoped, idempotent add, unique(shop,mobile) at `CustomerFreeze.php:49-51`. Match.
- Mobile normalization + dedupe — `CustomerFreeze.php:69-72`. Stronger than demo (demo has none). Confirmed.
- Inline Freeze button + frozen flag on rows — `index.php:74-83`, `CustomerFreeze.php:75-82`. NEW, works. Confirmed.
- Auth + CSRF + POST-only — `CustomersController.php:35-41`, `index.php:170-173`. Confirmed.
- Bilingual AR/EN — `messages/en/frontend.php:114-139` + `messages/ar/frontend.php:120-140` fully paired. Confirmed.
- Persistence — migration present. Confirmed.

**Downgraded overclaims:**
- **Toast feedback: partial → missing.** `index.php:162-194` has no success toast at all — success path is `location.reload()` with no message (the controller's flash at `:154-158` only fires on the non-AJAX redirect branch, which the JS never takes since it always sends `X-Requested-With`). Errors use a blocking native `alert()` (`:177,:190`). No non-blocking toast equivalent to the demo's `toast.success/info` (`Customers.tsx:78,89,277`) exists. Demote to missing.
- **Empty states: done → confirmed-with-caveat (kept done).** Freeze-empty in demo uses `EmptyState` with a Snowflake icon (`Customers.tsx:256-261`); ours is a plain text row, no icon (`index.php:115`). Behaviorally equivalent (message shown), so retained as done — cosmetic only.

**Re-affirmed missing (analyst correct):** gender column (computed `CustomersController.php:93`, never rendered — view has no gender `<th>`/`<td>`), classification badge, segmented tabs, classifications table, first-booking classification engine (`finance.ts:304-313`), marketing-fee linkage, admin override, grace-window detection + banner (`finance.ts:293-294`; no `graceWindowEndsAt` on our Shop), booking-link card + QR + Copy, invite customer, confirm-before-remove (demo `Customers.tsx:214` uses `confirm()`; ours deletes immediately at `index.php:184`).

**Adjusted tally:** done 19, partial 0, missing 14 of 34 rows. The freeze-CRUD half is at real, hardened parity, but the screen's defining half — attribution/classification, grace window, and the booking-link/QR + invite acquisition surface — is entirely absent, plus toast is now missing.

**Adjusted area score: 46%** (was 48%; toast partial→missing).
