# Frontend Migration Plan — Blade → Next.js

**Read `../README.md` first**, then `../backend/MIGRATION_PLAN.md`. This document
assumes the invariants (I1–I10), the architectural decision in §6 of the README,
and the API contract defined in the backend plan.

**Audience:** internal. Frank about cost and risk.

---

## 1. Scope

Rebuild the entire public website as a Next.js application consuming the Laravel
API. Nothing of the current frontend survives as code — but almost all of it
survives as **specification**.

| What moves | Size |
|---|---|
| Blade templates → React components | 76 files |
| Section blocks → component registry | 24 blocks |
| Blade components → React components | 18 |
| `resources/css/site.css` | 144 KB, hand-written |
| `resources/js/site.js` | 62 KB, hand-written |
| `lang/{ar,en}/*.php` (ui, forms, js, projects) | ~140 public-facing keys |
| SEO head rendering | from `PageMeta` |
| Sitemap + robots XML rendering | 2 templates |

**This is a rewrite, not a port.** Treat the Blade templates as the reference
implementation: read them, match their output, do not try to translate them
mechanically.

---

## 2. Stack

| Concern | Choice | Note |
|---|---|---|
| Framework | Next.js, App Router | Server Components by default |
| Language | TypeScript, strict | The API contract becomes types |
| Rendering | ISR everywhere; SSR where noted | `../README.md` §7 |
| Styling | **Port `site.css` as-is first**, refactor later | §9 |
| Client JS | Port the existing modules | §10 |
| i18n | Custom, driven by the URL | §8 — no library needed |
| Data | `fetch` with Next cache tags | §6 |
| Testing | Vitest + Playwright | §16 |

### 2.1 On Tailwind

The current site uses Tailwind 4 for the Filament panel only. The public site is
144 KB of hand-written CSS with a real design language, 29 rules scoped to
`data-page` on `<body>`, and named responsive breakpoints that `config/images.php`
depends on matching.

**Do not rewrite it in Tailwind as part of this migration.** Porting the design
and changing the styling paradigm at the same time makes every visual difference
ambiguous — is that a port bug or a design decision? Ship the CSS as-is, get
parity, then refactor if anyone still wants to.

---

## 3. Routing

### 3.1 The catch-all

The current system has **exactly one** page route: a catch-all at the bottom of
`routes/web.php`, with locale derived from the path. Next.js mirrors it:

```
app/
├── [[...slug]]/page.tsx        ← every content page
├── sitemap.xml/route.ts
├── sitemap-[section].xml/route.ts
├── robots.txt/route.ts
└── api/revalidate/route.ts
```

No per-type route directories. No `/projects/[slug]` beside `/[slug]`. The whole
point of the `url_paths` scheme is that a page, a project and an article can all
sit directly beneath the domain root and cannot collide — recreating type-based
route folders throws that away and reintroduces the ordering bugs it was built to
prevent.

`[[...slug]]/page.tsx` joins its segments, calls
`GET /api/v1/{locale}/resolve?path=...`, and switches on the returned `template`
field.

### 3.2 Arabic UTF-8 paths — hard problem H1

This is the highest-risk item in the entire frontend and it must be settled in
**week one**, not discovered in month three.

The situation:

```
Home        https://almashria.com/            https://almashria.com/en
Pages       /مشاريعنا                          /en/projects
Articles    /سلوك-المستثمر                     /en/investor-behaviour
Projects    /portfolio-item/{slug}             /en/portfolio-item/{slug}
```

Paths are stored **raw UTF-8** in the database and percent-encoded exactly once,
at render time, by `UrlBuilder::encodeSegment()` — which uses `rawurlencode()`
because RFC 3986 calls uppercase hex the normal form.

Next.js normalises and decodes route params on its own terms, and the behaviour
is not identical between `next dev`, `next start`, and a CDN in front of it.

**Required on day one — a test fixture with real Arabic slugs that asserts:**

1. `params.slug` decodes to the exact raw UTF-8 stored in `url_paths`
2. The value sent to the API is that raw string, encoded once for the query
3. Rendered internal links come back **byte-identical** to what `UrlBuilder`
   produces — uppercase hex, no double-encoding
