# Markets (Manage)

**Nav:** Manage sidebar → **Setting** → **Markets** (`/manage/markets`).
Gated on `VIEW_INTEGRATIONS`; toggling needs `MANAGE_INTEGRATIONS`.

## What it does

Decides which countries this deployment serves publicly. One checkbox per
country over `countries.is_active` — tick to serve the market, untick to hide
it. **Nothing is deleted**: the catalogue rows, media, floor plans and saved
analyses stay exactly as they are and return the moment the market is ticked
again.

## How it works

- **One flag, whole-site reach.** `App\Http\Middleware\SetPublicSite` resolves
  the URL country from **active countries only** and `abort_unless($country,
  404)`. Every public page lives inside the `{country}` route group behind that
  middleware, so unticking a market 404s its entire site in one move — home,
  `new-projects`, every project detail page, the region switcher. `SitemapController`
  and `SiteController`'s country tabs filter on the same flag, so a hidden
  market is not advertised either.
- **The portal reads catalogue rows regardless, but its LINKS must serve.** The
  only portal surface that lists catalogue projects is Analyze Property → New
  Project, and every card links at the **public** detail page
  (`/{iso2}/projects/{slug}`) — which SetPublicSite 404s for a hidden market. So
  that listing follows an active market (default when served, else the first
  active one), while the data-only fallbacks — the project typeahead, the
  `country_id` default on portal writes and catalogue imports — keep using
  `Country::defaultCountry()` whether or not it is served publicly.
- **The default market is switchable like any other.** Hiding it does not break
  the root `/`: SetPublicSite resolves the rootless URL from **active** markets
  only — visitor cookie → an active default → the first active market by name —
  so the front door simply moves to another market. `SiteController::region`
  picks its no-`?from=` landing the same way, for the same reason. The confirm
  modal names the market the front door moves to before you click.
  `is_default` keeps its **other** job while hidden: it is still the `country_id`
  fallback for portal writes, catalogue imports and Analyze Property, all of
  which read the row regardless of whether it is served publicly. So the badge
  is *default* (fallback market), not *locked*.
- **The LAST active market cannot be switched off** (`CountryRepository::setActive`
  throws; the controller flashes the error and returns `back()`). With no active
  market the root has no country to resolve and `abort_unless($country, 404)`
  takes the entire public site down, not one market's.
- **Switching OFF confirms, switching ON does not.** Taking a public site down
  is the destructive direction; the confirm names the market and how many
  published projects stop serving. There is no client-side guard on the last
  market — the server throws and flashes, so the rule lives in one place.
- Each row shows currency + timezone, published-of-total project counts, and a
  Visit link to that market's live home page.

## Host rules — `market_sites`

The section above is the GLOBAL switch: which markets this deployment serves at all. The same
page carries a second card, **Domain market visibility**, which narrows that per hostname.

- **Grain: one `market_sites` row per hostname** — `domain`, `is_default`, `all_markets`
  (`Src\Common\MarketSite`), with `market_site_countries` as its country pivot.
- **Resolution order** — `MarketSiteResolver::siteFor()` takes the exact domain match, else
  the single `is_default` row, in one `firstOrFail()`. A deployment with **no default row
  throws on an unknown host**. The default row is deliberately HOSTLESS: the repository
  refuses to give it a domain, and deleting it throws *"The default domain rule cannot be
  deleted."*
- **A host rule can only NARROW, never revive.** `countriesFor()` starts from
  `Country::where('is_active', true)` and only then applies the pivot when `!all_markets`, so
  a globally hidden market cannot be brought back by a hostname rule. The global switch above
  is always the ceiling.
- **Every write bumps a cache key.** `MarketSiteRepository` calls `MarketSiteResolver::touch()`
  after create, update and delete, invalidating `market-sites:version`. ⚠️ **A migration or raw
  SQL that edits these tables directly will not bump it**, and the old host mapping keeps
  serving until the cache expires.

> `market_sites` is **not** the same thing as `sites` / `site_catalog_projects`, despite the
> names. That pair is about which catalogue records a whole DEPLOYMENT lists; this one is about
> which countries a HOSTNAME may show. See
> [project-catalogue/neighbours-and-legacy.md](/docs/modules_handbook/shared/project-catalogue/neighbours-and-legacy.md) §4.

## Related files

- `src/Common/MarketSite.php`, `src/Common/Services/MarketSiteResolver.php`,
  `src/Common/Repositories/MarketSiteRepository.php`
- `app/Http/Controllers/Manage/Setting/MarketsController.php`,
  `app/Http/Requests/Manage/Setting/UpdateMarketRequest.php`
- `src/Common/Repositories/CountryRepository.php` (`setActive`), `src/Common/Country.php`
- `resources/js/Pages/Manage/Setting/Markets/Index.vue`, `Components/SettingTabs.vue`
- `app/Http/Middleware/SetPublicSite.php` — the middleware the flag actually drives
- `app/Http/Controllers/Main/SiteController.php` (`region`) — its fallback landing
  must be an active market too
- `tests/Feature/Manage/Setting/MarketsTest.php`
