# Aurora ⇄ Demo Porting Playbook

**Read this BEFORE porting any demo screen/feature into the portal.** It captures the
process, the demo location, how to run it, the reusable backend, the wave template, and
the gotchas — so the work doesn't have to be re-explained each time.

---

## 0. THE GOLDEN RULE (learned the hard way)

**Always SEE the rendered demo first. Never port from the React source alone** — the
source and the rendered result diverge, and "build from memory/code" repeatedly produced
mismatches. The reliable loop is:

1. **Run the demo** (below) and open the exact screen/modal in the preview browser.
2. **Screenshot it** (or have the user paste the rendered HTML — that was the most exact).
3. Read the matching React source for structure/classes/data.
4. **Build via a wave** (template below), giving agents the screenshot facts + exact classes.
5. **Verify in-browser** (curl + preview screenshot) against the demo, then commit.

When the user says "X is not like the demo", it's almost always because step 1–2 were
skipped. Do them.

---

## 1. WHERE THE DEMO IS + HOW TO RUN IT

Demo repo (sibling of this one): **`/Users/ahmedqotb/Documents/dockermachine/www/Navagoo_MI`**
(GitHub `mislambouli91/Navagoo_MI`, branch `dev` — private; not reachable via `gh`/web, so use the local checkout).

### React app (the source of truth — USE THIS)
```bash
cd /Users/ahmedqotb/Documents/dockermachine/www/Navagoo_MI/navagoo-app
npm install            # ~13s, 271 pkgs (only first time)
npm run dev            # Vite → http://localhost:5173  (run in background)
```
- **Hash routes:** `#/shop/dashboard`, `#/shop/bookings` (calendar), `#/admin/...`.
- In the preview browser: `preview_eval` → `window.location.href='http://localhost:5173/'`
  then `location.hash='#/shop/bookings'`, then click buttons to open modals, then
  `preview_screenshot`. The static mockups do NOT wire all modals — the React app does.

### Static mockups (quick look only; many modals are NOT interactive)
`Shop Portal 2.0 - Mockup.html` (13MB) + `Admin Portal 2.0 - Mockup.html` in the demo root.
Serve with `python3 -m http.server 8099 --bind 127.0.0.1` in the demo dir, open
`http://127.0.0.1:8099/Shop%20Portal%202.0%20-%20Mockup.html`. Prefer the React app.

### Key demo source files (shop portal)
- Calendar page + chrome: `src/portals/shop/Bookings.tsx`, `src/portals/shop/calendar/{DayCalendar,SpecialistColumn,TimeGutter}.tsx`, `src/lib/schedule.ts`.
- Booking detail: `src/portals/bookings/BookingDetailBody.tsx` + `BookingTimeline.tsx`.
- Modals (create/group/reschedule/cancel/collect): `src/portals/bookings/modals.tsx` (~1100 lines).
- Demo controls (flask): `src/components/layout/DemoDrawer.tsx`, `PortalSwitcher.tsx`.
- i18n: `src/i18n/en.ts` / `ar.ts`. Tokens/colors: `src/lib/status.ts`, `src/lib/theme.ts`, `src/lib/specialists.ts`.

---

## 2. PORTAL (target) ARCHITECTURE — what to build onto

- **Aurora layout:** `frontend/views/layouts/tailwind.php` → registers `frontend/assets/TailwindAsset.php`
  = built `frontend/web/css/tailwind.css` + `frontend/web/js/aurora-core.js`. See [[aurora-tailwind-build]].
- **Rebuild CSS after ANY class change** (incl. classes that only appear in JS):
  `npm run build:css` (repo root). The orchestrator does this centrally after a wave —
  do NOT make wave agents rebuild.
- **aurora-core.js client helpers** (use these; never native popups — [[aurora-dialog-helpers]]):
  - `ngToast(msg, 'success|error|info|warning')`, `ngConfirm`, `ngPrompt`.
  - `ngIframeModal(url, {title,width,tall})` — delegated via `[data-iframe-modal]`
    (+ `data-iframe-width="6xl"`, `data-iframe-tall="1"`; empty title → bar is just the X).
    The iframe gets `?iframe=1`. On `postMessage({source:'navagoo-iframe',status:'saved'})`
    from inside, it closes + toasts + refreshes the calendar (pjax/reload).
  - `ngDrawer` — right slide-over via `[data-drawer-open="id"]` / `[data-drawer-root="id"]`.
- **Two chrome-less iframe layouts:**
  - `layouts/iframe.php` → legacy Bootstrap/AdminLTE (BackendAsset) — for the OLD forms.
  - `layouts/iframe_aurora.php` → TailwindAsset + lucide — for NEW aurora content shown in a modal.
- **Booking-calendar route** (demo-faithful, NEW, separate from legacy `booking/`): see
  [[booking-calendar-v2-route]]. Controller `frontend/controllers/BookingCalendarController.php`,
  views `frontend/views/booking-calendar/*`, JS `frontend/web/js/booking-calendar.js`.
  Guest-previewable with `?real=1` (whitelisted in `frontend/config/web.php`).
- **Status palette (demo STATUS_COLOR), apply NEW-ROUTE-LOCALLY (never edit the shared
  BookingScheduleService/BookingCalendarPresenter):** scheduled `#6a3ab8`, in_progress
  `#f5b71b`, completed `#57b49b`, no_show `#4c5663`, cancelled `#dc5757`. Specialist column
  tints = SPECIALIST_PALETTE `#7C9CBF,#E0A458,#6FB58F,#C98BB9,#8E7CC3,#5FB0B7,#D98C8C,#B0A36F,#7FB0D9,#CDA06B`.

---

