# Pjax SPA-style Navigation — Shop Portal (frontend)

**Status:** Phases 0–3 DONE for shop. Calendar fixed + un-excluded 2026-07-21 (see
below); Social media still excepted (untestable — see below).
**Branch:** tailwind-poc · **Created:** 2026-07-21

## ⚠️ CRITICAL FIX, read this first: `NavContentPjax` (found 2026-07-21, late in Phase 2)

**Yii's own `yii\widgets\Pjax` silently discards every page-specific
`registerJs()`/`registerJsFile()`/`registerCss()` call on the exact request type this
whole navigation scheme depends on.** Root cause, verified against
`vendor/yiisoft/yii2/widgets/Pjax.php`: when `Pjax::init()` detects
`requiresPjax()` (the `X-Pjax-Container` header matching this widget's id — i.e.
**every single navigation click this feature produces**), it calls `$view->clear()`,
which unconditionally empties `$view->js`, `$view->jsFiles`, `$view->css`,
`$view->cssFiles` — including whatever the INNER VIEW (`<?= $content ?>`, already
rendered to a string moments earlier in the same request) had just registered for its
OWN page-specific behaviour. The layout's `Pjax::begin()` runs AFTER that content is
already a string, so by the time `clear()` fires, there is no way to re-register
anything — it's just gone. **This is not a "duplicate binding on the second visit"
bug — it means a page's own inline/registered JS never arrives AT ALL, even on the
FIRST Pjax-triggered navigation to that page.**

Caught by empirically comparing a full page load of `/shop` (admin) — which had the
`<script>` tag and worked — against the same page reached via
`fetch('/shop', {headers:{'X-PJAX':'true','X-PJAX-Container':'#tw-content-pjax'}})` —
which had **zero** `<script>` tags in the response body at all.

**Fix:** [`common/widgets/NavContentPjax.php`](../../common/widgets/NavContentPjax.php)
— a `yii\widgets\Pjax` subclass, shared by both portals, that backs up
`$view->js`/`jsFiles`/`css`/`cssFiles` immediately before `clear()` runs and merges
them back in immediately after (before `head()`/`beginBody()` read those positions).
Both layouts (`frontend/views/layouts/tailwind.php`,
`backend/views/layouts/tailwind.php`) use `NavContentPjax::begin()/end()` instead of
the bare `yii\widgets\Pjax`. **Any future Pjax container anywhere in this codebase
must use `NavContentPjax`, never the bare `yii\widgets\Pjax` widget**, or this exact
class of bug comes back.

**Practical fallout:** every idempotency-guard fix earlier in this doc
(`settings-notifications.js`, the various admin `.mode-toggle`/`.status-toggle`
handlers — see PJAX_NAV_ADMIN_PLAN.md) was written to solve "double-fires on repeat
Pjax visits" — which is real, but was ALSO masking a bigger problem: before
`NavContentPjax`, those scripts never ran under Pjax at all, guarded or not. Verified
live, post-fix: the admin `.mode-toggle` confirm dialog now fires exactly once on the
1st Pjax visit AND exactly once again on the 2nd (proving both halves — the fix that
makes it run, and the guard that keeps it from double-running — are both necessary
and both now correct).

## Phase 3 regression results (2026-07-21)

Multi-hop chain Home → Analytics → Finance → Team, then 3× browser back (landed
correctly on Home, exactly one sidebar item highlighted, matching URL/title), then 2×
forward (landed correctly on Finance, same single-highlight correctness), then a hard
reload mid-chain (`/earnings` direct load — sidebar present, tab-click still works).
Zero new console errors at any step (only the pre-existing, unrelated Google Maps
expired-API-key error, present on any page with a map widget regardless of Pjax).
**No accumulated state/duplicate-binding symptoms found** across the modules included
in this pass (Calendar was excluded at the time this pass ran; fixed + un-excluded
2026-07-21 — see Phase 1 findings, now updated).

## Phase 0 implementation log (2026-07-21)

Foundation landed and verified end-to-end (Home ↔ Services ↔ Team, EN + AR):
- `frontend/components/ShopNav.php` (NEW) — single source of truth for sections/footer
  items/active-id resolution, extracted verbatim from `_tw_sidebar.php`.