4. `/مشاريعنا` and `/%D9%85%D8%B4%D8%A7%D8%B1%D9%8A%D8%B9%D9%86%D8%A7` resolve to
   the same page and canonicalise to the same single form
5. Behaviour is identical in dev, in production build, and behind the CDN

If any of these cannot be made to hold, that is a finding worth escalating before
the rest of the build proceeds.

### 3.3 Locale

Arabic is the default and is served at the **domain root with no prefix**.
English is at `/en`. From `config/locales.php`.

Consequence: **do not use Next's built-in i18n routing.** It assumes a prefix per
locale or a default-locale-without-prefix mode that does not compose with a
catch-all carrying raw UTF-8. Derive the locale by inspecting the first segment
exactly as `UrlBuilder::splitLocale()` does — if it is a supported non-default
locale, shift it off; otherwise the locale is the default and the whole path is
content.

Invariant I3: locale comes from the URL and nothing else. **No
`Accept-Language` detection, no cookie, no automatic redirect on first visit.**
A URL must render the same content for a crawler as for a returning visitor.
This will feel wrong to a frontend developer used to locale detection — it is
deliberate.

### 3.4 Status codes

| Case | Next |
|---|---|
| API 404 | `notFound()` → `not-found.tsx` |
| Redirect (from manifest/middleware) | `redirect(target, 301)` |
| Tombstone | 410 — needs a custom response, `notFound()` cannot express it |
| `?page=1` | 301 to bare URL, preserving other query params (I9) |
| `?page=0/-3/abc`, page past last | 404 (I9) |

**410 is not expressible through `notFound()`.** It needs an explicit `Response`
with status 410 from middleware or a route handler. Deleting a record converts
its paths to 410 today, and a 410 that degrades to a 404 loses the signal that
tells Google to drop the URL rather than keep retrying it.

---

## 4. Rendering strategy

Adopt `../README.md` §7. Summary:

| Route | Mode | Revalidate |
|---|---|---|
| Home | ISR | 5 min |
| Projects listing, bare | ISR | 15 min |
| Projects listing, filtered/paginated | **SSR** | — |
| Project detail | ISR | 15 min |
| Articles / News listing | ISR | 15 min |
| Article detail | ISR | 15 min |
| Services, About, generic pages | ISR | 1 h |
| Media / videos | ISR | 1 h |
| Contact | ISR + client-side POST | 1 h |
| 404 / 410 / redirects | SSR | — |

Two deviations from the client's proposal worth being able to defend:

- **Nothing is SSG.** Every page is CMS-managed in Filament. SSG means a content
  edit needs a deploy, which is unacceptable for a client-operated panel. ISR
  plus on-demand revalidation gives the same delivered performance with none of
  that.
- **Filtered listings are SSR.** Four dimensions × up to 20 values × 5 sorts ×
  pagination is a combinatorial space. It cannot be pre-rendered, and caching
  every combination poisons the cache with long-tail entries nobody requests
  twice. The bare listing is the crawl entry point and it is the one that gets
  ISR.

`generateStaticParams` pre-builds only the highest-value set: home ×2 locales,
the bare listings, and published project and article details. Everything else is
generated on first request.

---

## 5. Page templates

Driven by `template` from the API (`../backend/MIGRATION_PLAN.md` §4.1). The
frontend does **not** re-derive `Page::KEY_*` logic.

| `template` | Component | Source Blade |
|---|---|---|
| `home` | `HomePage` | `pages/home.blade.php` |
| `projects` | `ProjectsListing` | `pages/projects.blade.php` |
| `posts-index` | `PostsListing` | `pages/posts-index.blade.php` |
| `media` | `MediaPage` | `pages/media.blade.php` |
| `show` | `GenericPage` | `pages/show.blade.php` |
| `project` | `ProjectDetail` | `projects/show.blade.php` |
| `project-full` | `ProjectDetailFull` | `projects/show-full.blade.php` |
| `post` | `PostDetail` | `posts/show.blade.php` |
| `service` | `ServiceDetail` | `services/show.blade.php` |

Note projects have **two** templates — `usesFullTemplate()` picks between them
server-side. That decision stays in the API.

---

## 6. Data fetching

