# FFS Content CMS — Developer Handoff Brief

Prepared for the developer who will implement the content-management layer for the Faith & Future Summit site.

**Repo:** https://github.com/abdullah-slayer/faithfuture (branch `main`)
**Stack:** Laravel + Blade + Filament 3, SQLite locally, MySQL on Hostinger.
**Current tip:** `4c22983` (home hero / about logo / feature cards / events grid admin-editable).
**Live:** `https://snow-echidna-796453.hostingersite.com/public/`

---

## 1. TL;DR

The client wants every home/site section to be admin-editable through **typed, schema-driven fields** — NOT a free-form page builder. We extend the existing `SiteSetting`/model idiom with `page_sections` + `section_items` tables and a `media` library, and the Filament admin renders each section's form from a per-section PHP schema declaration. Layout/design must not change.

Before the CMS layer is built, four live-site issues must be resolved. Three are already diagnosed (see §2). One (`/public/` in URLs) is a hosting-panel fix outside the app.

---

## 2. Live-site bugs to fix first

### 2.1 `/public/` in URLs — HOSTING CONFIG, not app code
- Evidence: the live URL itself is `https://snow-echidna-796453.hostingersite.com/public/` — Laravel requests go through `/public/` because the Hostinger document root points at the **project folder** instead of the `public` folder.
- Fix: in the Hostinger panel, set the document root to the `public` directory so requests hit `/` directly. No code change. Do **not** "fix" this by hacking routes or `asset()` helpers.
- After the fix, verify `https://snow-echidna-796453.hostingersite.com/` returns the site without `/public/`.

### 2.2 `twitter:site` meta tag — ALREADY FIXED locally
- Was printing the literal string `@App\Models\SiteSetting::get('seo_twitter_handle')`.
- Fixed in `4c22983`: `resources/views/layouts/site.blade.php:23` now reads `{{ App\Models\SiteSetting::get('seo_twitter_handle', '@ffsummit') }}`.
- Needs deploy only.

### 2.3 Home counters "render 0" — VERDICT: not a JS bug; one real edge case to fix
Investigated with headless Chrome against the **live** URL (script injected into a proxied copy of the live HTML; scrolled `#stats-left` into view):

- Result: `ANIMATED=0+,9+,166+,28K+,3+ | animated=true | statsObserver=object | startStatsCounters=yes | errs=none`
- The counters **do animate** when the section enters the viewport (`data-target` values 2/50/900/150/15 unchanged in the live HTML). The partial values (~18% of target) are an artifact of Chrome `--virtual-time-budget` (reproduced identically on an isolated 20-line page with the same JS); in a real browser the 2000 ms rAF loop completes.
- Explanation for the report: the server-rendered HTML intentionally contains `0` as the initial text of the count-up (`<span class="stat-counter" data-target="2" ...>0</span>`). Any raw-HTML inspection (`Invoke-WebRequest`, grep, curl) always shows `0`. This is normal for count-up animation.
- **Real edge case to fix:** the grid data source is `SiteSetting::get('home_stats', Stat::active()->map(...))`. The fallback only fires when the key is **missing**. If `home_stats` exists as an empty JSON array `[]` (e.g. someone saved the Filament settings repeater with zero rows), the grid renders **empty** — no counters at all. The local DB currently has `home_stats = []`, which is exactly this state.
  - Fix: treat `[]` / empty array as "use fallback" (or seed `home_stats` in `SiteContentSeeder`). Apply the same rule to `home_benefits`, `home_faqs`, etc.
  - Do not remove the count-up; it is desired behavior.

### 2.4 Duplicate "defense-platform" benefit cards — SEEDER BUG, confirmed
- `database/seeders/SiteContentSeeder.php:102-104` seeds **two identical rows** `Preemptive Risks Scouting and Reactions` (sort_order 0 and 2) with copy about "defense platforms observing bandwidth streams" — unrelated to a faith summit.
- Must be replaced with real faith-summit benefit copy (or neutral placeholders) before any re-seed. The Key Benefits section reads `home_benefits` from `SiteSetting` or falls back to the `Benefit` table.

---

## 3. Architecture decisions

The client's prompt asked for `page_sections`, `section_items`, and a `media` library, with "sections" modeled as typed fields. Our approach, agreed with the client:

1. **Schema-driven, not a builder.** Each section gets a PHP schema class/config declaring typed fields. No drag-and-drop or free-form blocks — those break the fixed layout. If a requested edit would change visual output, stop and flag it instead.
2. **Extend the existing `SiteSetting` idiom.** Round 1 already made home hero/about/events editable via `SiteSetting` + model tables (`Benefit`, `Stat`, `MediaItem`, `Event`, `Speaker`, `HomeFeatureCard`, `Faq`). The CMS layer normalizes through these existing tables and settings rather than starting a parallel system.
3. **New tables** (per client prompt):
   - `page_sections` — `page` (string: `home`, `events`, etc.), `section_key` (unique per page), `title`, `sort_order`, `is_active`, `content` (JSON).
   - `section_items` — repeatable rows, FK to `page_section`, `sort_order`, `is_active`, `content` (JSON).
   - `media` library — `disk`, `path`, `original_name`, `mime`, `width`, `height`, `size`, `alt_text`. Images are referenced by media-library ID, never a hardcoded path.
4. **Filament admin renders each section form from its schema.** One page per site page, plus a media library page. No per-field hand-written forms.
5. **Caching & audit:** cache rendered section data per page; flush on save; audit log of who changed what/when.

### Field types (per section schema)
`text`, `textarea`, `richtext`, `image`, `video`, `url`, `number`, `icon`, `boolean`, `repeater` — each with: key, label, default, validation rules, and (for images) recommended dimensions.

---

## 4. Section map (home page + global)

Current home structure in `resources/views/home.blade.php` (section numbers match the Blade comments). Global chrome lives in `resources/views/layouts/site.blade.php`.

| # | Section key | Current source | Typed fields to expose |
|---|---|---|---|
| — | `header` (global) | `layouts/site.blade.php` | logo (image → media), nav links (repeater: label+url), "Get Tickets" CTA visibility, announcement/badge bar text |
| 1 | `hero` | `SiteSetting`: `hero_video`, `hero_badge`, `hero_words`, `hero_sub_words`, `hero_btn_primary_text/link`, `hero_btn_secondary_text/link` | video (media), badge (text), **heading + subheading as single-line text** (typed out char-by-char, NOT word-by-word repeaters), 2 buttons (text+url); scroll-hint on/off |
| 2 | `globe` (Global Vision) | `SiteSetting`: `label_global_vision`, `globe_typewriter`, globe point labels (Faith/Ethics/Leadership) | label (text), typewriter line (text), globe point labels (repeater text), Lottie animation path (video/url) |
| 3 | `upcoming_event` | `$upcoming` from `Event` (section hidden if none) | **source: "existing Event record" OR "manual"** (title, excerpt, date, venue, banner→media, buttons). Manual mode must not drift from the Event table. |
| 4 | `about` | `SiteSetting`: `about_heading`, `about_quote`, `about_logo`, `about_description_1/2` + `HomeFeatureCard` ×3 | heading (text), quote (textarea), logo (image), descriptions (textarea ×2), feature cards (repeater: image + description) |
| 5 | `stats` (By the Numbers) | `SiteSetting`: `stats_heading`, `stats_description`, `home_stats` / `Stat` table | heading (text), description (textarea), stats (repeater: label, value number, suffix). **Fix the empty-`[]` fallback here (§2.3).** |
| 6 | `vision` | `SiteSetting`: `vision_label/heading/description` + **hardcoded YouTube iframe** (`3gQRkO9-uAA`) | label/heading/description (text), video URL (url) — currently hardcoded, should become a field |
| 7 | `benefits` | `SiteSetting`: `label_benefits`, `home_benefits` / `Benefit` table | heading (text), benefit rows (repeater: icon, title, description). Replace the duplicate seeder rows first (§2.4). |
| 8 | `speakers` | `Speaker::where('is_featured', true)->orderBy('sort_order')` | eyebrow (text), heading (text), description (text), **speaker multi-select** (ordered) from `Speaker` + manual sort. Link to `speakers.index`. |
| 9 | `past_gallery` | `SiteSetting`: `label_legacy`, `label_past_summits`; `Event::published()->take(3)` | eyebrow/heading (text), pick 3 past events (select from `Event`). Manual vs existing-record same rule as `upcoming_event`. |
| 10 | `support` | `SiteSetting`: `label_support`, `support_heading`, `support_description`; **3 hardcoded cards** (Donation / Partner / Advertisers) with popups | eyebrow/heading/description (text), card repeater (icon, title, description, button label, button action: donate popup / partner popup / url) |
| 11 | `media_resources` | `SiteSetting`: `label_resources`, `label_media_resources`; `MediaItem::ordered()->take(3)` | labels (text), **source: "latest N from MediaItem" (N number) OR manual picks**. Link targets per type already in `MediaItem`. |
| 12 | `cta_faq` | `SiteSetting`: `cta_heading`, `cta_button`; `home_faqs` / `Faq` table | CTA heading (textarea), CTA button (text), FAQ rows (repeater: question, answer). |
| — | `footer` (global) | `layouts/site.blade.php` | columns (repeater: heading + links), social links, newsletter form copy, copyright text |
| — | `modals` (global) | Ticket / Donation / Partner popups in layout + `routes/web.php` | enable/disable + copy per modal; form config stays with the dev |