## 3. REUSABLE PORTAL BACKEND (don't rebuild — wire to these)

| Need | Endpoint / call | Params → returns |
|---|---|---|
| Day/Month data | `BookingScheduleService::dayLayout($shopId,$date,$zoom)` / `BookingCalendarPresenter::monthGrid()` | rich array (columns→blocks, shaded, time-offs, window/pxPerMin/hours/nowMin) |
| Free slots | GET `/booking/slots` | `agent_id,date,duration,services(csv)` → `{slots:[{value,from,to,label}],bookNow,nextAvailable}` |
| Create walk-in (creates customer too) | POST `/agents-bookings/create-walk-in` | `mobile,name,email,agent_id,booking_date,from_hour,services_ids[]` → `{status,booking}` |
| Transition status | POST `/booking/transition?id=` | `status` (int Booking::STATUS_*) → JSON |
| Collect payment | POST `/booking/collect?id=` | `method(cash|card),amount,tip` → JSON |
| Cancel / reschedule | `/booking/cancel`, `/booking/cancel-preview`, `/booking/reschedule` | (see BookingController) |
| Placement check | `BookingScheduleService::checkPlacement(...)` | → `{ok,reason,collidingId}` |
| Form data | customers/services/agents/capability queries are in `frontend/views/booking/_form.php` (lines ~43-100) — copy them |

Booking statuses: `STATUS_SCHEDULED=2, COMPLETED=3, INPROGRESS=4, CANCELED=5, ACCEPTED=6, NO_SHOW=9`.
Booking code = `formatted_id` (e.g. `NB-…`). Money helper in detail = `'SAR ' . asDecimal(v,2)`.

---

## 4. THE WAVE TEMPLATE (how to orchestrate a port)

Use the **Workflow tool** (ultracode is on). Pattern: **Foundation → Build → Verify**.

- **Foundation (1–2 agents):** the controller action + view-model data (reuse §3 backend) +
  i18n. Return the EXACT view-model keys so Build agents match them.
- **Build (parallel, DISJOINT files):** one agent per new file (view partials, JS). Give each
  the demo facts (classes/structure from the screenshot) + the fixed contract (data keys,
  data-* hooks, geometry constants). New files = no collisions; one owner per shared file.
- **Verify (parallel):** lint (`php -l` in `projects-webserver`, `node -c` for JS) + render
  (curl `?real=1` → 200, grep for key markup) + adversarial fidelity vs the demo.

Then the orchestrator (me) integrates: rebuild CSS, reconcile i18n (Arabic-guard), curl
smoke, **open the preview browser and screenshot vs the demo**, commit.

### Hard rules for every wave
- **Bilingual i18n is mandatory.** Every `Yii::t` key in BOTH `common/messages/{ar,en}/<cat>.php`.
  Reconcile with the Arabic-guard (ar value must contain U+0600–06FF, else fall back to en).
  Categories: common/backend/frontend/shop (see `common/config/base.php` fileMap).
- **Never edit shared code** that the legacy views depend on (`BookingScheduleService`,
  `BookingCalendarPresenter`) — do demo-fidelity overrides new-route-locally in the controller.
- **Don't** rebuild `tailwind.css` inside agents; **don't** run `yii migrate`; commit only the
  files for this task (a concurrent committer under `dev.ahmedqotb@gmail.com` also touches the tree).
- Lint command: `docker exec projects-webserver sh -c 'cd /var/www/html/Navagoo && php -l <file>'`.
  Guest-route curl: `curl -H 'Host: shop.navagoo.localhost' http://localhost/<route>` (keep the space).

---

## 5. VERIFY IN-BROWSER (the proof step)

- Preview server: `preview_start` "Navago shop- frontend" → `preview_eval` navigate to
  `http://shop.navagoo.localhost/<route>` (authed pages need login; `?real=1` works as guest
  on the booking-calendar routes). Then `preview_screenshot`.
- To compare with the demo, open BOTH (demo on :5173, portal on shop.navagoo.localhost) and
  screenshot each. Match header, layout (columns vs stacked), colors, classes.
- The docker container runs `YII_ENV=dev`. If a page is **blank white**, first check
  `.env` — a stray space (`YII_ENV= dev`) flips it to prod and hides errors. It must be `YII_ENV=dev`.

---

## 6. SKILLS

~1,588 skills from **antigravity-awesome-skills** are installed under `~/.claude/skills/`.
**See [`SKILLS_INDEX.md`](SKILLS_INDEX.md)** (same folder) for the curated project shortlist,
how to invoke (`/<skill-name>`), and how to update. Top picks for porting: `tailwind-design-system`,
`frontend-design`, `deterministic-design` (build/match screens), `code-reviewer`/`architect-review`
+ `accesslint-audit` (verify phase), `backend-dev-guidelines`/`php-pro` (controller/service).

---

## 7. PORT STATUS (update as you go)