- `frontend/views/layouts/_tw_sidebar.php` — now calls `ShopNav::*`; `$renderItem()`
  emits `data-pjax-nav`, `data-nav-id`, `data-{active,inactive}-class`,
  `data-icon-{active,inactive}-class` on every link for JS-driven re-highlighting.
- `frontend/views/layouts/tailwind.php` — `Pjax::begin(['id' => 'tw-content-pjax', ...])`
  wraps the content div (full-chrome mode only, not iframe mode); the div carries
  `data-active-nav-id` + `data-nav-title` read by the JS after each swap. **Upgraded
  later the same day to `common\widgets\NavContentPjax::begin()/end()` — see the
  CRITICAL FIX section at the top of this doc; the bare `yii\widgets\Pjax` used here
  initially was silently dropping every page's own `registerJs()` output.**
- `frontend/web/js/aurora-pjax-nav.js` (NEW) — click-delegates `[data-pjax-nav]` to
  `$.pjax()`; on `pjax:success` re-inits lucide icons, re-highlights the sidebar,
  syncs `document.title`, resets `#tw-content-scroll` scroll position; falls back to a
  real navigation on `pjax:error`. Registered in `TailwindAsset::$js`.
- No `SiteController` change was needed — see the corrected note under decision #2.

**GOTCHA found + fixed during verification: bind to `pjax:end`, not `pjax:success`.**
Browser back/forward restores content from jquery-pjax's OWN client-side cache
(`onPjaxPopstate()` in `vendor/bower-asset/yii2-pjax/jquery.pjax.js`) and NEVER fires
`pjax:success` at all — only a real XHR-driven navigation does. `pjax:end` is the one
event that fires on every completion path (normal success, error, AND cached
popstate-restore), so `aurora-pjax-nav.js` binds there instead. Caught this by
explicitly testing browser back — the swap and title updated correctly (jquery-pjax
handles those itself even on the cached path) but the sidebar stayed highlighting the
PREVIOUS item, since only our own JS re-highlights it and our listener wasn't firing.
Any future Pjax-adjacent JS in this codebase should bind `pjax:end`, not
`pjax:success`, for the same reason.

