# SEO / OpenGraph (Shared · `App\Support\Seo`)

**Context:** Shared library (not a portal module) · **Routes / UI:** `/sitemap.xml`, `/og-image/{uuid}` (public), funnel *Landing* tab share-image card (Manage) · **Used by:** the public site (`SiteController`), funnel + slot landings (`LandingController`), login (`LoginController`) — designed to be attached by any controller whose page should be indexable/shareable.

## What it does

A backend-driven foundation for **per-page SEO and social-share metadata**. A controller builds a fluent `App\Support\Seo` object (title, description, canonical, og:image, robots, JSON-LD) and attaches it to its Inertia response; a Blade partial renders the full tag set **server-side** in the initial HTML — which is the only HTML crawlers and WhatsApp/Facebook share-scrapers ever read. Pages that attach nothing render **fail-closed** as `noindex, nofollow`, so a forgotten page can never leak into search results.

Around the value object sit four related pieces:

- **`/sitemap.xml`** — dynamic, cached 1 h: active countries × (`/{iso2}/home`, `/{iso2}/new-projects`) + every active funnel `/{slug}` + active slot `/{funnel}/{slot}`.
- **`/og-image/{uuid}`** — stable public URL streaming a funnel's uploaded share banner (media lives on a **private** GCS bucket whose signed URLs expire in minutes — those must never appear in meta tags). Scoped to the `og_banner` collection only.
- **Funnel share-image upload** — Manage → funnel Show → *Landing* tab; slots inherit the funnel's banner; everything else falls back to `public/images/og/default.png` (1200×630).
- **Inertia SSR** for the public pages (see [DEPLOY.md](/docs/DEPLOY.md) *Inertia SSR* section) — full-page HTML for search engines. The meta tags do **not** depend on it.

## How it works

- **Single source of truth per tag type.** Blade renders description/robots/canonical/og:\*/twitter:\*/JSON-LD from the `Seo` object (`resources/views/partials/seo-meta.blade.php`, included by `app.blade.php`). Vue pages own **only** `<title>` via Inertia's `<Head>` — they must never emit description/OG tags (duplicates would confuse scrapers). The `Seo` title suffix (`{title} · {appName}`) mirrors the title callback in `resources/js/app.js` so `og:title` matches the visible tab title.
- **Transport:** `Inertia::render(...)->withViewData(['seo' => $seo])` — merged into the root Blade view, and only evaluated on full page loads (exactly when crawlers/scrapers fetch). Client-side Inertia navigations don't refresh the tags; that's intentional and harmless (scrapers always full-load).
- **Defaults layer:** `config/seo.php` — site description, default og:image, `og:locale` map, optional `twitter:site` (env-overridable: `SEO_DEFAULT_DESCRIPTION`, `SEO_DEFAULT_IMAGE`, `SEO_TWITTER_SITE`).
- **JSON-LD:** `App\Support\StructuredData` builds schema.org nodes (`organization()`, `webSite()`, `event()` — dated by a slot's next upcoming session, dates omitted when none exists). Appended via `Seo::jsonLd()`; the partial renders one `<script type="application/ld+json">` each.
- **hreflang is deliberately absent:** the site's locale is cookie-resolved (no per-locale URLs — `?lang=` 302s back to the clean URL), so hreflang annotations would violate Google's stable-URL requirement. `og:locale`, `<html lang>` and self-referencing canonicals cover it.
- **robots.txt** (`public/robots.txt`, static): Disallows the private areas, deliberately does **not** disallow `/login` (its noindex meta must stay fetchable), and carries the `Sitemap:` line — **point it at the real production domain before go-live**.
- **Sitemap cache:** `Cache::remember('sitemap.xml', 1 h)`. Funnel/slot writes don't invalidate it — a ≤1 h staleness window is invisible to crawlers.

## Reference usage — slot landing (`LandingController@slot`)

The canonical consumer. Build the object fluently, attach with `withViewData`:

```php
use App\Support\Seo;
use App\Support\StructuredData;

$canonical = url("/{$funnel->slug}/{$slot->slug}");
$image = ($banner = $funnel->ogBanner()) ? route('main.og-image', $banner->uuid) : null;

$seo = Seo::make()
    ->title($slot->title)                                   // og:title = "{title} · {appName}"
    ->description($slot->description ?: $funnel->description) // strip_tags + 300-char limit applied
    ->canonical($canonical)
    ->image($image)                                          // null → config('seo.image') default
    ->jsonLd(StructuredData::event($slot, $nextSession, $canonical, $image ?? url(config('seo.image'))));

return Inertia::render('SlotLanding', [...])->withViewData(['seo' => $seo]);
```

For a page that must never be indexed: `Seo::make()->title('Sign in')->noindex()` (see `LoginController@create`). For everything else, attach nothing — the partial falls back to `Seo::private()` (noindex).

**Rules for consumers:**
1. `Seo::image()` must be a long-lived public URL — the `/og-image/{uuid}` route or a `public/` asset. **Never** `MediaService::temporaryUrl()`.
2. Don't add `<meta name="description">` / `og:*` tags in Vue `<Head>` — Blade owns them.
3. New indexable public URLs should also be added to `SitemapController`.
4. New top-level public routes must be added to `Funnels\StoreRequest::RESERVED_SLUGS` so a funnel slug can't shadow them (`og-image` and `sitemap` already are).

## Related files

**Backend**
- `app/Support/Seo.php` — the fluent value object (+ fail-closed `Seo::private()`)
- `app/Support/StructuredData.php` — schema.org JSON-LD builders
- `config/seo.php` — defaults (description, default image, og:locale map)
- `app/Http/Controllers/Main/SitemapController.php` — `/sitemap.xml`
- `app/Http/Controllers/Main/OgImageController.php` — `/og-image/{uuid}` streaming
- `app/Http/Controllers/Manage/Events/FunnelsController.php` — `storeOgBanner` / `destroyOgBanner` (+ `og_banner_url` in `transform()`)
- `app/Http/Requests/Manage/Events/Funnels/OgBannerRequest.php` — upload validation (1200×630 min, 2 MB)
- `src/Event/EventFunnel.php` — `media()` morph + `ogBanner()` helper
- `app/Ssr/PublicPagesGateway.php` — SSR whitelist gateway (`config/inertia.php` → `ssr.only`)

**Frontend**
- `resources/views/partials/seo-meta.blade.php` — the tag renderer (included by `resources/views/app.blade.php`)
- `resources/js/Pages/Manage/Events/Funnels/Partials/Tabs/LandingTab.vue` — share-image upload card
- `resources/js/ssr.js` — Inertia SSR entry
- `resources/js/ssr-stubs/leaflet.js` — inert Leaflet stand-in aliased into the SSR bundle by `vite.config.js` (Leaflet touches `window` at import time, which crashes the Node daemon; maps init in `onMounted`, which never runs server-side)

**Routes**
- `routes/main.php` — `main.sitemap` (`GET sitemap.xml`), `main.og-image` (`GET og-image/{uuid}`, declared before the funnel catch-alls)
- `routes/web.php` — `manage.events.funnels.og-banner.store` / `.destroy`

**Assets / ops**
- `public/images/og/default.png` — site-wide 1200×630 fallback share image (placeholder — replace with a branded asset)
- `public/robots.txt` — disallows + `Sitemap:` line
- `scripts/server-setup.sh` / `scripts/deploy-update.sh` / `docs/DEPLOY.md` — SSR daemon + build wiring