One `fetch` per page, server-side, in the Server Component. No client-side
content fetching except the per-request context (§13).

```ts
const res = await fetch(`${API}/v1/${locale}/resolve?path=${encodeURIComponent(path)}`, {
  next: { revalidate: 900, tags: [`path:${locale}:${path}`] },
});
```

Cache tags mirror what the backend's revalidation webhook sends
(`../backend/MIGRATION_PLAN.md` §9), so an editor's save invalidates exactly the
right entries.

The site chrome (`/site`) is fetched once per locale with a long revalidate and a
`site:{locale}` tag — it is shared across every page.

### 6.1 Types from the contract

Generate TypeScript types from the API contract and check them in. The contract
is frozen at the end of phase 0; the types are how the frontend gets compile-time
protection when the backend drifts from it.

---

## 7. The 24 section blocks

The largest frontend work item after the CSS.

```
about_intro      accordion        articles_grid    contact_band
contact_cta      contact_details  cta_band         feature_grid
guarantees       hero_video       intro_band       leaders
map              partners         post_gallery     projects_carousel
rich_text        services_detail  services_grid    split_band
statistics       subsidiaries     ticker           videos
```

### 7.1 Registry

```tsx
const SECTIONS = {
  about_intro: AboutIntro,
  accordion: Accordion,
  // ... 24 entries
} as const;

function Sections({ blocks }: { blocks: Block[] }) {
  return blocks.map((b) => {
    const C = SECTIONS[b.type];
    return C ? <C key={b.id} {...b.props} /> : null;
  });
}
```

An unknown `type` renders nothing rather than throwing — a block added in
Filament before the frontend ships must not take the page down. **Log it**, so
"nothing rendered" does not become a silent gap.

### 7.2 Approach

Each block's props arrive fully resolved (`../backend/MIGRATION_PLAN.md` §6) —
images with `srcset`, relations expanded, URLs finished, rich text pre-processed.
The React component is presentational.

Read the Blade template for each block and match its DOM and class names exactly.
The CSS is being ported unchanged (§9), so a changed class name is a broken
layout — and it will be broken in a way that looks like a CSS port bug rather
than a component bug.

### 7.3 Effort

24 blocks × (read Blade + build component + match DOM + verify against live) is
**3–4 weeks**. Several are non-trivial: `projects_carousel` carries the drag
handler (§10), `hero_video` and `map` embed third-party frames with CSP
implications, `ticker` and `accordion` have real interaction.

---

## 8. i18n and RTL

### 8.1 Strings

Public-facing strings live in `lang/{ar,en}/`: `ui.php` (79 keys), `projects.php`
(39), `forms.php` (11), `js.php` (11). `admin.php` (295) stays in Laravel — it is
the panel.

Export the four public files to JSON at build time from the Laravel repo, so
there is one source of truth and the translation manager in Filament keeps
working. **Do not hand-copy them** — they will drift on the first edit.

### 8.2 RTL

Arabic is `dir="rtl"`, English `dir="ltr"`, from `config/locales.php`. Set `dir`
and `lang` on `<html>` from the resolved locale.

The existing CSS already handles RTL. Port it as-is (§9) — do not "improve" it to
logical properties during the migration. That is a separate, later refactor.

### 8.3 No content fallback

Invariant I4. A record with no Arabic title is not available in Arabic: the URL
404s and no hreflang alternate is emitted. The API enforces this; the frontend
must not paper over a missing field with the other locale's value or with an
empty string that renders as a blank heading.

### 8.4 Dates

`date_format` is `j F Y` for both locales, from `config/locales.php`. Format
server-side in PHP and ship formatted strings, or replicate exactly. Do not use
`Intl.DateTimeFormat` with a locale guess — Arabic date rendering varies by
calendar and numeral system, and a mismatch is immediately visible.

---

## 9. The CSS port

144 KB of hand-written CSS. The plan is deliberately boring:

1. **Copy `site.css` verbatim** into the Next app.
2. Import it once in the root layout.
3. Match DOM and class names component by component until the site looks right.
4. Only then consider refactoring.

### 9.1 `data-page` on `<body>` — load-bearing