**Verified in-browser (2026-07-21), Home ↔ /shop-service ↔ /agents:**
- Click nav → content-only swap (network response body starts with `<title>...</title>`
  immediately followed by the content div — no `<html>`/sidebar markup at all, confirms
  the widget's automatic short-circuit).
- Sidebar active-highlight (link class AND lucide icon color) correct after: click-nav,
  browser back, browser forward.
- `document.title` correct in both directions, EN and AR.
- Scroll position resets to top on each swap.
- Direct/deep-link reload of `/shop-service` still full-renders (sidebar present,
  correct server-computed active state) — non-Pjax fallback intact.
- RTL: `dir="rtl"` preserved, Team page (الفريق) renders correctly, no console errors.
- Zero console errors across every step above.

Not yet exercised this pass (defer to Phase 2 per-module rollout): forms/validation
inside a swapped page, `ngIframeModal` open/close across a nav, and the 2 existing
grid-level `Pjax::begin()` usages (`customer-invitations`, `sign-in/_formShopGallery`)
nested inside the new outer container — flagged as a real risk in the decisions above,
untested so far since neither is reachable from Home/Services/Team.

## Phase 1 findings (2026-07-21) — shared JS audit

**`aurora-core.js`: no fix needed.** All its delegated listeners bind on `document`
(modal/drawer/toast/confirm/iframe-modal openers) — safe for swapped-in content with
zero changes. It ALSO already had `document.addEventListener('pjax:end', ...)` calls
for stagger-animation re-init and page-transition replay (lines ~603, 606) — written
before today's work, an independent confirmation that `pjax:end` is this codebase's
established convention (see the GOTCHA above).

**Bonus discovery:** `aurora-core.js`'s `ngIframeModal` "saved" handler
(`document.querySelector('[data-pjax-container]')`) now matches `#tw-content-pjax` on
EVERY aurora page (Yii's `Pjax::init()` puts `data-pjax-container` on its container by
default) — meaning every "save inside an iframe modal → refresh the list" flow
site-wide now does a partial Pjax reload instead of `location.reload()`, for free.
Untested side effect of Phase 0 — verify in Phase 2 alongside `ngIframeModal` checks.

**Fixed (in two passes — see the CRITICAL gotcha below):** `aurora-forms.js`,
`aurora-tabs.js` — these are per-node-flagged and idempotent, but only ran once
(initial page load). Added a re-init call on every Pjax swap so a copy button /
swatch picker / tab group that only exists on a freshly-swapped page gets wired.

**CRITICAL GOTCHA (found during Phase 2 testing, not Phase 1 — corrected here):**
my first pass wired the re-init via `document.addEventListener('pjax:end', init)`.
Caught during Finance-page testing: clicking a tab did NOTHING, and
`tab.dataset.ngTabsBound` was `null` — `init()` had never re-run. Root cause, verified
live: `jquery.pjax.js` fires `pjax:end`/`pjax:success` via **jQuery's own `.trigger()`**,
which for a non-native event name does **not** dispatch a real DOM event —
`document.addEventListener` never sees it (proved with a throwaway custom-event test:
`jQuery(document).trigger('x')` fired a `jQuery(document).on('x', ...)` handler but
NOT a `document.addEventListener('x', ...)` one, on this app's jQuery 3.6.4). **The
fix in both files is `if (window.jQuery) { window.jQuery(document).on('pjax:end',
init); }`, not `document.addEventListener`.**

**Bigger finding from the same root cause: `aurora-core.js` had PRE-EXISTING, ALREADY-
DEAD `document.addEventListener('pjax:end', ...)` calls** (stagger-animation re-init,
page-transition replay — see the "Phase 0 implementation log" note above, which I'd
read as "confirms `pjax:end` is this codebase's established convention" — true for the
EVENT NAME, but that code had likely never actually fired since it predates any real
Pjax exercising and used the same wrong `addEventListener` API). Fixed the same way
(`jQuery(document).on(...)`), verified live afterward.

**Lesson for Phase 2/3 and the admin port: any `document.addEventListener('pjax:...',
...)` found ANYWHERE in this codebase is dead code — grep for it and fix on sight.**

**Fixed:** `settings-notifications.js` (page-specific, `registerJsFile`'d inside
`views/settings/_notifications.php`) — re-executes on every Pjax nav TO Settings since
it's registered per-view, not per-bundle. Its single delegated click listener had no
guard, so a second visit would double-bind it (and `classList.toggle()` run twice is a
no-op — clicking would silently do nothing). Added a `window.__ngNotifPreviewBound`
guard.

**Out of scope (verified, no fix needed):** `booking-new.js`, `shop-service-form.js`,
`shop-service-extras.js` — only ever loaded inside an `ngIframeModal` `<iframe>`,
a separate browsing context Pjax swaps never touch. `column-chooser.js`,
`customGalleryManager.js` — registered via `BackendAsset`/`DashboardAsset`/
`MyGalleryManagerAsset` for legacy `layout=base` (AdminLTE) pages, never reachable
through the Pjax container. **Minor residual risk, not fixed:** `ShopServiceController::
actionCreate/actionUpdate` also render `layout=tailwind` (full chrome) when hit WITHOUT
`?iframe=1` — a documented no-JS degrade path, not the normal in-app flow (the "Add
service" button always opens the iframe). Didn't audit `shop-service-form.js`'s
idempotency for that edge case; low priority since it's not part of the sidebar-driven
Pjax flow.

**RESOLVED 2026-07-21 — `booking-calendar.js` fixed, Calendar exclusion LIFTED.**
Follow-up investigation (triggered by a real user report: "the click on the calendar
day view in the slot not working") found and fixed three distinct, real bugs — but
disproved the "duplicate listeners" theory this section originally warned about:

1. **Root cause of the user-visible symptom: `NavContentPjax` didn't back up
   `$view->assetBundles`.** `registerJsFile($url, ['depends' => [SomeAsset::class]])` —
   exactly how `booking-calendar.js` is registered in
   `views/booking-calendar/index.php` — routes through `$view->assetBundles`, a
   DIFFERENT registry than the plain `$view->jsFiles` the original `NavContentPjax` fix
   covered. `$view->clear()` wiped it on every Pjax-detected request, so the script
   never arrived AT ALL after a Pjax nav to Calendar (zero console errors — it just
   silently never loaded). Fixed by adding a `$_assetBundlesBackup` backup/restore,
   mirroring the other four registries — see `common/widgets/NavContentPjax.php`.
2. **Duplicate-listener accumulation — empirically DISPROVEN.** Tested directly: zoom
   button clicked across 3 separate Pjax visits to Calendar increased zoom by exactly
   one step each time (never doubled/tripled), and only one `<script src="booking-
   calendar.js">` tag was ever present in the DOM at a time. jquery-pjax's content
   swap does not appear to re-execute an already-loaded EXTERNAL `<script src>` tag on
   repeat visits — the first successful load's bindings persist for the tab's
   lifetime. So the ~15 unguarded `document`/`window` listeners are safe as-is; no
   idempotency-guard rework was needed.
3. **Real bug: two stale-closure sites, both fixed to re-query fresh instead of
   capturing once.** Because the script only runs ONCE per tab (per finding #2), any
   function that captured a DOM reference in its outer closure and used it directly
   inside a later-bound event handler would go stale the moment Pjax swapped in a new
   `#tw-content-pjax` subtree (detached node, but attributes still readable — so no
   error, just silently WRONG data):
   - `initFullscreen()` captured `root = document.querySelector('[data-cal-root]')`
     and used it directly in the click/Escape handlers → toggle silently stopped
     working on the 2nd+ Pjax visit. Fixed: re-query `document.querySelector('[data-
     cal-root]')` fresh inside each handler.
   - `initHoverCreate()`'s empty-slot click handler read `grid.getAttribute('data-
     date')` off the same kind of captured outer variable, with no fresh-query
     fallback (unlike the geometry calc two lines above it, which did fall back to
     `body.closest(...)`) → clicking an empty slot to create a walk-in on a 2nd+ Pjax
     visit to a DIFFERENT date silently prefilled the WRONG date. This is almost
     certainly what the user's bug report was describing. Fixed: re-query
     `body.closest('[data-cal-columns]') || document.querySelector(...)` fresh and
     read `data-date` off that.
   Audited the remaining functions (`initZoom`, `positionNowLines`/`initNowLine`,
   `initList`, `initDrag`, `initTimeOffRemove`) — all already re-query fresh inside
   their handlers or use pure event delegation (`e.target.closest(...)`); no other
   stale-closure sites found.
   (Self-caught regression during this fix: my first edit to `initHoverCreate()`
   removed the outer `var grid = ...` declaration but missed two remaining references
   to it further down, throwing `ReferenceError: grid is not defined` and silently
   killing the whole click handler — caught via a `window.addEventListener('error',
   ...)` probe during verification, fixed before landing.)

Verified live end-to-end (direct DOM-level `dispatchEvent(new MouseEvent(...))`
against the real bound handlers — the `computer`-tool's synthetic click-to-pixel
mapping proved unreliable in this environment for unrelated tooling reasons, so this
was the more rigorous check): fresh nav to Calendar, an in-page day-forward Pjax hop,
and a Home→Calendar sidebar Pjax hop all correctly produce a `ngIframeModal(...)` call
with the CURRENT date, zero thrown errors, across all three paths.

**Decision: Calendar is no longer excluded — safe to include in the sidebar's Pjax
flow.** Update the table row below accordingly.

## Goal

Make sidebar navigation in the shop portal feel like the React demo (only the content
area swaps, sidebar/topbar chrome never re-renders, no white-page flash) using Yii's
built-in `Pjax` widget instead of a framework rewrite. See the discussion this plan
came out of — no separate doc, just the conversation that led here.

## Non-goals

- Not a SPA rewrite. Still server-rendered Yii2 views.
- Not touching the `api/` tier (mobile app unaffected — this is portal-only chrome/JS).
- Legacy `layout=base` pages (login/setup/activation) are OUT of scope — they don't share
  the aurora chrome and aren't reachable from the sidebar while unauthenticated.

## Current state (verified in code, 2026-07-21)

- `frontend/views/layouts/tailwind.php` already registers `yii\widgets\PjaxAsset` and has
  exactly one content container: `<main>...<?= $content ?></main>` (~line 84-86).
- `frontend/views/layouts/_tw_sidebar.php` builds every one of its **15** top-level links
  through a single function, `$renderItem()` (~line 96) — one edit point, not 15.
- Only 2 existing views use `Pjax::begin()/widget()` today (grid-level, not full-page nav):
  `frontend/views/customer-invitations/index.php`, `frontend/views/sign-in/_formShopGallery.php`.
- Core JS: `aurora-core.js` (635 lines), `aurora-forms.js` (169), `aurora-tabs.js` (138).
  4 call sites use `lucide.createIcons()`; 6 use `DOMContentLoaded`. Plus ~11 page-specific
  JS files (`booking-calendar.js`, `shop-service-form.js`, etc.) with their own init code.

## Architecture decisions (must be made before Phase 0)

1. **Sidebar stays OUTSIDE the Pjax container.** This is the whole point (chrome doesn't
   re-render). That means Yii Pjax's default "auto-bind every `<a>` inside me" behavior
   does NOT cover sidebar links — they live outside the container. Need a small custom
   binding: sidebar link click → `$.pjax({container: '#tw-content-pjax', url: this.href})`
   + `event.preventDefault()`, with a `data-pjax-nav` marker so unrelated `<a>` tags
   (external links, `data-method=post` logout, iframe-modal triggers) are excluded.
2. **Server-side short-circuit for real perf gain — CORRECTION (2026-07-21, verified in
   `vendor/yiisoft/yii2/widgets/Pjax.php`): this is automatic, no controller changes
   needed.** `Pjax::begin()`'s `init()` checks `requiresPjax()` (the `X-Pjax` +
   `X-Pjax-Container` request headers matching this widget's own id, which is exactly
   what jquery.pjax.js's `$.pjax({container: '#tw-content-pjax', ...})` sends). When it
   matches, `init()` output-buffers everything from that point, and `run()` discards the
   buffer, sets `$response->content` to ONLY the widget's own captured content, and calls
   `Yii::$app->end()` — terminating the request right there. Because `Yii::$app->end()`
   clears ALL nested output buffers (PHP's `ob_*` stack), whatever the sidebar/navbar/head
   already echoed earlier in the SAME request is discarded too — it costs a little CPU
   (that PHP still executes) but ZERO bytes over the wire. This means every module using
   `layout='tailwind'` gets the byte/server-render win for free the moment Phase 0's
   layout wiring lands — nothing to add per-controller in Phase 2.
3. **Title + active-nav-state on every swap.** Pjax updates the URL (pushState) but not
   `<title>` or which sidebar item is highlighted automatically — wire both from a
   `pjax:success` handler (read title from a data attribute or response header).
4. **JS re-init contract.** Every script that runs `lucide.createIcons()` or binds via
   `DOMContentLoaded` must ALSO bind the same init on `$(document).on('pjax:success', ...)`
   scoped to the swapped container, so it re-runs after each content swap without
   double-binding event listeners (idempotency matters more here than a first page load).

## Phases

- **Phase 0 — Foundation + 1 pilot page.** Wire the container + custom sidebar binding +
  title/active-state sync on ONE page only (`/` Home). Confirm back/forward, deep-link
  reload, RTL, and no console errors before touching anything else.
- **Phase 1 — Shared JS made pjax-safe.** `aurora-core.js` / `aurora-forms.js` /
  `aurora-tabs.js` get a `pjax:success` re-init pass (icons, tab state, modal triggers,
  toast/confirm bindings). This is shared infra — do it once, benefits every module.
- **Phase 2 — Per-module rollout**, using the checklist below, module by module (not all
  15 at once). Each module's controller(s) get the `getIsPjax()` short-circuit here.
- **Phase 3 — Full regression pass**: every module below re-verified together (a link from
  module A to module B, browser back through 3+ hops, hard-refresh mid-navigation).

## Verification checklist (apply to every module row below)

For each module: `[ ] Pjax loads content only (chrome untouched)` · `[ ] Icons/JS
re-init (no dead buttons)` · `[ ] Forms submit + client validation still fire` ·
`[ ] Modals / ngIframeModal still open+close+postMessage correctly` · `[ ] Sidebar
active-item highlights correctly` · `[ ] Browser back/forward restores the right view`
· `[ ] <title> updates` · `[ ] RTL (Arabic) unaffected` · `[ ] No console errors` ·
`[ ] Response is genuinely partial on a Pjax nav (check Network tab — no full <html> in
the response body; confirms the widget's auto short-circuit fired for this page)` ·
`[ ] Direct/deep-link reload of the URL still full-renders correctly (non-Pjax fallback)`

## Per-module TODO (grounded in `_tw_sidebar.php`, 2026-07-21)

| # | Module | Route | CRUD surface | Status |
|---|---|---|---|---|
| 1 | Home | `/` (site/index) | read (dashboard) | ✅ verified Phase 0 |
| 2 | Calendar | `/booking-calendar/index` | day/month/list views + create/reschedule/cancel/collect (modals) | ✅ **FIXED + INCLUDED (2026-07-21)** — see Phase 1 findings (now updated). Root cause was `NavContentPjax` missing an `assetBundles` backup (script never arrived at all under Pjax); duplicate-listener risk was empirically disproven; two real stale-closure bugs (`initFullscreen`, `initHoverCreate`'s slot-click date) found and fixed. Verified live via direct DOM event dispatch across 3 Pjax scenarios (fresh nav, in-page day-hop, sidebar hop) — correct date, zero errors |
| 3 | Analytics | `/shop-analytics/index` | read only | ✅ verified Phase 2 |
| 4 | Services | `/shop-service` | full CRUD + variants + freebies + bundles | ✅ verified Phase 2 (nav swap + `ngIframeModal` open/save/close all correct, confirmed via a `window.__marker` survival check that the outer page never reloads). Found an UNRELATED pre-existing bug — the list shows "Services (0)" even for a real active row, reproduces on a plain hard reload — spawned as a separate task, not a Pjax issue. |
| 5 | Customers | `/customers` | read + invite | ✅ verified Phase 2 |
| 6 | Finance / Earnings | `/earnings` | read + settlement + charges + invoices tabs | ✅ verified Phase 2 — this is the page that surfaced the jQuery `pjax:end` gotcha (tab click did nothing until fixed) |
| 7 | Team (agents) | `/agents` | full CRUD (create/update verified this session — see [[agents-service-capability]]) + payroll + structure tabs | ✅ verified Phase 0 (incl. RTL) |
| 8 | Notifications | `/notifications/index` | read + mark-read | ✅ verified Phase 2 |
| 9 | Promo codes | `/promo-code` | full CRUD | ✅ verified Phase 2 (nav-only; CRUD forms not separately exercised) |
| 10 | Package | `/package` | full CRUD | ✅ verified Phase 2 (nav-only) |
| 11 | Branch | `/branch` | full CRUD (conditionally visible) | ✅ verified Phase 2 (nav-only) |
| 12 | Reviews | `/rate` | read + reply | ✅ verified Phase 2 (nav-only) |
| 13 | Social media | `/social-media/index` | connect/create/posts (conditionally visible) | ⬜ **untested** — not enabled for the test shop used this session (`ShopSocialSettings` gate), not a bug |
| 14 | Help center | `/technical-support/index` | create ticket + read | ✅ verified Phase 2 (nav-only) |
| 15 | Navagoo Plans | `/navagoo-plans/index` | subscribe/upgrade/downgrade/cancel (no classic CRUD, transactional) | ✅ verified Phase 2 (nav + content render; offer/period toggle JS not separately exercised) |
| 16 | Settings | `/settings/index` | 5-tab settings form (general/scheduling/payments/commercials/notifications) | ✅ verified Phase 2 (tab switch confirmed working; unrelated pre-existing Google Maps API key expiry console error noted, out of scope) |

## Open questions to resolve in Phase 0

- Does `ngIframeModal` (used across Services/Team/Calendar create forms) need any change,
  or does it already live fully inside whatever DOM node Pjax swaps (should be transparent
  since it's appended to `<body>`, outside `<main>`, already unaffected by content swaps)?
- Any page that does a raw `window.location.href` redirect after a form submit (instead of
  `ajax` + `pjax:success`) will force a full reload anyway — audit during Phase 2, don't
  assume Phase 1 covers it.