Sections 3 and 11 must offer "use existing record" vs "manual" so data doesn't drift in three places (§5.2).

---

## 5. Data model & schema rules

### 5.1 JSON storage
- `page_sections.content` and `section_items.content` are JSON keyed by field key, decoded by the schema.
- `SiteSetting::get($key, $default)` JSON-decodes values beginning with `{` or `[`; anything else returns the raw string. Empty `[]` is returned as `[]`, NOT the default — this is the §2.3 bug; when reading repeaters treat empty/`[]` as "use fallback".
- `SiteSetting::get/set/group` helpers are the single source pattern; extend them, do not build a parallel system.

### 5.2 Data normalization (client concern: no 3-way drift)
- `upcoming_event`, `past_gallery`, `media_resources` can pull from existing `Event`/`MediaItem` records **or** manual entry. When manual, the schema must include the option and the section item must reference the canonical record where one exists.
- Featured speakers: multi-select from `Speaker` (with manual sort), never duplicate speaker content into a settings blob.
- Images: always via the `media` library ID. Never commit a hardcoded path as the long-term source.

### 5.3 Uploads
- WebP + responsive sizes on upload; strip EXIF; enforce max file size; **alt text required**.
- Existing uploads already use a `public_images` disk (`config/filesystems.php`, root `public_path('images')`) and are served via `asset('storage/'.$path)` + a storage fallback route in `routes/web.php`. Migrate new uploads to the `media` library.

### 5.4 Admin form behavior (Filament)
- Repeaters need `->defaultItems(5)` so editors don't start at zero rows.
- `->sanitize()` in Filament 3 is a whitelist approach; plain textarea sanitizes differently from richtext. Decide the richtext sanitizer strategy per field (whitelist tags), do not ship raw HTML into Blade.
- Draughts + preview URL required; audit log of who changed what/when.

---

## 6. Verification workflow (use this locally)

- PHP CLI is only reachable via full path: `& "D:\xampp\php\php.exe"`. `node` is NOT installed. PHP's built-in server works for smoke tests (`php artisan serve --port=8899`).
- Headless Chrome IS available at `C:\Program Files\Google\Chrome\Application\chrome.exe` for JS verification (`--headless=new --dump-dom`). Note: `--virtual-time-budget` throttles rAF — do not conclude an animation is broken from a partial-value dump.
- After schema changes: `& "D:\xampp\php\php.exe" artisan migrate --force`, `& "D:\xampp\php\php.exe" artisan db:seed --class=SiteContentSeeder`, then smoke-test `/` and `/events` for 200 with expected markup.

---

## 7. Deploy checklist (client side, for current live fixes)

On Hostinger (MySQL):

```
git pull origin main
php artisan migrate --force
php artisan db:seed --class=SiteContentSeeder   # after §2.4 benefit rows are fixed
php artisan config:cache && php artisan route:cache && php artisan view:cache
```

Then fix the document root in the Hostinger panel (§2.1) and confirm `twitter:site` renders the handle (§2.2, already in code).

---

## 8. Constraints (non-negotiable)

1. Do not restyle/redesign. If a change would alter visual output, stop and flag it instead of shipping it.
2. No free-form page builder. Typed fields only.
3. Never commit secrets; never hardcode a path that the media library should own.
4. Local = SQLite, Hostinger = MySQL. Run both migration + seeder locally before pushing.