**29 rules in the stylesheet are scoped to `[data-page="..."]` on `<body>`.**
The current layout sets it per page. The Next root layout must set the identical
attribute with the identical values, or 29 rules silently stop applying.

This is exactly the kind of detail that survives a rewrite only if someone writes
it down. It is written down here and in `../README.md` §5.

### 9.2 Breakpoints

The `sizes` values in `config/images.php` (`992px`, `640px`) match breakpoints in
`site.css`. If the CSS is ported unchanged they stay in sync for free. If anyone
changes a breakpoint, `config/images.php` changes with it — they are one system.

### 9.3 `popup.css`

Separate 8 KB file, loaded with the popup subsystem. Keep it separate and lazy —
it is only needed when a popup exists for the current URL (§13).

---

## 10. The JavaScript port

62 KB of `site.js`, plus two modules that already have their own Vitest suites:

| Module | Note |
|---|---|
| `drag-gesture.js` (2.4 KB, tested) | Carousel drag. **Binds to `.carousel__viewport`** — without that class the carousel throws. Load-bearing, per `../README.md` §5. |
| `lead-links.js` (3.9 KB, tested) | Lead link handling |
| `site.js` (62 KB) | Everything else — nav, accordion, ticker, gallery, filters, scroll behaviour |

### 10.1 Approach

- **Keep `drag-gesture.js` and `lead-links.js` as modules and keep their tests.**
  They are already isolated and already covered. Porting them to React hooks
  gains nothing and loses two working test suites.
- Break `site.js` apart by concern as its behaviours are absorbed into
  components. Most of it is imperative DOM code whose React equivalent is a
  `useEffect` in the owning component.
- Anything interactive becomes a Client Component. Keep the boundary as low in
  the tree as possible — the whole point of the SSR/ISR requirement is that the
  page is HTML before JS runs.

### 10.2 The client's first non-negotiable

> Next.js SSR/SSG/ISR وليس Client-Side Rendering

Correct and adopted. Enforce it with a check: **no page's meaningful content may
require JS to appear.** Test with JS disabled — every route must render its
content, its `<head>`, and its JSON-LD. Only the per-request context (§13) and
interaction may need JS.

---

## 11. Images — do not use `next/image`

**Restated from `../backend/MIGRATION_PLAN.md` §7.2 because this is the mistake a
Next developer makes by reflex.**

The API returns a finished image object: `src`, `srcset`, `sizes`, `width`,
`height`, `alt`. These point at **pre-generated static WebP derivatives** that
never touch PHP and are CDN-cacheable.

Pointing `next/image` at them:

- re-encodes files that are already optimised, at a second quality setting
- routes static assets back through a Node process — the exact problem the Glide
  server was rejected for (invariant I7)
- overrides a `srcset` that was generated from **recorded source dimensions** so
  it never claims a width the file lacks (invariant I8)

### 11.1 Do this instead

```tsx
<picture>
  <source type="image/webp" srcSet={img.srcset} sizes={img.sizes} />
  <img src={img.src} alt={img.alt} width={img.width} height={img.height}
       loading="lazy" decoding="async" />
</picture>
```

`width` and `height` are always present and always set — they are what prevent
layout shift, and CLS is part of the performance case for this whole project.

Above-the-fold hero images get `loading="eager"` and `fetchPriority="high"`.
Match what the current templates do; they are already tuned for LCP.

If `next/image` is genuinely wanted later, it must be behind a **pass-through
custom loader** that returns the pre-generated URL unchanged and `unoptimized`.
That is a refactor, not part of this migration.

---

## 12. SEO

The client's fourth condition: sitemap, robots, canonical, structured data,
metadata. All of it comes from the API (`../backend/MIGRATION_PLAN.md` §5) and is
rendered verbatim.

### 12.1 Metadata

`generateMetadata` maps `meta` onto Next's Metadata object. **Straight
assignment, no derivation.**

```ts
export async function generateMetadata({ params }): Promise<Metadata> {
  const { meta } = await resolve(params);
  return {
    title: meta.title,
    description: meta.description,
    alternates: { canonical: meta.canonical, languages: meta.alternates },
    robots: meta.robots ?? undefined,
    openGraph: { /* from meta.og* */ },
  };
}
```

