# 05 — Storefront frontend & views

> Snapshot of branch `docs/codebase-map-2026-09-29` (= `origin/dev`), written 2026-09-29.
> Claims marked **(unverified)** could not be confirmed from the repository alone (they depend on
> production DB rows, server-only files or runtime behaviour). Line numbers are `file:line` at this commit.
> Public tracking identifiers are masked as `xxxx***` in this document.

---

## 1. Purpose & scope

This document maps the **customer-facing web storefront** of Kapitano (kapitano.shop), which runs on
Active eCommerce CMS: Blade views, the main layout, home page composition, product/cart/checkout
pages, user dashboard, popups, i18n/RTL, static assets, browser analytics, and performance.

Out of scope, and covered by other docs: pricing math (`Helpers.php` price contract, see CLAUDE.md),
the admin panel (`backend/`), the seller panel (`seller/`), the mobile API (`Api/V2`), payments.
They are mentioned here only where storefront markup depends on them.

Key facts to know before touching anything:

- The storefront is **server-rendered Blade + jQuery** (Bootstrap 4, slick, the CMS's `AIZ` JS object).
  There is **no working build pipeline** for the storefront JS/CSS (see §6).
- **The storefront CSS/JS bundles are in git since 2026-10-09** (`public/assets/{css,js,img,fonts}`, taken from production, see §6.1). Change them in git, not on the server.
- Five home-page "themes" ship with the CMS; the active one is `business_settings.homepage_select`.
  All evidence points to **`megamart`** being live (see §2.1).
- The same markup is copied into many files (5 themes × product cards, product page vs quick-view modal,
  one-page checkout vs the legacy multi-step checkout). See §7 before editing anything.

---

## 2. View directory map

`resources/views/` (counts are `*.blade.php` files):

| Folder | Files | What lives there | Panel / theme |
|---|---|---|---|
| `frontend/layouts/` | 2 | `app.blade.php` (the storefront shell, 1360 lines), `user_panel.blade.php` (12 lines: app layout + `inc.user_side_nav`) | Storefront, all themes |
| `frontend/inc/` | 5 (+2 `.bak`) | `nav.blade.php` (header, search, cart/wishlist/compare dropdowns, notifications), `footer.blade.php` (last-viewed strip, widgets, mobile bottom bar, side nav), `floating_buttons`, `user_side_nav`, `demo_nav` | Storefront |
| `frontend/megamart/` | 14 (+1 `.bak`) | `index.blade.php` (home) + `partials/` (sections, `product_box_1/2`, `category_menu`, `brands_section` (megamart only), preorder box) | **Theme — believed active** |
| `frontend/classic/`, `metro/`, `minima/`, `reclassic/` | 11–12 each | Same structure as megamart (minus `brands_section`) | Inactive themes (still maintained sometimes) |
| `frontend/product_details/` | 10 | `details` (price/variants/add to cart), `image_gallery`, `description`, `reviews`, `review_section`, `product_queries`, `seller_info`, `top_selling_products`, `frequently_bought_products` + a misspelled twin `frequently_brought_products` | Product page |
| `frontend/partials/` | 43 | Cart modals and checkout steps (`partials/cart/*`), address modals, `search_content`, `search_landing_box`, `app_popup`, `login_modal`, `wishlist`, `compare`, `shop_header`, `coupon_box`, pagination | Storefront, shared |
| `frontend/user/` | 21 | Customer dashboard: `customer/dashboard`, `purchase_history`, `order_details_customer`, `view_wishlist`, `wallet/`, `conversations/`, `customer/notification/`, `profile`, `loyalty_logs` | Customer panel (inside storefront layout) |
| `frontend/*.blade.php` (root) | ~40 | Pages: `product_details`, `product_listing`, `view_cart`, `checkout`, `order_confirmed`, `track_order`, `seller_shop`, `shop_listing`, `all_category`, `all_brand`, `todays_deal`, `flash_deal_details`, `coupons`, `custom_page`, legacy checkout steps (`shipping_info`, `delivery_info`, `payment_select`), `digital_product_details`, `customer_product_*` | Storefront |
| `frontend/{bkash,payhere,razorpay,voguepay,instamojo,...}` | ~25 | CMS payment-gateway pages. Only COD + Paymera are really used. | Mostly dead |
| `frontend/policies/`, `sitemap/`, `blog/` | 9 | Policy pages, XML sitemaps, blog | Storefront |
| `auth/` | 52 | Login/registration in 3 layouts (`boxed`, `focused`, `free`, chosen by `authentication_layout_select`), OTP forms, `login_dialog` (included in the storefront layout) | Auth |
| `preorder/` | 93 | Preorder add-on: `frontend/` (listing, product boxes, home section `home_page/newest_preorder`), `backend/`, `seller/` | Add-on |
| `backend/` | 348 | Admin panel (own layout `backend/layouts/app.blade.php`). `backend/website_settings/pages/<theme>/home_page_edit` is the per-theme home editor. `backend/invoices/{invoice,combined_invoice}` are the PDF invoices. | Admin |
| `seller/` | 49 | Seller panel (own layout `seller/layouts/app.blade.php`) | Seller |
| `emails/` | 10 | Mail templates (`invoice`, `verification`, `newsletter`, `support`, `contact`, …) | Mail |
| `partials/`, `components/`, `modals/` | 23 | Shared admin/storefront pieces (`components/notification`, `unread_notification`) | Mixed |
| `errors/`, `installation/`, `update/`, `uploader/` | 17 | Error pages, CMS installer/updater, file uploader | Misc |

Views that are referenced but **missing** from the repo:
- `auction.frontend.<theme>.auction_products_section` (`HomeController.php:611`). There is no `resources/views/auction/`. It only renders when the auction add-on is active. It is not active today: the Lighthouse run got a 626-byte response. **(unverified: prod addon state)**
- `delivery_boys.dashboard` / `delivery_boys.profile` (`HomeController.php:1483,1494`).

### 2.1 Which theme is active

`HomeController@index` renders `frontend.{homepage_select}.index` (`app/Http/Controllers/HomeController.php:577`).
The value lives in `business_settings`, and the CMS default is `classic` (`sqlupdates/v800.sql:8`). Admin
picks it at `backend/website_settings/select_homepage.blade.php`.

**Believed active: `megamart`** **(unverified: DB value not readable here).** Evidence:
- 12 of the storefront commits since 2025-06 touch `frontend/megamart`. The other themes got only 1–2 "mirror" commits.
- `brands_section` exists only in megamart. The `load_brands_section` docblock (`HomeController.php:624-631`) says so explicitly.
- The unmerged `origin/perf/frontend-speed-2026-08-28` branch edits only megamart.

Any view that includes `'frontend.'.get_setting('homepage_select').'.partials.product_box_1'` follows the
active theme (listing, shop, flash deal, footer last-viewed, inhouse products, today's deal).

### 2.2 `.bak` files (tracked in git, never rendered)

| File | Lines | vs live file |
|---|---|---|
| `frontend/inc/nav.blade.php.bak` | 809 | live `nav.blade.php` is 763 |
| `frontend/inc/footer.blade.php.bak` | 708 | live 721 |
| `frontend/megamart/index.blade.php.bak` | 513 | live 795 |

They were added in `1a694565 upload project` / `246e4a96 link server to git` (Aug 2026) and are stale
snapshots. Blade cannot resolve `.blade.php.bak`, so they are dead. They do pollute `grep`, so exclude them
when searching for sibling copies.

---

## 3. Page → route → controller → view

| Page | Route (name) | Controller@method | View | Notes |
|---|---|---|---|---|
| Home | `GET /` (`home`) `routes/web.php:385` | `HomeController@index` :568 | `frontend.{theme}.index` | Only the slider, banners and featured categories render server-side. `featured_categories` is `Cache::rememberForever`. The flash deal block became the offers section (2026-11-05, `App\Services\SectionOffers`), a home section like the others: same carousel and `product_box_1` card (its `$box_footer` slot adds `frontend.partials.offer_timer` under the price: "You save" in pounds, "Only N left" at a real stock of `SectionOffers::LOW_STOCK` or below, and a ring countdown: day / hour / minute while a day or more is left, else hour / minute / second, then "The last minute!" and "Offer ended"), a yellow timer beside the title to the nearest end still ahead (`offer_next_end`), script and style in `offer_countdown_script`; the same on `/section/flash-sale`. Navy cards of its own were tried and dropped (2026-11-06): they did not fit the page. |
| Home sections (AJAX) | `POST /home/section/*` `web.php:388-395` | `HomeController@load_*` :580-659 | `frontend.{theme}.partials.*` | See §3.1 |
| Category | `GET /category/{slug}` (`products.category`) :459 | `SearchController@listingByCategory` :326 → `index` :67 | `frontend.product_listing` | Uses the theme's `product_box_1` (`product_listing.blade.php:470`) or `preorder.frontend.product_box3` |
| Brand | `GET /brand/{slug}` :460 | `SearchController@listingByBrand` :371 | `frontend.product_listing` | |
| Search | `GET /search` (`search`) :455 | `SearchController@index` | `frontend.product_listing` | Live dropdown: `POST /ajax-search` → `partials.search_content` (:473). Empty box: `POST /search/landing-box` → `partials.search_landing_box` |
| Product | `GET /product/{slug}` (`product`) :407 | `HomeController@product` :1562 | `frontend.product_details` → `product_details/*` | AJAX `?type=query/review` returns paginated partials. Variant price: `POST /product/variant-price` → `HomeController@variant_price` :1876 (JSON) |
| Quick view / add-to-cart modal | `POST /cart/show-cart-modal` :468 | `CartController@showCartModal` :95 | `frontend.partials.cart.addToCart` | Second copy of the product-details form |
| Cart | `GET /cart` (`cart`) :467 | `CartController@index` :64 | `frontend.view_cart` → `partials.cart.cart_details` | Add: `POST /cart/addtocart` returns JSON with `modal_view` (`partials.cart.addedToCart`) and `nav_cart_view` (`partials.cart.cart`) |
| Checkout (one page) | `GET /checkout` (`checkout`) :548 | `CheckoutController@index` :352 | `frontend.checkout` (1217 lines) | Includes `partials.cart.{shipping_info,delivery_info,payment_info,checkout_coupon,cart_summary}`. Submit: `POST /checkout/payment` → `CheckoutController@checkout` :466 |
| Legacy multi-step checkout | `ANY /checkout/delivery-info`, `POST /checkout/payment-select` | `store_shipping_info` :784 / `store_delivery_info` :865 | `frontend.delivery_info`, `frontend.payment_select` | Still routed. `frontend.shipping_info` has no route (`get_shipping_info` :762 is unrouted). |
| Order confirmed | `GET /checkout/order-confirmed` (`order_confirmed`) :553 | `CheckoutController@order_confirmed` :1108 | `frontend.order_confirmed` | Reads `Session::get('combined_order_id')`. Shows only a thank-you, the order number (`combined_orders.id`, the order page's number) and a button to `purchase_history.details` of the first order; the order's contents are on that page (since 2026-10-22). Server-side purchase events, seller alerts and loyalty points all happen here (see §5, §8). |
| Track order | `GET /track-your-order` (`orders.track`) :428 | `HomeController@trackOrder` :1551 | `frontend.track_order` | **No auth, lookup by order code only** (see §8) |
| User dashboard | `GET /dashboard` (`dashboard`) :529 | `HomeController@dashboard` :1472 | `frontend.user.customer.dashboard` (extends `layouts.user_panel`) | Sellers are redirected to `seller.dashboard` |
| Purchase history | `purchase_history` resource :565 | `PurchaseHistoryController@index` :26 / `purchase_history_details` :46 | `frontend.user.purchase_history`, `frontend.user.order_details_customer` | Details are scoped by `user_id` (OK) |
| Wishlist / wallet / notifications (support tickets removed 2026-11-04) | various | `WishlistController`, `WalletController`, `NotificationController` | `frontend.user.*` | |
| Seller shop | `GET /shop/{slug}` (`shop.visit`) :409, `GET /shop/{slug}/{type}` :410 | `HomeController@shop` :1629 → `filter_shop` :1649 | `frontend.seller_shop` (or `seller_shop_without_verification`) | `/shop/{slug}` opens `all-products`. The store front is `/shop/{slug}/home`. Product box = theme `product_box_1` (`seller_shop.blade.php:35`) |
| All sellers / categories / brands | `all_seller` :2148, `all_categories` :1825, `all_brands` :1833 | `HomeController` | `frontend.shop_listing`, `all_category`, `all_brand` | |
| Today's deal page | `HomeController@todays_deal` :2139 | | `frontend.todays_deal` | `Cache::rememberForever('todays_deal_products')`, while the home section (:582) is **uncached**, so the two can disagree |

### 3.1 Home page composition (AJAX sections)

Since 2026-10-11 the `home` route fires **one** POST, `home.sections` (`HomeController@load_home_sections`),
which builds every section below with its own method and returns `{key: html, _deferred: []}`; the layout puts each
into its container and starts the carousels once. Past `config('app.home_sections_budget_ms')` (1 s) the remaining
sections are listed in `_deferred` and fetched from their own routes below (which stay for that and for cached old
pages); a section slower than `app.home_section_slow_ms` is logged.

Since 2026-10-13 the request is started at the top of `<head>` (`window.kpHomeSections`, `fetch`, homepage route only) instead of after vendors.js (measured on live: HTML at 0.9 s, request at 2.2 s, sections at 3.3 s); the fill takes the promise or asks again itself. Each megamart container holds `frontend.partials.home_section_skeleton` (shapes `products` / `logos` / `categories`; CSS `…_skeleton_style` once, outside the containers) so the page never looks like one section with the footer under the categories; the fill removes a placeholder the server did not fill or whose request failed. Pinned by `HomeSectionsSkeletonTest`. The single routes and containers:

| POST route | Controller | Container id | Data source | megamart has container? |
|---|---|---|---|---|
| `home.section.featured` | `load_featured_section` :595 | `#section_featured` | `get_featured_products()` in the view | yes |
| `home.section.todays_deal` | `load_todays_deal_section` :580 | `#todays_deal` | `home_section_products_query('todays_deal')` (uncached) | yes |
| `home.section.custom` (`{id}`) | `load_custom_section` | `#section_custom_<id>` (megamart: `partials/home_section_slots`, in the `HomeSectionOrder` order; other themes: `partials/custom_home_section_containers`) | `CustomHomeSections::visible()`, view `frontend.partials.custom_home_sections` | yes |
| `home.section.best_selling` | `load_best_selling_section` :600 | `#section_best_selling` | helper in view | yes |
| `home.section.best_sellers` | `load_best_sellers_section` :619 | `#section_best_sellers` | top sellers | yes (megamart only) |
| `home.section.brands` | `load_brands_section` :632 | `#section_brands` | returns `''` if the theme lacks the view | yes (megamart only) |
| `home.section.newest_products` | `load_newest_product_section` :586 | `#section_newest` | `Cache::remember('newest_products', 3600)` | yes |
| `home.section.auction_products` | `load_auction_products_section` :605 | `#auction_products` | returns nothing unless the auction addon is active | **no** (wasted request) |
| `home.section.preorder_products` | `load_preorder_featured_products_section` :642 | `#section_featured_preorder_products` | uncached query (cache commented out) | yes, only if the preorder addon is active |
| `home.section.home_categories` | `load_home_categories_section` :614 | `#section_home_categories` | category-wise products | yes |

Server-rendered in `megamart/index.blade.php`: CSS block (:4-89), sliders with separate desktop/mobile sets
using a `<picture>` 1px-gif trick (:95-170), featured categories (:285-390), banners,
a coupon block that is HTML-commented but **still executed by Blade** (:581-634, closed by `--!>`),
the classified products section, `preorder.frontend.home_page.newest_preorder` (:719), a dead
legacy `#appPopup` (:725-783: CSS and markup that is never shown, and `closePopup()` is undefined), and
`@include('frontend.partials.app_popup')` (:790).

### 3.2 Popups

| Popup | Where | Source | Frequency |
|---|---|---|---|
| **Dynamic popups** (CMS) | `layouts/app.blade.php:569-629`, on **every page** | `DynamicPopup::where('status',1)` (uncached query per request). Admin: `DynamicPopupController` | id 1 = subscribe form. Others are a banner + link. Hidden via `removable-session` / `set-session` (session key `website-popup[-id]`), handled in aiz-core.js **(unverified: JS not in repo)** |
| **App popup** (Kapitano) | `frontend/partials/app_popup.blade.php` (180 lines), home page only | `app_popup_web_payload()` (cached 5 min, same source as the mobile app's `app_popups`) | Promo: once per popup id per day. App-download: phones only, once per 7 days. Uses `localStorage` (:138-139) |
| Custom alerts (CMS) | **not rendered on the storefront** | `CustomAlertController` admin CRUD still exists | Renderer removed from the layout in `29d01a31` (2026-08-10), so the admin screen has no visible effect |

---

## 4. Layout & shared partials — where things are injected

`resources/views/frontend/layouts/app.blade.php`:

| Lines | What | Notes |
|---|---|---|
| 3-11 | `$rtl = get_session_language()->rtl ?? 1`, then `<html dir="rtl" lang="ar">` or `lang=app locale` | Defaults to **RTL/ar** when the session locale has no `languages` row. `lang="ar"` is hard-coded for every RTL language |
| 15 | **Zoho PageSense** (added `d8a809da`, 2026-09-07) | Anti-flicker snippet: hides `body`/`html` (`opacity:0`) until the script loads, with a **10 s** timeout. Directly hurts FCP/LCP |
| 17-23 | **Microsoft Clarity** (`y4aa***`) | async |
| 26-28 | `csrf-token`, `app-url` (`getBaseURL()`, reads `$_SERVER`), `file-base-url` | Read by aiz-core.js |
| 32-38 | GTM (`GTM-5XF3***`) script, **HTML-commented out** | …but the GTM `<noscript>` iframe is still emitted at 523-525 |
| 40-75 | `<title>`, description, keywords, `@yield('meta')`, default OG/Twitter tags | Default tags are skipped when `$detailedProduct/$shop/$page/$blog/$customer_product` exist. `fb:app_id` is filled with `FACEBOOK_PIXEL_ID` (wrong field) |
| 81-82 | favicon from `site_icon` upload | |
| 89 | select2 CSS from jsDelivr | render-blocking third-party CSS |
| 93-98 | `vendors.css?v=2`, `bootstrap-rtl.min.css` (RTL only), `aiz-core.css?v={{ rand(1000,9999) }}`, `custom-style.css?v=6` | `rand()` defeats caching (§8) |
| 100 | Facebook domain-verification meta | |
| 101-125 | `AIZ.local` i18n strings for the uploader | `translate(..., null, true)` addslashes |
| 127-450 | Large inline `<style>`: `:root` colour tokens (partly from `get_setting`), search-dropdown CSS (must stay here, see comment at :232) | |
| 459-469 | Old GA (`TRACKING_ID`), commented out | |
| 471-506 | `@if facebook_pixel == 1`: the env-based pixel is commented out; a **hard-coded pixel `8390***`** is live | `init` + `PageView` |
| 507-513 | **GA4 gtag.js** with `env('GA4_MEASUREMENT_ID')` (`G-27BC***` seen in Lighthouse), `config` with `currency:'SYP'` | Unconditional. `env()` in a view returns null if config is cached (§8) |
| 514-516 | `echo get_setting('header_script')` — raw admin HTML | LiveAgent (`kapitano.ladesk.com`) and GetButton (WhatsApp widget), both seen in Lighthouse, most likely come from here or `footer_script` **(unverified: DB)** |
| 543 / 545 / 548 | `@include('frontend.inc.nav')`, `@yield('content')`, `@include('frontend.inc.footer')` | |
| 552-555 | floating buttons (`use_floating_buttons`) | |
| 569-629 | dynamic popups | see §3.2 |
| 631-654 | `partials.modal`, `account_delete_modal`, `#addToCart` modal shell, `@yield('modal')`, `auth.login_dialog` | |
| 666-667 | `vendors.js` (≈565 KB, not versioned), `aiz-core.js?v={{ rand() }}` | Synchronous, end of body |
| 669-673 | flash notifications → `AIZ.plugins.notify` | |
| 675-745 | home-section AJAX loader | §3.1 |
| 747-789 | category mega-menu lazy load (`category.elements`), language and currency switchers | |
| 791-891 | header search: debounce 400 ms, abort stale XHR, landing box, rotating placeholders, `navigator.sendBeacon` to `search.track` | |
| 908-1213 | global cart/wishlist/compare/variant JS: `updateNavCart`, `removeFromCart`, `showAddToCartModal`, `getVariantPrice`, `addToCart` (+ pixel events at 1090-1094), `addToCartBeforeLogin`, `buyNow`, `bid_single_modal`, … | These globals are called from theme partials via `onclick` |
| 1216-1268 | intl-tel-input setup (every country, Syria first; `preferredCountries` = `PhoneCountries::PREFERRED`) | Includes a leftover Bangladesh tweak (`bd`) |
| 1343 | `@yield('script')` | Page scripts: product view_item, checkout begin_checkout, order purchase |
| 1345-1347 | `echo get_setting('footer_script')` | raw admin HTML |
| 1349-1357 | **Zoho SalesIQ** chat widget (`siq3***`, added `69ad2555`) | defer |

Other layouts:
- `frontend/layouts/user_panel.blade.php`: extends `app` and adds `inc.user_side_nav`. All `frontend/user/*` pages use it. The account menu (also the phone drawer in `footer`) carries its own `kp-acc-*` styles (`@once`, navy band with the customer, links with icon tiles in three groups, sign out, delete account); the links come from `App\Support\CustomerAccountMenu` (add or remove one there). Below 1200px it is a drawer from the start side (right in Arabic): close button in the navy band, only the links scroll, sign out pinned at the bottom, Esc closes it, z-index 1048 while open (over the floating buttons, under modals). Kept short on purpose (2026-10-24): no loyalty points / referral code, and no links for features that are off or barely used (the partial's docblock lists them); the header's account dropdown (`inc/nav`) dropped Downloads the same way.
- `auth/layouts/authentication.blade.php`: its own head. It uses `aiz-core.css?v=rand()` and has none of the tracking scripts.
- `seller/layouts/app.blade.php:25,107-108`: `vendors`/`aiz-core` with **no** version query, so sellers can keep stale assets after a deploy.
- `backend/layouts/app.blade.php:42,164`: `rand()` busting, like the storefront.

Shared partials worth knowing:
- `frontend/inc/nav.blade.php`: top banner (`topbar_banner*`), search input with `data-placeholders`, `partials.compare` (:161), `partials.wishlist` (:167), `partials.cart.cart` (:334), and the notification dropdown (`{!! $notifyContent !!}` :256).
- `frontend/inc/footer.blade.php`: the last-viewed products strip (:1-32, customers only, uses the theme's `last_view_product_box_1`; styled by `.last-viewed-products` in `public/assets/css/custom-style.css`, carousel `slick-right` in RTL, arrows only above 2 products, cards carry wishlist/compare/add-to-cart or Place Bid for auctions — pinned by `tests/Feature/LastViewedProductsLayoutTest.php`), footer widgets from settings (raw HTML at :44, :128, :564), the mobile bottom bar with `count(get_user_cart())` (:618), and `inc.user_side_nav` (:718).
- `ComposerServiceProvider::boot()` is empty (the CMS view composer is commented out). Views query the DB directly.

---

## 5. Analytics / tracking events reference

**Money conventions (CLAUDE.md):** BASE = USD-normalised, stored in carts and orders. RAW = `products.unit_price`.
SYP = displayed. **Every browser event labels its value `USD`**, yet several send RAW or SYP numbers. **Every
server-side event says `SYP`.** Browser and server events do not share event IDs, so Meta and GA4 **double count**.

| Event | Browser (file:line) | Value / currency sent (browser) | Server-side counterpart | Value / currency (server) | Dedup? |
|---|---|---|---|---|---|
| PageView (Meta) | `layouts/app.blade.php:499-500` | — | none | — | — |
| page_view (GA4) | `layouts/app.blade.php:512` (automatic via `config`) | `currency:'SYP'` default param | none | — | — |
| **ViewContent / view_item** | `frontend/product_details.blade.php:278-300` (only if `facebook_pixel == 1`) | `value: $detailedProduct->unit_price` = **RAW** (not BASE, not variant, not discounted), `currency:'USD'` | `Ga4Mp::viewItem()` exists but is **never called** | — | n/a |
| **AddToCart / add_to_cart** | `layouts/app.blade.php:1090-1094`, using the `facebook_pixel` payload from `CartController.php:260-271` | `value = $product->unit_price` (RAW), `currency:'USD'`, quantity 1 in GA4 regardless of the real quantity. `data.ga4` is never returned, so the fallback object is always used. `event_id` is passed **inside the params**, not as `{eventID}`, so Meta ignores it. Only fires if `facebook_pixel == 1` | `CartController.php:231-256`: `Ga4Mp::addToCart` + `FacebookConversionApi::sendEvent('AddToCart')` (random UUID event_id) | `product_price_to_syp(unit_price)` = SYP, single unit, ignores variant/quantity | **No**: IDs differ, currencies differ |
| **InitiateCheckout / begin_checkout** | `frontend/checkout.blade.php:836-880` (only if `facebook_pixel == 1`) | `value: $total` (BASE subtotal+tax+shipping), `currency:'USD'`. Item `price` = `home_discounted_base_price(..., false)` which returns **SYP**. Mixed units within one event. Meta eventID `ic_<session id>` | `Ga4Mp::beginCheckout()` exists, never called | — | n/a |
| **Purchase / purchase** | `frontend/order_confirmed.blade.php:249-296` (only if `facebook_pixel == 1`, including the gtag call) | `value = round($combined_order->grand_total,2)` = BASE, `currency:'USD'`. Item price = `order_details.price` (BASE line). Meta `eventID` and GA4 `transaction_id` = `$combined_order->code` — **`combined_orders` has no `code` column** in the shipped schema (`sqlupdates/v52.sql:23`), so both are probably empty strings **(unverified on prod schema)** | `CheckoutController.php:1121-1161`: `FacebookConversionApi::sendPurchase` + `Ga4Mp::purchase` | `convert_order_price(grand_total)` = SYP. `event_id = 'purchase_'.$id`. GA4 `transaction_id = $combined_order->id` | **No**: IDs differ. **The server calls sit inside the inner `foreach ($order->orderDetails ...)` loop, so they are sent once per order line** (see §8) |
| Search tracking (first party) | `layouts/app.blade.php:878-891` (`.js-search-track` clicks → `sendBeacon`) | query / target id | `POST /search/track` → `Api\V2\SearchTrackingController@store` (`web.php:462`, throttle 60/min) | — | — |
| Clarity, PageSense, SalesIQ, LiveAgent, GetButton | layout :15, :17, :1349 / DB scripts | session recording and chat | — | — | — |

Server-side senders:
- `app/Services/Ga4Mp.php`: GA4 Measurement Protocol with `env('GA4_MEASUREMENT_ID')` / `env('GA4_API_SECRET')`. It silently no-ops if either is missing (the local `.env` has neither). `client_id` comes from the `_ga` cookie, otherwise it is random. Timeout 5 s.
- `app/Services/FacebookConversionApi.php`: Graph v23.0 with `env('FACEBOOK_PIXEL_ID')` / `env('FACEBOOK_ACCESS_TOKEN')`. The **access token is sent in the query string**. No timeout is set (Laravel's default is 30 s). No try/catch, and `Http::post` throws `ConnectionException` on connect failure. It sends a hashed email/phone/external_id plus IP/UA/`_fbp`/`_fbc`.
- Both run **synchronously inside the web request** (add-to-cart AJAX, order-confirmed page). There is no queue.
- The browser uses the hard-coded pixel `8390***` while the server uses `env('FACEBOOK_PIXEL_ID')`. If they differ, CAPI events go to another pixel **(unverified: prod env)**.

If you fix analytics, pick **one** currency (SYP, matching the server and `gtag config`) and send
BASE→SYP through `convert_price()` (carts) / `convert_order_price()` (orders). Share `event_id` /
`transaction_id` between browser and server. Move the server calls out of the loop.

---

## 6. Frontend assets & build

### 6.1 What is in git and what is not

`git ls-files public` lists only 13 files: `.htaccess`, `index.php`, `robots.txt`, `favicon.ico` (0 bytes),
`how_to_buy.mp4` (15 MB), `storage` (a symlink stored as a 45-byte text file on Windows), `uploads/.htaccess`,
`uploads/online.png`, two `download/*.xlsx`, and **only three icon fonts** `public/assets/fonts/la-*.svg`.

**Not in git (server-only)**, even though every page references them:

| Path | Referenced from |
|---|---|
| `public/assets/js/vendors.js` (jQuery, Bootstrap, slick, select2, lazysizes, intl-tel-input, … **(unverified contents)**) | all layouts |
| `public/assets/js/aiz-core.js` (the `AIZ` object: plugins, notify, carousel, uploader, `set-session`) | all layouts |
| `public/assets/css/{vendors,aiz-core,custom-style,bootstrap-rtl.min}.css` | all layouts |
| `public/assets/img/*` (placeholders, flags, payment cards, `play.png`, `app.png`, theme previews) | ~270 × `placeholder.jpg` and more |
| `public/assets/fonts/IBMPlexSansArabic-*.woff2`, `la-*.woff2` | CSS |
| `public/assets/js/intlTelutils.js`, `hummingbird-treeview.js` | layout, admin |
| `public/uploads/*` (all product/banner images) | `.gitignore` excludes them on purpose |

They were kept out by `public/assets/.gitignore`, which was `*` (it even ignored itself, so `git status` never showed it).

**Update 2026-10-09: all of `public/assets` is now tracked** (the table above is the history). `public/assets/.gitignore`
now ignores only `.DS_Store` and `myText.txt`. Before committing, every file was compared with production
(`https://kapitano.shop/assets/...` fetched with a cache-busting query, because Cloudflare Polish recompresses
cached images): production was newer for `css/aiz-core.css`, `css/vendors.css`, `img/cards/cod.png`, `img/flags.png`,
`img/logo1.png`, and alone had `img/{APP STOR,Dark Logo,Google play,facebook,insta,linkedin}.png` and
`fonts/IBMPlexSansArabic-Medium.ttf`; those were taken from production. A file the site never references could not be
found that way. On the server these files were *ignored*, so the first `git pull` there overwrites them silently with the
committed copies — which is why the commit had to match production. From now on change them in git, not on the server.

### 6.2 Build tooling state

- `webpack.mix.js` is the stock Laravel file: `resources/js/app.js` → `public/js`, `resources/sass/app.scss` → `public/css`. **No view references `mix()`, `public/js/app.js` or `public/css/app.css`.** The Vue/axios example in `resources/js` is unused.
- `package.json`: laravel-mix ^2.0, bootstrap ^4, jquery ^3.2, vue ^2.5, axios ^0.18 (2018 era). `npm run dev|prod` builds files nothing loads. The CLAUDE.md "Commands" section is misleading here.
- `aiz-core.js` / `vendors.js` are CodeCanyon **pre-built artifacts**. The repo has no source for them.

### 6.3 How to change JS/CSS safely

1. **Prefer inline changes in Blade.** That is what the team does: the `<style>` block at `app.blade.php:127-450`, theme `<style>` blocks (`megamart/index.blade.php:4-89`), and page `@section('script')`. These are versioned and deploy with `git pull`.
2. If you must change `custom-style.css` or `aiz-core.*`, fetch the **current server copy** first. Edit it and upload it by hand. Bump the `?v=` in the layout (`custom-style.css?v=6`; `vendors.css?v=2`). Consider committing the file at the same time so it becomes tracked, and flag that to the owner.
3. For `custom-style.css`, change the `?v=N` in **every** layout that loads it (5 references: `grep "custom-style.css"`).
4. Global JS functions called by partials through `onclick` (`addToCart`, `showAddToCartModal`, `addToWishList`, `addToCompare`, `clickToSlide`, `getVariantPrice`, `buyNow`) live in `app.blade.php`. Renaming one breaks every theme's `product_box_*`.
5. Search-dropdown CSS must stay in the layout (`app.blade.php:232` comment), because `#search-content` is replaced on every keystroke.

---

## 7. "Change together" list (duplication map)

| Concern | All the places you must touch |
|---|---|
| **Product card** (price, discount badge, add-to-cart button, wishlist) | `frontend/{classic,megamart,metro,minima,reclassic}/partials/product_box_1.blade.php` (5 near-identical copies, all different hashes), `product_box_2` (4 themes), `last_view_product_box_1` (5), `megamart/partials/preorder_product_box_1`, `preorder/frontend/product_box{,2,3}`, plus the search dropdown `partials/search_content` and `search_landing_box` |
| **Home sections** | Each of `todays_deal`, `featured_products_section`, `best_selling_section`, `newest_products_section`, `home_categories_section`, `best_sellers_section`, `category_menu` exists in 5 themes (several are byte-identical: e.g. `best_sellers_section` in classic/metro/minima/reclassic). The admin editor is duplicated per theme too: `backend/website_settings/pages/<theme>/home_page_edit.blade.php`. The app equivalent is `Api/V2/ProductController` (shares `home_section_products_query()`, good) |
| **Product page vs quick-view modal** | `frontend/product_details/details.blade.php` (718 lines) ↔ `frontend/partials/cart/addToCart.blade.php` ↔ `digital_product_details.blade.php` ↔ `customer_product_details.blade.php` ↔ `preorder/frontend/product_details/*` ↔ API `ProductDetailCollection`. The variant-price JS in the layout (`getVariantPrice` :1001) serves both page and modal, and its comment at :1008 notes the different thumb-tag formats |
| **Checkout** | One-page `frontend/checkout.blade.php` + `partials/cart/*` (live) **and** legacy `frontend/shipping_info` / `delivery_info` / `payment_select` / `partials/cart_details` / `partials/cart_summary` (partly routed, partly orphan: `partials/cart_details.blade.php:115` links to an undefined route `checkout.shipping_info`). Payment method descriptions are echoed in both `partials/cart/payment_info.blade.php:137` and `payment_select.blade.php:547`. The API has its own `Api/V2/CheckoutController`. The phone/country list JS is in `checkout.blade.php` and `partials/cart/guest_phone_prefill_js.blade.php` |
| **Order amounts** | `order_confirmed`, `user/order_details_customer`, `track_order`, `emails/invoice`, `backend/invoices/{invoice,combined_invoice}`. All must use `single_order_price()`, which they do today; keep it that way |
| **Cart dropdown / count** | `partials/cart/cart.blade.php` (nav), `inc/footer.blade.php:618` (mobile bar), `updateNavCart()` in the layout, `CartController` JSON (`nav_cart_view`, `cart_count`) |
| **Tracking snippets** | Browser: `layouts/app.blade.php`, `product_details.blade.php`, `checkout.blade.php`, `order_confirmed.blade.php`. Server: `CartController`, `CheckoutController@order_confirmed`, `Services/Ga4Mp`, `Services/FacebookConversionApi` |
| **Asset includes / cache-busting** | `frontend/layouts/app`, `auth/layouts/authentication`, `backend/layouts/{app,blank,layout}`, `seller/layouts/app`, `frontend/handle_demo_login` |
| **Contact Us rendering** | `backend/support/contact/{index,show}`: customer text always escaped (the staff e-mail and its `emails/contact` view were removed 2026-11-09). Support ticket views were deleted on 2026-11-04 |
| **Notification rendering** | `inc/nav.blade.php:256`, `user/customer/notification/index:100`, `components/notification:122`, `components/unread_notification:85` |
| **Phone field with a country code** (one look site-wide, decided 2026-10-12: code on the LEFT, number typed left to right, the popup's look "flag SY +963 caret") | The shared picker `auth/partials/phone_country_select` (+ its look/behaviour `phone_country_flags`, once per page) inside `<div class="input-group kp-phone-field" dir="ltr">`; it lists **every country** (`App\Support\PhoneCountries`, intl-tel-input's list, the usual 8 first, then by name) with a search box (English, local or Arabic name - Arabic folded so المانيا finds ألمانيا, `PhoneCountries::fold()` and its JS copy -, ISO or dial code; since 2026-10-22, before only 8 countries). On the Arabic site it shows the **Arabic names** (CLDR, stored in `PhoneCountries::COUNTRIES`, no `intl` needed) in Arabic order; the intl-tel-input fields of the register page, forgot password and the add-to-cart guest form get them through `localizedCountries` (`intlTelInputNames()`). Only the 8 are rendered in the HTML (the picker is in the sign-in popup of every page; all 243 rows were ~86 KB per picker): the others are `window.kpPhoneCountriesRest` (~10 KB, once per page, from `PhoneCountries::restForScript()`) and are added to the list and the hidden select on first open. The intl-tel-input phone fields of the register page, forgot password, the layout and the add-to-cart guest form also offer every country (no `onlyCountries`). Pages: `auth/login_dialog`, `frontend/partials/login_modal`, `auth/phone_login`, `auth/boxed/seller_new_login`, `partials/cart/guest_shipping_info`. Pass `kpCountryName` when the form posts the country (`country_code` = ISO, read by `HomeController` on the sign-in pages) and `kpCountryId` when a script finds it by id (checkout's `#country-code`, read through `guestCountry()`); a script that sets the hidden select (`.val(iso).trigger('change')`) gets the picker redrawn. The address window (`partials/address/map_picker`) keeps intl-tel-input (E.164 + pre-filled account phone) but is styled the same and shows the ISO code (`.kp-iti-iso`). Pinned by `tests/Feature/PhoneFieldLayoutTest.php` and `PhoneCountriesTest.php` |
| **Product link of a cart / order line** (2026-11-03: opens on the line's variant) | Every product name / photo of a line with a variant links through `product_page_url($productOrSlug, $variant)` (`Helpers.php`: `/product/{slug}?variant=MediumSeaGreen-S`, plain link when the variant is empty), never `route('product', $slug)`: the nav cart `partials/cart/cart`, `user/order_details_customer`, admin `sales/show`, `sales/show_combined`, `sales/partials/_checkout_peek`, `sales/deleted_orders/show`, `reports/cart_retargeting_row`, seller `orders/partials/products`. The product page (`product_details/details`) checks that variant's colour / option values instead of the first in-stock ones (`App\Support\ProductVariantSelection::fromVariant()`, which matches the name against the product's values, so a value holding a "-" still works; an unknown name keeps the default); `getVariantPrice()` on load then shows its price and photo. The canonical URL drops the query. A new line that links its product must use the helper. Pinned by `tests/Feature/ProductVariantSelectionTest.php` |
| **Category visibility** | Customer-facing category menus in every theme's `category_menu` + `HomeController@get_category_items`. Use `Category::visible()` / `visible_children` (see CLAUDE.md). The 2026-09-27 commit `8ccac56b` shows the pattern of mirroring across all 5 themes |

Price helpers used directly in storefront views (a grep target for pricing changes): `home_discounted_base_price` (35 files), `home_base_price` (33), `single_price` (42), `discount_in_percentage` (18), `cart_product_price` (6), `single_order_price` (3), `home_discounted_price` (4).

---

## 8. Gotchas, known bugs, XSS risks, performance

### 8.1 Bugs

1. **Server purchase events are sent once per order line.** In `CheckoutController.php:1127-1161`, `FacebookConversionApi::sendPurchase` and `Ga4Mp::purchase` sit inside `foreach ($order->orderDetails as $detail) { … }}`. A 5-line order sends 5 purchases, each with a growing `items` array. Meta dedups on the constant `event_id`; GA4 MP probably does not **(unverified)**.
2. **The order-confirmed page does side effects on GET, with no idempotency** beyond the session key: seller alerts (idempotent by design), **loyalty points** (`UserLoyalty` row per visit), and analytics. `$earned_points` is undefined if `Setting::find(1)` is null (:1181-1204). If the synchronous CAPI call throws, the page 500s before `Session::forget('combined_order_id')`, and a refresh repeats everything.
3. **Currency mislabelling** in every browser event (§5): RAW/BASE/SYP numbers labelled `USD`, while server events say `SYP`.
4. ~~`frontend/product_details.blade.php`: og/twitter prices from a RAW `unit_price`~~ — fixed 2026-10-04: every price tag is the SYP number the page shows (`SeoUtility`, see 03-catalog-search §4.12b). `digital_product_details` and `customer_product_details` still pass a RAW price to `single_price()`.
5. The `#auction_products` POST fires on megamart, which has no container, and the auction view does not exist (§2).
6. `HomeController@product` :1605-1617 (same pattern in `registration` :1243-1246): when `product_referral_code` does not match any user, `$referred_by_user->id` hits null and the page 500s (only with the affiliate addon).
7. `LanguageController@changeLanguage:26-28` stores the locale in the session **before** checking that it exists. An unknown code then crashes, and later requests fall back to `rtl ?? 1`.
8. `translate()` (`Helpers.php:1955`) **inserts a `translations` row on every unknown key**, on GET requests, and clears the `translations-en` cache. Dynamic keys like `translate(get_setting('cupon_title'))` (megamart :619) create rows.
9. ~~`GET /checkout/in` was a public debug route that sent a low-stock email for the first product.~~ Removed 2026-10-21.
10. `layouts/app.blade.php:1225`: leftover Bangladesh dial-code override (`bd` → `88`), harmless.

### 8.2 XSS / privacy risks (`{!! !!}` / raw echo with non-admin content)

| File:line | Content | Author | Risk |
|---|---|---|---|
| `frontend/product_details/description.blade.php:22` (`<?php echo … ?>`) | product description | **sellers** (partner imports are sanitised via `sanitize_partner_html`; seller/admin editor input is not) | **Stored XSS on the storefront by any seller** |
| `frontend/digital_product_details.blade.php:554` | digital product description | sellers | same |
| `frontend/customer_product_details.blade.php:168` | classified product description | **customers** | stored XSS (only if classifieds are enabled) |
| ~~Support ticket views and the Contact Us query window~~ | ticket `details` / `reply`, contact `content` | customers/sellers | fixed 2026-11-04: tickets removed, Contact Us escaped (`backend/support/contact/show`) |
| `layouts/app.blade.php:846` | `'…<strong>"'+searchKey+'"</strong>'` via `.html()` | the visitor | self-XSS only (DOM) |
| `inc/nav.blade.php:256`, `components/notification.blade.php:122`, `unread_notification:85`, `user/customer/notification/index:100` | notification template + order code | admin / system | low |
| `inc/footer.blade.php:44,128,564`, `contact_us_page:53`, `custom_page:55`, `m_custom_page:21`, `blog/details:72`, `payment_select:547`, `partials/cart/payment_info:137`, `layouts/app.blade.php:515,1346` (`header_script`/`footer_script`) | settings / pages / blog / payment method text | admin | by design; any admin account compromise means site-wide script injection |

There is no HTML-sanitising middleware (`app/Http/Kernel.php`). HTMLPurifier is available (it ships with
phpspreadsheet) and is already used by `sanitize_partner_html()` (`Helpers.php:5855`). Reuse it.

**PII disclosure:** `/track-your-order?order_code=…` (`HomeController@trackOrder:1551`) needs no login and
shows the customer's name, **email** and **full address** (`track_order.blade.php:60-70`). Order codes are
`date('Ymd-His') . rand(10,99)` (`OrderController.php:614`, `Api/V2/OrderController.php:175`), which are trivially enumerable.

Also: `EncryptCookies` is commented out in `Kernel.php:43` (cookies are plain text).

### 8.3 Performance

Baseline (see `performance-baselines/`, 2026-08-19, `https://www.kapitano.shop/`):

| Run | Perf score | FCP | LCP | TBT | CLS | Page weight |
|---|---|---|---|---|---|---|
| desktop-run-1/2/3 | 54 / 37 / 50 | ~1.0 s | ~1.9 s | 250–740 ms | 0.34–0.38 | ~14 MB |
| mobile-run-1 (desktop UA) | 29 | 3.5 s | **20.2 s** | 2.7 s | 0.03 | ~12.5 MB |
| `invalid-cloudflare-mobile/*` | — | — | — | — | — | 642 KB: Cloudflare challenge pages, **do not use** |

`lighthouse-mobile-desktop-ua.config.mjs` = mobile throttling/viewport with a desktop User-Agent, because
Cloudflare challenges Lighthouse's Android UA. These runs predate Zoho PageSense (09-07), which adds up to 10 s of hidden page.

Main causes, visible in the code and the run data:
- **Unoptimised uploads.** Banner/product PNGs of 300–865 KB each (`public/uploads/all/*.png`). `AizUploadController` only re-encodes at quality 75. No resized variants or WebP exist, so 60 px gallery thumbs load originals (`product_details/image_gallery.blade.php:50-52`).
- **`lazyload` misuse.** 166 `lazyload` class uses. Where `src` is the real image (e.g. every `product_box_1`, `megamart/index.blade.php:245-246`), lazysizes does nothing and every card image loads eagerly. Conversely, the **product page main image** is lazy (placeholder `src` + `data-src`, `image_gallery.blade.php:25-28`), which delays LCP.
- **Cache-busting with `rand(1000,9999)`** on `aiz-core.css/js` (`layouts/app.blade.php:97,667`): a new URL on every page view, so ~76 KB is re-downloaded each navigation, and the Cloudflare cache is defeated. `vendors.js` (565 KB) is unversioned and synchronous.
- **Render blocking:** PageSense anti-flicker (:15), select2 CSS from jsDelivr (:89), 4 local stylesheets, a ~320-line inline `<style>`.
- **Third-party load:** GA4 (188 KB), Meta pixel (238 KB with config), Clarity, PageSense, SalesIQ, LiveAgent, GetButton (129 KB), Cloudflare challenge scripts.
- **9 AJAX home POSTs**, each booting Laravel and each re-initialising *all* carousels. The home-category and preorder sections are uncached. The dynamic-popup query runs on every page, and `uploaded_asset()` is only memoised per request.
- **CLS 0.34–0.38 on desktop:** AJAX sections are injected into empty containers with no reserved height.
- The unmerged `origin/perf/frontend-speed-2026-08-28` (commit `7b4995bc`: "stop cache-busting core assets, lazy-load below-fold images, prioritise the hero") addresses several of these. It touches only megamart + layout + nav.

### 8.4 Other gotchas

- `env()` is called from views and services (`GA4_MEASUREMENT_ID`, `FACEBOOK_PIXEL_ID`, `DEMO_MODE`, `APP_NAME`, `DEFAULT_LANGUAGE`). After `php artisan config:cache` these return `null`: GA4 loads `?id=` and CAPI silently stops.
- `getBaseURL()` reads `$_SERVER['HTTP_HOST']` (layout :27), so rendering the layout in CLI/queue breaks.
- HTML comments do not stop Blade. Commented-out sections (`megamart/index.blade.php:581-634`, `checkout.blade.php:1214`) still run their PHP, and some still ship bytes.
- i18n: UI strings go through `translate()`, backed by the `translations` table (`lang`, `lang_key`, `lang_value`) and cached forever per language (`translations-{lang}`). Clear the cache after editing. Locale = session `locale`, defaulting to `DEFAULT_LANGUAGE`. The `languages` table has `code`, `rtl`, `app_lang_code` (Carbon locale). Much Kapitano-specific copy is **hard-coded Arabic** in Blade (e.g. the `megamart` app popup, product meta suffix `- تسوق`), so it does not translate.
- RTL: `bootstrap-rtl.min.css` is only added when `rtl == 1`. Custom CSS uses `html[dir="rtl"]` overrides (e.g. `.cart-count-badge`, layout :171).

---

## 9. Open questions

1. What is the production value of `homepage_select`? Megamart is inferred, not read from the DB.
2. ~~Where do the `public/assets` sources and current server copies live?~~ Answered 2026-10-09: committed from production, git is the source (§6.1).
3. Are LiveAgent and GetButton injected through `header_script`/`footer_script` settings, or through Cloudflare/another layer?
4. Does production `combined_orders` have a `code` column (browser purchase `transaction_id`/`eventID`)? Does it have `GA4_*` and `FACEBOOK_*` env vars, and does `FACEBOOK_PIXEL_ID` equal the hard-coded `8390***` pixel?
5. Is `facebook_pixel` = 1 in production? If not, the browser view_item, add_to_cart, begin_checkout and purchase GA4 events **never fire**, because they are nested inside that check.
6. Is `config:cache` used on the server? (It affects every `env()` call in views.)
7. Are the auction / preorder / classified / affiliate add-ons active? That determines which dead paths above are reachable.
8. Is the legacy multi-step checkout (`/checkout/delivery-info`, `/checkout/payment-select`) still linked from anywhere (emails, app deep links), or can it be removed?
9. Should `/track-your-order` require login or a second factor (phone), given the PII it shows?
10. Should the perf branch `origin/perf/frontend-speed-2026-08-28` be rebased and merged? It is 2 commits ahead and conflicts are likely with the 09-07 → 09-26 layout changes.