**2026-07-27 — Bilingual auto-translate system ported portal-wide (demo translate.ts +
BilingualField):** new shared `frontend/web/js/aurora-translate.js` (MyMemory free API,
4s timeout → salon/beauty glossary fallback → manual; session cache; blur = gentle fill
that never clobbers manual text; per-side force-"translate" button; sparkles "auto"
badge + accent tint; amber couldn't-translate note; ar/en UI strings via document.dir).
Loaded by TailwindAsset (shop) + AuroraDialogAsset (admin, published copy — its classes
now scanned via tailwind.admin.config.js content globs). Wiring is automatic on every
page: explicit `data-translate-pair`/`data-translate-lang` groups AND auto-detected
legacy pairs (`X[attr_en]` ↔ `X[attr_ar]`/`X[attr]`, or id `<base>_en` ↔ `<base>`; opt
out with `data-no-autotranslate`; ambiguous duplicate names are skipped). Explicitly
wired: faq/support modals (FAQ q/a + policy title/body, w/ demo hint line),
push-notification composer (title/message), notification-trigger name + per-channel
templates. Auto-covered: shop-service, package, branch, settings (Shop title/address/
about/cancel_terms), page, sign-in/profile + all backend ML forms (category,
shop-category, city, faq, page, service, bank, government, skill…). Browser-verified on
/shop-service/create: EN→AR glossary hit, AR→EN live API hit, manual-override protection,
auto badge/tint, 4 translate buttons. NOT bilingual in the portal data model (demo is):
Team/agents name-title-bio, promo-code label, admin plans/offers names — making those
bilingual = schema+API changes, flag-first. MyMemory is called client-side exactly like
the demo (no key; external host) — swap point is `ngTranslate.translate` when a real
provider lands.

**2026-07-27 — Admin "Support & content" full demo parity (SupportContent.tsx v0.28):**
`/faq/support` rebuilt from read-only overview → fully interactive demo twin. FAQs tab:
For-customers/For-shops audience sub-tabs (faq.audience 0/1), single-open accordion,
per-row move ↑/↓ (per-audience sort swap), edit/delete, "Visible in help centre" toggle
+ Hidden badge, bilingual Add/Edit modal with the demo required-hint amber banner.
Policies tab: 4 seeded text policies (privacy/terms × customer/business) as cards + a
bilingual Title/Body edit modal — stored as CMS `page` rows slugged
`policy-<kind>-<audience>` (migration m260727_120000; AR in base cols, EN in
translations_with_text; distinct from the file-based `policy_document` table).
Contact tab: exactly the demo's 7 channels (Support email, WhatsApp, Instagram,
Facebook, X→`twitter` col, LinkedIn, TikTok) — dropped Phone/YouTube from the UI
(columns untouched). New JSON endpoints on FaqController: support-save-faq /
support-move-faq / support-toggle-faq / support-delete-faq / support-save-policy
(all POST + CSRF; raw-SQL reads/writes because MultiLanguageBehavior's attribute swap
is route-dependent). Legacy FAQ CRUD untouched. Mobile API untouched — open flag from
[[admin-parity-v28-sweep]] still stands: api/faq returns ALL rows; once shop-audience
FAQs exist the mobile endpoint should filter audience=customer (API change — needs
sign-off). Smoke: `/faq/support` added to tests/smoke/render-smoke-admin.sh (needs the
current admin password via SMOKE_PASS).

Done (commits): aurora build/asset pipeline; Wave 8 iframe modals; booking-calendar
Day/List/Month (`e18cf13`,`42bbf85`,`a095c5f`); booking detail = demo BookingDetailBody
(`26b8794`); demo-controls flask + calendar drag/hover (`05721fb`); New-booking / walk-in
form = demo NewBookingModal (`15890ac`).

**2026-06-25 — Sprints 1+2 (demo-parity backlog, verified in-browser; pending commit):**
- S1: calendar drag wiring restored (`_day.php` emits the reschedule/reassign/date + bilingual
  msg attrs the JS reads); switcher = List/Day/Month (dropped the non-demo "Week·Soon"); fullscreen
  label + zoom gutter wiring; appointment block now leads **customer → service → time** (no code,
  matches the rendered demo); dashboard recent-activity (6 most-recent DESC + Service column +
  stacked When); promo create/edit modal now chrome-less (`iframe_aurora` on `?iframe=1`) + a
  `postMessage('saved')` handshake; garbled `ar/backend.php` "نavagoo"→"نافاجو" fixed.
- S2: the booking-detail **Collect / Cancel / Reschedule** actions are now in-page aurora OVERLAYS
  inside `_detail.php` (was the P0 nested-iframe-to-JSON-endpoint bug) — verified pixel-faithful to
  the demo; **Block-time** is a new chrome-less aurora form (`actionBlockTime` + `_block_time.php`)
  posting to the real `/booking/time-off`; time-off blocks gained a hover **remove-X**
  (`/booking/remove-time-off`). aurora-core gained a `'close'` iframe-message branch.
  All wired to REAL endpoints (no mock; no API/data-model change) — see [[real-db-no-static-data-shared-api]].

**2026-06-25 — Sprint 3 + walk-in picker fix (committed `9541ece`):**
- Sprint 3: focused **New group booking** modal (`booking-calendar/_group_booking.php` +
  `actionGroupBooking`) — organiser(existing) + guest repeater + per-guest ServicePicker +
  shared SlotPicker + party total + Pay-after/Pay-now, posting to the existing
  `/agents-bookings/create-group` (+ `collect-group`). New-organiser disabled (create-group
  takes `customer_id` only) — flagged for sign-off. No schema/API change.
- **Walk-in form was entirely dead** (`booking-new.js` never booted: wrong root selector
  `[data-booking-new]` vs `[data-new-booking]`, flat config keys vs nested `cfg.urls.*`/`cfg.csrf.*`,
  and ~9 attr-name mismatches with `_new_booking.php`). Reconciled all bindings; picker now
  opens + selects + Accept fills summary. Group modal had a twin bug (guest `<template>` outside
  the root but queried root-scoped → no guest cards) — bind template via `document`.

**GOTCHA (cost us a user report): "renders" ≠ "works".** A view can curl-200 and look right but
be fully inert if the JS bindings/boot don't match the markup. ALWAYS interaction-test ported JS
forms in the browser (click the trigger, see the sub-screen open, submit) — not just screenshot the
initial paint. When markup + its JS are authored in one pass, audit every `$('[data-…]')` selector +
the boot root + the config shape against the actual rendered markup.