If the frontend is ever tempted to compute a canonical, or fill in a missing
alternate, or default a title — that is invariant I1 and I4 breaking. The API is
the authority.

`meta.alternates` omits a locale when the record has no content in it (I4). Pass
the map through as-is; do not backfill.

### 12.2 JSON-LD

Render `meta.schemaJson` — the pre-encoded string — directly:

```tsx
<script type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: meta.schemaJson }} />
```

Do **not** `JSON.stringify(meta.schemaGraph)`. It re-orders keys and re-escapes
unicode, which changes nothing semantically but makes the parity diff (§16) noisy
for no reason. `SchemaGenerator` (726 lines, 30+ node types) stays the only
authority on the graph.

### 12.3 Sitemap and robots

Route handlers producing XML from the API's sitemap endpoints. Every URL,
`lastmod` and hreflang alternate comes from `SitemapBuilder` — the frontend
renders XML and computes nothing (invariant I1).

Do not use Next's `MetadataRoute.Sitemap` helper. It does not express hreflang
alternates, and this sitemap is a bilingual index with per-section files.

### 12.4 Pagination

`meta.prevUrl` / `meta.nextUrl`, and self-referential canonicals on paginated
listings (invariant I10). Page 2 of the news canonicalises to page 2.

---

## 13. Per-request context — hard problem H2

Popups, action bars and code snippets are resolved per URL and cannot live in
cached ISR HTML. From `GET /api/v1/{locale}/page-context?path=...`, marked
`no-store`.

| Subsystem | Where |
|---|---|
| Popups | Client Component, fetched after paint. Lazy-load `popup.css` with it. |
| Action bars | Same |
| **Code snippets** | **Next middleware, injected server-side** |
| Maintenance window | Next middleware |
| Active nav item | Derived client-side from `meta.canonical` vs menu URLs |

### 13.1 Code snippets

Injected at `<head>`, body-start and body-end today, and typically analytics or
marketing tags that must run early. Fetching them client-side after paint changes
when they execute and can break attribution.

Middleware injection with an edge-cached manifest, matching the redirect pattern.
See `../backend/MIGRATION_PLAN.md` §8.2.

**Verify tag firing order against the current site before cutover.** "Analytics
still works" is not the bar — the bar is that the same tags fire in the same
order at the same point in the page lifecycle.

### 13.2 Maintenance mode

Three escape hatches must survive: admin session, bypass cookie, IP allowlist.
All three must **fail open** for admins. The Laravel implementation's middleware
ordering hazard does not transfer, but the requirement does. Test all three.

---

## 14. Forms

`POST /api/v1/leads/{form}` — `contact`, `project_interest`, `home`.

- Progressive enhancement: the form posts and works with JS enabled; validation
  errors come back as 422 in Laravel's standard shape and render inline.
- **The success message depends only on the API's 2xx**, which itself depends
  only on the database write. An email or Odoo outage may delay a notification,
  never lose a lead, and never show the visitor an error for a lead that was
  saved.
- No CSRF token — there is no session. Origin allowlist plus the existing
  throttle is the control (`../backend/MIGRATION_PLAN.md` §11).
- Keep the honeypot / timing checks the current forms use.

---

## 15. Preview

Next **draft mode**, entered via the signed URL the Filament panel generates.
Draft mode bypasses the ISR cache, so an editor sees the current draft.

The preview response must carry `noindex` — the API sets `X-Robots-Tag`
(`../backend/MIGRATION_PLAN.md` §10) and the page must also emit the meta tag.
A shared or crawled preview URL serving indexable draft content is a live SEO
incident.

---

## 16. Testing and the parity harness

### 16.1 Unit and component

Vitest. Keep the two existing suites (`drag-gesture.test.js`,
`lead-links.test.js`) working — they are already written and already passing.

### 16.2 E2E

Playwright, against a real build. Minimum:

- Arabic UTF-8 paths round-trip (§3.2) — all five assertions
- `?page=1` 301s and preserves other query params
- `?page=0`, `-3`, `abc`, past-last all 404
- 410 tombstones return 410, not 404
- Every route renders its content with JS disabled (§10.2)
- All three maintenance escape hatches
- Lead submission success and 422 paths

### 16.3 The parity harness — the gate on cutover

**This is how "preserve SEO" becomes a measurable claim instead of a promise.**

1. **Phase 0, before any code changes:** crawl every URL in `url_paths` on the
   *current* Blade site. For each, record `title`, `description`, `canonical`,
   every `hreflang` alternate, `robots`, and the JSON-LD `@graph` node types.
   Commit that snapshot.
2. Run the same crawl against the Next.js build.
3. Diff.

**Zero diffs on canonical, hreflang, robots and JSON-LD `@type` is a hard gate on
cutover.** Title and description diffs are reviewed individually — some may be
deliberate improvements — but they are reviewed, not waved through.

Extend it with the redirect table: every row still 301s, every tombstone still
410s.

The baseline must be captured **before** the Blade layer is touched. Once it has
been modified it is no longer a baseline (`../backend/MIGRATION_PLAN.md` §12.3,
risk B7).

### 16.4 Performance

Lighthouse CI on home, a project detail, and the projects listing, in both
locales. Capture the **current site's numbers first** — the performance case for
this project should be verified, not assumed. If the new build is not faster,
that is worth knowing internally before the client asks.

---

## 17. Deployment

Open question for the client (`../README.md` §11): Vercel, or self-hosted
alongside PHP. It changes the middleware and ISR implementation materially, so it
must be answered before phase 3.

Either way:

- Next talks to Laravel over the internal network where possible; the API is not
  publicly routable unless it has to be
- `TrustProxies` on the Laravel side, or the lead throttle silently dies
  (`../backend/MIGRATION_PLAN.md` §11)
- The revalidation webhook secret in env on both sides
- CDN in front, honouring ISR cache headers
- Both apps' logs shipped somewhere joint — a request now spans two runtimes, and
  debugging across two unjoined log streams is miserable

---

## 18. Risks

| # | Risk | Mitigation |
|---|---|---|
| F1 | Arabic UTF-8 paths do not round-trip cleanly through Next + CDN. | §3.2, week one, five explicit assertions. Escalate before proceeding if unresolved. |
| F2 | Frontend constructs URLs. Invariant I1 dies. | §12.1. API is the authority; never derive a canonical. |
| F3 | `next/image` used on pre-generated derivatives. | §11. Stated three times across these documents on purpose. |
| F4 | `data-page` on `<body>` not reproduced; 29 CSS rules stop applying. | §9.1. |
| F5 | `.carousel__viewport` class dropped in a component rewrite; carousel throws. | §10, and a Playwright test that exercises the carousel. |
| F6 | CSS rewritten in Tailwind during the port; every visual diff becomes ambiguous. | §2.1. Port verbatim, refactor later. |
| F7 | Snippets moved client-side; analytics attribution breaks silently. | §13.1. Verify firing order before cutover. |
| F8 | Something needs JS to render, breaking the client's first condition. | §10.2. JS-disabled test on every route. |
| F9 | Sitemap built with Next's helper; hreflang alternates lost. | §12.3. |
| F10 | 410 degrades to 404. | §3.4. Explicit test. |
| F11 | Section count underestimated; the 24 blocks eat the schedule. | §7.3. Budget 3–4 weeks explicitly. |
| F12 | Parity harness written late and becomes a rubber stamp. | §16.3. It is a phase-0 deliverable and a hard cutover gate. |

---

## 19. Definition of done

- [ ] All 9 page templates rendering (§5)
- [ ] All 24 section blocks implemented and matched against Blade (§7)
- [ ] All 18 components ported
- [ ] `site.css` ported; `data-page` reproduced; site visually matches
- [ ] `drag-gesture` and `lead-links` tests still green
- [ ] Arabic path round-trip: all five assertions passing (§3.2)
- [ ] Every route renders with JS disabled (§10.2)
- [ ] Metadata, JSON-LD, sitemap, robots all sourced from the API — nothing derived
- [ ] Parity diff clean on canonical, hreflang, robots, JSON-LD `@type` (§16.3)
- [ ] Redirects: every 301 and 410 verified
- [ ] Lighthouse captured for both old and new; new is not slower
- [ ] Content verified against `docs/migration/almashria-content.sqlite`
      (`../README.md` §10 step 4)