**2026-06-25 — authed END-TO-END pass (logged in as a real shop owner) found + fixed 3 real
pre-existing bugs (portal-only; mobile API unaffected):**
- `create-walk-in` matched the customer by `user.mobile` only, but `myClearPhone()` strips the
  country code → stored `966…` missed → tried to CREATE a duplicate → crashed on NOT-NULL
  `user.email`. Fixed: send + match `customer_id`; placeholder email on create (`f84ed6f`).
- Walk-in service **image** wasn't exposed by `walkInServices` → scissors placeholder. Fixed:
  return `ShopService::getImage()`, render in the picker (`00dd925`).
- **Capability table mismatch**: the modal's specialist filter (`walkInAgentServices`) read
  `agents_service` (global ids) but the server's `BookingScheduleService::canPerform`
  (used by create-group) reads **`user_shop_service`** → the modal offered specialists the
  server rejected, and shops whose agents lack `agents_service` rows showed NO specialists
  even though they ARE assigned in `user_shop_service`. Fixed: source `walkInAgentServices`
  from `user_shop_service` (`1229d71`).
- VERIFIED live: walk-in create→start→collect (cash) → COMPLETED; group create → party of 2
  sharing `group_booking_id`. `/booking/collect` verified live; `collect-group` verified by code
  (computes each child's outstanding server-side, ignores the client amount).

**GOTCHA: two capability tables exist** — `agents_service` (global service ids) and
`user_shop_service` (specialist→shop-service). The booking SERVER (`canPerform`) uses
`user_shop_service`; always source client capability filters from the SAME table.
**GOTCHA: walk-in default payment timing is `online`** (first option) → "Pay now" has nothing to
collect in-store and skips the collect screen; for a walk-in, `on_visit` is the natural default.
**HARNESS NOTE:** loading `_detail`/`_new_booking`/`_group_booking` DIRECTLY (not inside the
calendar iframe) makes the `saved`/`close` postMessage hit the same window → aurora-core reloads
it; in real use they live inside the calendar's iframe modal (parent ≠ self) so it closes+refreshes.

Backlog + status: `ai_specs/05_PLANS/DEMO_PARITY_GAP_BACKLOG.md`. Open: group Pay-now collect
click-through (constituents verified); walk-in default-timing UX; group New-organiser.

**2026-06-28 — Team epic + Notifications system + Unified Settings (sprint)**

**Team epic (3 tabs):**
- `frontend/views/agents/index.php` — refactored to 3 tabs: Specialists | Payroll | Structure.
- `frontend/views/agents/_payroll.php` — monthly payroll table: agent avatar, completed count,
  serviced value, commission base, fixed salary, commission %, total pay, tips earned. Grand total footer.
- `frontend/views/agents/_structure.php` — pure CSS/JS org-chart. `window.ngStructureRender`
  deferred so DOM is visible before `getBoundingClientRect` fires. Top-down/left-right toggle.
- `frontend/controllers/AgentsController.php` — payroll computation from completed bookings
  this month (sub_amount sum, tipping_amount sum). Wage type: fixed/commission/both.
- `common/models/UserProfile.php` — added `reports_to` rules (was in DB but not declared).
  `common/migrations/db/m260526_000200_add_agent_compensation.php` already created the column.

**Notifications system foundation:**
- `common/migrations/db/m260628_120000_add_notification_triggers.php` — creates
  `notification_trigger` + `shop_notification_setting` tables with 7 seeded triggers. Ran + verified.
- `common/models/NotificationTrigger.php` + `ShopNotificationSetting.php` — models with
  `timingLabel()`, `findForShop()`, constants for audience/channel.
- `frontend/views/layouts/_tw_navbar.php` — bell badge with unread count.
  Fixed `asDate('now', ...)` → `asDate(time(), ...)`.

**Unified Settings page (5 tabs):**
- `frontend/controllers/SettingsController.php` — 5-tab controller. POST routing by `?tab=`.
  ShopSubscription/ShopPlan guarded with try/catch + class_exists.
- `frontend/views/settings/index.php` + `_general.php` + `_scheduling.php` + `_payments.php` +
  `_commercials.php` + `_notifications.php` — all complete. Notifications tab: single POST form,
  3-button None/SMS/WhatsApp per optional customer trigger; shop triggers show in-app badge only.
- `frontend/views/layouts/menu/Menu.php` (frontend) — consolidated Shop Settings + Payment Methods
  → single "Settings" item pointing to `/settings/index`.

**2026-06-28 sprint (wave 2) — Catalogue + Notifications bell + Settings usage stats:**

**Admin Category Catalogue:**
- `backend/controllers/ShopCategoryController.php` — added `actionCatalogue()`: loads parents
  (parent_id IS NULL) + children grouped, service/shop counts. Links to existing create/update/delete actions.
- `backend/views/shop-category/catalogue.php` — two-level hierarchy card (port of `AdminCatalogue.tsx`):
  parent rows with thumb + bilingual name + child count + shop count + "Add category" button;
  indented children with svc count, edit/delete on hover. Uses `name_ar` virtual attribute from
  `MultiLanguageBehavior` (NOT `getTranslation()`).
- `backend/views/layouts/menu/Menu.php` — "Catalogue" item added under the categories submenu;
  "Shops Categories" active flag now excludes `action.id === 'catalogue'` to avoid double-highlight.

**Settings Notifications tab — usage stats:**
- `frontend/controllers/SettingsController.php` — passes `$notifSentThisMonth` (count from
  `notifications` table where `from_id = user.id AND created_at >= month start`).
- `frontend/views/settings/_notifications.php` — "This month's usage" card added at top: 3 tiles
  (In-app: real count / unlimited · SMS: 0/100 free · WhatsApp: 0/50 free). SMS/WhatsApp are 0
  until actual delivery is wired.
- `frontend/views/settings/index.php` — passes `$notifSentThisMonth` to the notifications pane render.

**Per-booking notifications bell:**
- `frontend/views/booking-calendar/_detail.php` — "Notifications sent" section injected inside the
  3rd column (status timeline) after the actions grid. Queries `notifications` where
  `module='booking' AND module_id=$bookingId`, shows last 10. Each item: bell icon chip (unread
  = brand tint, unread dot), title, relative time. Section hidden when empty (no extra queries on
  bookings with no notifications yet).

**Admin Notification Trigger Catalogue:**
- `backend/controllers/NotificationTriggerController.php` — CRUD: index, create, update, delete,
  toggle-active (AJAX), toggle-optional (AJAX).
- `backend/views/notification-trigger/index.php` + `_row.php` + `_form.php` + `create.php` + `update.php`
  — aurora Tailwind admin chrome. Two tables (customer | shop). Inline AJAX toggle knobs. Auto-slug
  event_key from name_en. All syntax-checked clean.
- `backend/views/layouts/menu/Menu.php` (backend) — "Notification Triggers" added after "Push Notifications".

GOTCHA (hit twice): the HTML `hidden` ATTRIBUTE is overridden by a Tailwind `flex`/`grid`/
`block` utility on the same element (utilities beat `[hidden]{display:none}`). For
hidden-by-default elements that also carry a display class, use inline `style="display:none"`
and toggle via JS `el.style.display = ''|'none'` (authoritative). `flex-1` is fine (it's
`flex:1 1 0%`, not a display value).

**2026-06-28 wave 3 — Finance notification fees + Notification timing modes:**

**Finance: notification fees in Earnings drawer (demo commits d694be8 + 56f1a62):**
- `common/components/FinanceLedgerService.php` → `bookingSettlement()` now deducts
  `TYPE_SMS` + `TYPE_WHATSAPP` charges from net payout; adds `notifFees` key to the
  returned settlement array (alongside marketingFees, processingFees, feeVat).
- `frontend/views/earnings/view.php` → settlement formula strip conditionally shows a
  "Notifications − X" term between "Processing" and "Fee VAT" when `notifFees > 0`.

**Notification trigger timing modes (demo commit b27bcf6):**
- `common/migrations/db/m260628_130000_add_notification_timing_modes.php` — new migration
  adding `timing_mode` (offset|calendar), `days_before`, `at_time` to `notification_trigger`;
  updates the `booking_reminder` seed row to calendar mode D-1 @ 22:00. Applied ✓.
- `common/models/NotificationTrigger.php` — rules + `timingLabel()` updated for both modes:
  calendar → "1 day before · at 22:00"; offset → "24h before" / "3h before" / "30 min before".
- `backend/views/notification-trigger/_form.php` — "When to send" select (offset|calendar) with
  conditional field groups: offset shows `timing_offset_minutes`; calendar shows `days_before` +
  `at_time` (time input). `ntTimingMode()` JS function toggles visibility.
- The base migration (`m260628_120000_add_notification_triggers.php`) was updated with the new
  column definitions + updated seed columns for future clean installs.

**2026-07-04 — Milestone-N phase-2 sync (demo v0.13.0 fully dispositioned).**
Plan + commit-by-commit disposition: `ai_specs/05_PLANS/DEMO_SYNC_MILESTONE_N_PHASE2_PLAN.md`.
Portal commits `5f368af..bb01f13` (plan · UI parity · engine · bell inbox · agent-slots fix · i18n/css):
- **Settings notifications tab = demo final table** (9c04ab4/14ba5ce/c1348b3/2a3a90d): fixed-header
  colgroup table, capped first column + template-preview expand, schedule label + "N sent this month"
  (re-scoped query — the old `from_id` filter never matched how `emit()` stamps rows), 3-up equal
  channel buttons + ngConfirm cost dialog, Platform-managed rows, max-w-4xl. f40a7e6 (channels[])
  is SUPERSEDED by the final single-paid-channel model — intentionally not ported.
- **Admin commercial config**: per-channel SMS/WA cards + live margin badge; migration
  `m260704_100000` adds `sms|wa_fail_refund`, `sms|wa_free_month` (NULL = code default).
- **Engine completion** (f940b0e/bee93ba/b62a26d/76f68b3/5c957dd): over-allowance sends append one
  `charge` row (sell price + VAT, `meta.notification_id` link — shared `notifications` table
  untouched); `markNotifFailed()` emits a negative STATUS_REVERSED row (min(perMsg×count, original),
  same convention as marketing-fee reversals); `notifications/process-due` cron every 5 min
  (withoutOverlapping) fires offset|calendar reminders comparing UNIX TIMESTAMPS with the
  same-day guard. Verified live: fire → bill → fail → reversal → idempotent re-run.
- **Bell inbox** (2d815c5): navbar dropdown (8 recent, mark-all-read, View all), POST mark-read
  scoped to `to_id`, feed rows click-to-read, bell↔page DOM sync.
- **Charges/Earnings**: per-channel "Notif fees · SMS/WhatsApp" split, "SMS/WhatsApp notification"
  labels, "reverses CHG-x · failed delivery" annotation.

**GOTCHA (cost us a silent no-op cron): PHP 8.2 E_DEPRECATED escalates to ErrorException in the
console ErrorHandler.** vendor `MultiLanguageBehavior` assigns dynamic properties on Shop → every
`$booking->shop` in a console command THROWS (web tier unaffected). The new cron masks
`E_DEPRECATED` in `init()`; any future console command touching translated models needs the same
(or a global console bootstrap fix). Also: Yii returns MySQL JSON columns as ARRAYS — write them
with `yii\db\JsonExpression` (plain `json_encode` string double-encodes and breaks `JSON_EXTRACT`).

Open follow-ups: ~~`settlement_received` dispatch~~ + ~~group-booking call sites~~ **CLOSED
2026-07-05 (`fe846bb`)** — `dispatchShopEvent()` transfer-scoped entry (fires on the transition into
SETTLED; inert until the admin authors+approves the trigger's in-app template) and group
create/cancel/collect/complete now fire the same per-child events as solo flows. Still open: REAL
SMS/WA delivery + DLR per the demo integration spec (`c79f6b7`/`2ba6594`/`62a31db`: Msegat poll-DLR,
T2 push webhook) — `SMSHelper`/`WhatsAppHelper` exist but are intentionally NOT wired to dispatch;
customer-audience in-app rows stay unaddressed (`to_id=null`) pending mobile-feed sign-off.

**2026-07-05 — engine fixes (`7d2a2e0`), found while closing the follow-ups.** Three real bugs:
(1) paid-channel `alreadySent()`/`emit()` keyed `module_id = shop_id` → the FIRST paid send of a
trigger suppressed every later booking's send as a "duplicate"; `module_id` is now ALWAYS the source
booking id, and shop scoping happens via `JOIN booking` (usage counter + Settings per-trigger counts
re-scoped; key_id restricted to real trigger ids so legacy NotificationHelper rows never pollute).
(2) shop-audience in-app rows were written `to_id = null`, but the portal bell/feed are
to_id-scoped → engine shop alerts could never surface; they now carry the shop-owner user id.
(3) the Settings "in-app this month" tile filtered `from_id = user id`, which `emit()` never writes
→ permanently zero. **GOTCHA: when adding a consumer/producer of engine rows, the row contract is
(key_id=trigger, module, module_id=SOURCE-ROW id, topic, to_id=recipient) — never scope by shop via
module_id and never filter by from_id.**

**2026-07-06 — "Navagoo Plans" shop page RE-WIRED to the correct tables** (plan:
`ai_specs/05_PLANS/NAVAGOO_PLANS_SHOP_PAGE_PLAN.md`). The Finance → Subscription tab was
wired to the WRONG concept — `subscription_package` (the shop's OWN prepaid customer
packages, managed at `/package/subscriptions`) — so it never matched the demo
`shop/finance/Subscription.tsx`. It now reads the shop's **SaaS subscription to Navagoo**:
`navagoo_subscription_plan` (active tiers) + `shop_subscription` (one row per shop) +
`user_card` (billing cards). Files: migration `m260706_120000_navagoo_plans_shop_page`
(adds `shop_subscription.free_period_ends_at` + idempotently seeds 3 active plans —
Starter/Professional/Business — if the catalogue is empty), models
`NavagooSubscriptionPlan` (+priceForPeriod/perMonthPrice/savePctForPeriod/featureList/
findActivePlans) & `ShopSubscription` (+period constants/periodMonths/isActive…),
`EarningsController` (subscription-tab load + `actionSubscriptionActivate` upserts
shop_subscription with plan+period+next_billing_at+card_last4, `actionSubscriptionCancel`
flips status), full rebuild of `frontend/views/earnings/_subscription.php` (current-status
card, **functional** Monthly/6mo/12mo toggle that recomputes per-month price + Save% + billed
line from server-rendered strings, plan grid, cards). Browser-verified end-to-end as the
[[dev-test-login]] owner: render → period toggle (Save 10%/20% math) → Activate (writes
active/six_month/next-billing) → status card "Active · Renews …" + "Current plan" → Cancel
(status=cancelled). Admin plan CRUD (`ShopController::actionPlans`) stays the source of truth.
GOTCHA to remember: **two different "subscription" concepts exist** — `subscription_package`
(shop→customer packages) vs `navagoo_subscription_plan`/`shop_subscription` (shop→Navagoo
SaaS). The demo's Finance → Subscription is the LATTER.
**Same day (later):** the ADMIN side (demo admin → Shops → Subscription Plans / Subscription
Dashboard — already fully built in wave-3) was unreachable from the sidebar → added
**Navagoo Plans** + **Subscriptions** entries under Shop Details in `backend/.../menu/Menu.php`
(+ crown icon in the tw sidebar map, demo CardHeader on the dashboard, month-clamped
`nextBillingFrom`). Backend admin password was rotated → in-browser admin re-check pending
the current password (routes curl-302, lint + i18n clean).

**2026-07-06 WAVE 2 — "Navagoo Plans" STANDALONE page (demo v0.19).** CRITICAL LESSON: the
local demo checkout was at v0.13 while origin/dev had moved to **v0.19** — `git fetch` the
demo BEFORE any port; the user sees the latest. In v0.19 Subscription left the Finance tabs
and became a top-level shop nav item (`/shop/plans`, Gem, above Settings) with offers,
tier gradients, upgrade/downgrade, and billing. Ported to `/navagoo-plans`
(plan: `ai_specs/05_PLANS/NAVAGOO_PLANS_SHOP_PAGE_PLAN.md` WAVE 2):
- Migration `m260706_150000_navagoo_plans_v2`: plan cols (tier/most_popular/bands/discount
  pcts/sms+wa) + seed→demo rate card (Starter 99 · Growth 225 popular · Pro 349, cumulative
  demo feature lists), sub cols (payment_method/current_term_*/past_due_since/
  pending_plan_id/trial_consumed), NEW `navagoo_offer` (+ Founding Partner 10%/50cap/12mo
  + Eid 5% seeds), NEW `shop_offer_enrollment` (shop untouched), `invoice.trigger`.
- Models `NavagooOffer` (eligibility/netFor/bestFor) + `ShopOfferEnrollment` (enrol stamps
  IMMUTABLE locked_until) + plan TIER_RANK/bandLabel/exceedsBand + sub past_due/expired/
  stampTerm.
- `NavagooPlansController`: subscribe (trial→free_period+trial_consumed; no-trial card→
  active + PAID `charge` TYPE_SUBSCRIPTION charge_to_card; no-trial bank→past_due + UNPAID
  net_from_settlement charge — rides the EXISTING ledger, no invoice engine yet), upgrade
  (trial=free switch; active=proration (newNet−term_price)×remaining → charge + immediate
  switch), downgrade (pending_plan_id at term end; trial=free switch), cancel. Blocked
  while past_due (demo).
- View `navagoo-plans/index.php` + `_offer_ticket.php`: hero tier-gradient banner (status
  pills incl. red PAST DUE, countdown lines, payment line, specialists used, offer panel),
  coupon tickets (Apply/Applied/Best offer/LOCKED IN), 3 gradient cards (conic MOST POPULAR
  border via registerCss @property --nav-angle), server-rendered price maps — JS only swaps
  strings (period × offer live repricing), confirm modal w/ full disclosure + card|bank
  picker. Finance tab REMOVED (+ redirect `?tab=subscription`→/navagoo-plans); menu items
  in `_tw_sidebar` (gem, above Settings) + legacy Menu.php. 84 new i18n keys ar+en.
- VERIFIED in-browser as the owner: offer switch repricing (Eid 94.05 = 99×0.95), period
  stacking (6mo+Eid 84.71 ✓), subscribe→trial (row + FP enrolment locked 2027 ✓), tier CTAs
  (Downgrade/Current/Upgrade), trial upgrade (free switch), trial downgrade (free switch),
  cancel, no-trial bank re-subscribe → PAST_DUE + real unpaid ledger charge 202.50+VAT
  30.38 + blocked CTAs. Smoke 30/30 (route added to render-smoke.sh). Test rows cleaned.
- **GOTCHA (testing):** the preview browser's floating "Leave feedback" bubble sits
  bottom-right and INTERCEPTS coordinate-based `preview_click` on buttons there (silent
  no-op). Use `element.click()` via preview_eval for bottom-right CTAs.
- **GOTCHA (auth):** running `tests/smoke/render-smoke.sh` logs in as the same owner and
  the device-validation feature KILLS your interactive browser session — re-login after.
- Deferred (documented in the plan doc): portal-wide gating/entitlements, renewal/dunning
  cron, term-end downgrade application, admin offers CRUD + plan-form v2 fields,
  subscription invoice generation.

**2026-07-14 — Onboarding (sign-in + sign-up) re-skinned to the demo PreAuthLayout look.**
The portal's self-signup + login ALREADY existed (`frontend/controllers/SignInController.php`
`actionLogin`/`actionSignup` + `frontend/views/sign-in/{login,signup,signup_pending}.php`, wired
to real `LoginForm`/`SignupForm` → creates User + Shop `verification_status=PENDING` = the demo's
`requested`). **Portal-only, no API/data-model touch** (mobile API unaffected). Synced their LOOK
to the current demo (`onboarding/{Login,SignUp}.tsx` + `PreAuthLayout.tsx`; those files last
changed only by the motion commit `50d5a73`, so the design has been stable since M1):
- minimal centered `img/logomark-purple.png` (already in the portal) + `.nav-auth-card` —
  **dropped the flanking Firefly salon images** and the GRADIENT wordmark;
- login: icon-tile header + "Sign In" + "Welcome back to Navagoo." subtitle; **KEPT username +
  remember-me + forgot-password** (real, working — user chose to keep vs the email-only demo mock);
- `.nav-field-label` accent-bar labels (3px brand bar via `::before`; legacy required-`*`
  asterisk suppressed for these labels), `text-[11px]` uppercase section labels, inline +966
  segment, free-floating category chips (kept the demo-parity chip JS + submit-gate + strength),
  **accent(green) "Submit Request"**;
- `signup_pending` → demo CheckCircle2 in-page ConfirmationPanel (red XCircle for rejected);
- CSS `.nav-reveal` (fade + 16px rise, ease-out-quint ~0.55s — mirrors demo `Reveal tier="hero"`,
  reduced-motion aware; no framer-motion). New tokens in `frontend/web/css/tailwind.src.css`:
  `.nav-auth-card`, `.nav-field-label`(+::before/::after), `.nav-reveal`. +1 i18n key
  `Welcome back to Navagoo.` (ar+en) + `e.g. Lila Beauty Studio`; email placeholder is a literal
  (not `Yii::t`) so the Arabic-guard doesn't flag an un-translatable example.
- **Browser-verified LTR + RTL:** both cards match the demo (accent bars + switcher flip correctly
  in RTL); signup chip-toggle + submit-gate (disabled→enabled) + password-strength interaction-
  tested live. Not committed (awaiting user).

**GOTCHA (renders≠works, again — cost a live-broken gate): an inline
`<script>jQuery(document).ready(...)</script>` on a `layout='base'` page THROWS.** base.php
registers jQuery at END of body, so the mid-body inline call hits `jQuery` undefined at parse
time → ReferenceError → it silently traps EVEN the vanilla chip/gate code nested inside the
callback (the whole block was dead: button never disabled, chips inert — no console error survived
the navigation). Fix: `document.addEventListener('DOMContentLoaded', function(){ var $ =
window.jQuery; … })` (DCL fires AFTER the end-of-body jQuery asset executes). Prefer
`$this->registerJs(…, View::POS_READY)` for any new inline JS on base-layout pages.

**GOTCHA (colors/fonts fidelity on `layout='base'` pages): the AdminLTE reboot leaks in and
fights the demo tokens.** It shrinks the ROOT font-size to ~14.4px (so every rem-based Tailwind
size renders ~10% small: `text-sm`→12.6px not 14px), inflates `h1` (base `h1{font-size:2rem}`
beats `.text-xl`), and paints `label` grey (#848484, not `text-ink`). The color TOKENS are fine —
portal `tailwind.config.js` is **hex-identical** to the demo `src/index.css` (brand/accent ramps,
`ink #1f1629`, `ink-soft #4e5560`, `ink-muted #6f7682`, `page #f4f7f7`, `st-*`), and self-hosted
Google Sans Text (LTR) / Noto Kufi Arabic (RTL — portal-extra; the demo is EN-only) load. It's the
AdminLTE base RULES that mis-render. Fix = scoped reset in `tailwind.src.css` (top-level, never
purged): `html:has(body.login-page){font-size:16px}` restores the demo root (fixes label/subtitle/
input/section-label/hint sizes at once) + `!important` re-asserts of `.nav-auth-card h1` (text-xl
20px / text-2xl 24px) and `.nav-auth-card .nav-field-label{color:#1f1629}`. Any page using
`.nav-field-label` needs `body-class = 'login-page'` for the `:has` root-reset to bind. VERIFY with
`preview_inspect` / computed-style eval (NOT screenshots) against the demo's rendered values (demo
login: h1 20/700/#1f1629 · label 14/700/#1f1629 · subtitle 14/400/#6f7682 · input 14/400). The
`demo-fidelity` colors/fonts pass = read the demo `src/index.css` `--color-*` + `@font-face`, diff
the portal config, then compare COMPUTED styles on both renders.

**2026-07-14 (later) — CAUGHT A STALE-CHECKOUT MISS + ported v0.28.0 "aura login + setup wizard".**
The light re-skin above matched the demo at **v0.26.0** — but the local checkout was **36 commits /
a full version behind** origin/dev (`git fetch` had silently FAILED twice: a 2-min network timeout,
then macOS has no `timeout` binary so the wrapped fetch never ran). The user's screenshots showed the
real **v0.28.0**: a DARK animated-aura login + a NEW 4-step first-run setup wizard. **LESSON (the
playbook's #1 rule, again): a `git fetch` that errors/timeouts leaves `origin/dev` STALE — verify the
fetch SUCCEEDED (`git rev-list --count HEAD..origin/dev`) before porting; never trust a ref a failed
fetch didn't update.** Fixed by pulling to `cb48c8d` (v0.28.0) and re-porting:
- **Dark aura login/sign-up** (`PreAuthLayout.tsx` + new `PreAuthAura.tsx`). Ported demo `src/index.css`
  1:1: `.bg-preauth` (dark radial), `.preauth-card` (glass + light-on-dark overrides: `.text-ink`→#fff,
  `.text-ink-muted`→wht/.62, `.text-brand-600`→#c3a9e8, `.bg-brand-600/10`→wht/.1, `input/select`→wht/.08;
  **NOT** `.text-ink-soft` — the white category chips need it dark), `preauth-orb-1..4` drift keyframes
  (orbs inline in `_preauth_aura.php`), white `wordmark-white.png` in-card, `Segmented` EN|العربية toggle
  (`_lang_toggle.php`). Login btn = demo `primary` (`bg-brand-600 rounded-full h-12`); sign-up = `accent`
  (`bg-accent-600`). Files: `frontend/views/sign-in/{login,signup,signup_pending,_preauth_aura,_lang_toggle}.php`.
  Kept username+remember+forgot (real features) styled dark.
- **Setup wizard** (`Setup.tsx` → `SetupController` + `frontend/views/setup/{index,_wizard_js}.php`): white
  card on the aura, `StepDots`, 4 steps — Profile (name + `open_at`/`close_at`) ▸ Services ▸ Specialists ▸
  Payments (`shop_payment_settings.pay_*_enabled`). **Completion DERIVED** (name set · ≥1 active
  `shop_service` · ≥1 active agent `user_type=1,status=2` · ≥1 pay method); ONLY stored bit =
  `shop.setup_completed_at` (migration `m260714_140000`, nullable/additive → mobile-safe, user-approved).
  Services/Specialists reuse the EXISTING create forms via `ngIframeModal`; Profile/Payments AJAX-save;
  `/setup/counts` refreshes on the iframe `saved` postMessage. Trigger = `SiteController::actionIndex`
  else-branch → redirects a verified+active shopOwner (not admin) to `/setup` when `SetupController::
  needsSetup($shop)`; `/setup` redirects to `/` once complete/dismissed. VERIFIED live as the
  [[dev-test-login]] owner (blanked shop 15's name to force it, restored after): login→wizard · step-1
  name-gate + AJAX save + advance · step-2 "17 added" gate · payments 3-toggle gate + AJAX save · Finish
  stamps setup_completed_at + redirects. GOTCHA: preview screenshots of the blur+mix-blend aura sometimes
  capture BLACK — a headless-capture artifact, NOT a render bug (DOM/computed-styles verify fine). The
  add-service/specialist iframe flow reuses the real editors but wasn't exercised (test shop already had
  17 services + 5 specialists) — verify it when onboarding a truly-empty shop.
