# Phase 7: Frontend Menu Rendering

## Goal
Update the live website's Header and Footer components so they dynamically render their links from the `navigation_items` database table instead of being hardcoded in the React component code.

## Important Context
> [!CAUTION]
> **DO NOT modify the Neon database schema.** All tables are already prepared.

> [!WARNING]
> **CRITICAL RULE: Do not delete or rewrite the Header/Footer components.** You must only change how they fetch their data. The visual design (CSS, layout, colors) must stay exactly the same. Only the data source changes.

---

## Requirements

### 1. The Data Layer (`lib/navigation.ts`)

The file `lib/navigation.ts` is the single source of truth for navigation data. It must contain:

```typescript
const SITE_ID = process.env.SITE_ID || 'your-site-id';

// Used by the LIVE FRONTEND — active items only
export async function getNavigationItems(menuLocation: MenuLocation): Promise<NavigationItem[]> {
  return await db.select().from(navigationItems).where(
    and(
      eq(navigationItems.siteId, SITE_ID),
      eq(navigationItems.menuLocation, menuLocation),
      eq(navigationItems.isActive, true),
    )
  ).orderBy(asc(navigationItems.sortOrder), asc(navigationItems.createdAt));
}

// Used by the ADMIN Navigation Manager — all items including hidden ones
export async function getAllNavigationItems(): Promise<NavigationItem[]> {
  return await db.select().from(navigationItems)
    .where(eq(navigationItems.siteId, SITE_ID))
    .orderBy(asc(navigationItems.menuLocation), asc(navigationItems.sortOrder), asc(navigationItems.createdAt));
}

// Converts a flat DB list into a nested tree using parent_id
export function buildMenuTree(items: NavigationItem[]): NavTreeNode[] {
  const map = new Map<number, NavTreeNode>();
  const roots: NavTreeNode[] = [];
  for (const item of items) { map.set(item.id, { ...item, children: [] }); }
  for (const item of items) {
    const node = map.get(item.id)!;
    if (item.parentId && map.has(item.parentId)) {
      map.get(item.parentId)!.children.push(node);
    } else {
      roots.push(node);
    }
  }
  return roots;
}
```

### 2. Replace Hardcoded Links with a Database Fetch

For each menu area, switch from hardcoded arrays to fetching from `navigation_items`:

```
top_header   → getNavigationItems('top_header')
category_bar → getNavigationItems('category_bar')
footer       → getNavigationItems('footer')
```

After fetching, run through `buildMenuTree()` to get the nested structure for rendering dropdowns.

> [!WARNING]
> **Next.js Caching:** By default, Next.js caches server-side data fetches. If you do not disable caching, the live website may show stale menu data for several minutes after the admin saves changes.
>
> Always add `export const revalidate = 0;` (or `dynamic = 'force-dynamic'`) to server components that fetch navigation data:
> ```ts
> // In app/layout.tsx or any server component fetching nav items
> export const revalidate = 0;
> // OR
> export const dynamic = 'force-dynamic';
> ```
>
> For fetch-based calls inside a server action or route handler, add:
> ```ts
> const items = await fetch('/api/navigation-items', { cache: 'no-store' });
> ```

### 3. Defensive Rules for `buildMenuTree()`
- If a child item points to a missing parent ID, render it as a root item safely (do not crash).
- Never show items from another `site_id`.
- Never show `is_active = false` items on the live frontend (only in the admin manager).

### 4. Dropdown Rendering Rules

**Item WITH children:**
```
Desktop: Hover parent → dropdown opens showing child links
         Click parent → opens the parent's own URL
```
**Item WITHOUT children:**
```
Desktop: Hover parent → nothing opens (no chevron shown)
         Click parent → normal link behavior, opens URL
```

> [!IMPORTANT]
> Only show the chevron/arrow indicator (▾ or `<ChevronDown>`) next to items that actually have children. Do not show it on plain leaf links.

### 5. Desktop UX Rules
- Use **CSS hover** (`group-hover:` in Tailwind / `group:hover` pattern) to open and close dropdowns.
- Use proper `z-index` so dropdowns never appear behind other page sections.
- Parent items with children remain **clickable** — they navigate to their own URL on click.

### 6. Mobile UX Rules
Mobile has no hover, so use a **tap/click** model:
```
Tap the arrow (▾) beside the parent → expand/collapse children
Tap the parent label               → open the parent's URL
```

If split-tap behavior is complex, use this fallback inside the expanded dropdown:
```
Parent Category
  → View All (links to parent URL)
  → Child Link 1
  → Child Link 2
```

This fallback is already implemented in the existing `MobileMenu.tsx` component. Do not rewrite it.

### 7. Site Isolation
> [!IMPORTANT]
> Every database query MUST include a `site_id` filter. `getNavigationItems()` and `getAllNavigationItems()` in `lib/navigation.ts` already handle this via the `SITE_ID` constant. Never call raw DB queries without the site_id filter.

### 8. Backward Compatibility
Do not remove old fields like `custom_pages.show_in_header` or `custom_pages.show_in_footer`. These may still be referenced elsewhere. Mark them as legacy in comments if needed, but never delete them without explicit approval.

### 9. Seed Core Pages Before Removing Hardcoded Links

> [!CAUTION]
> **Before removing any hardcoded links from components (Header, Footer), you MUST first verify that those links already exist as rows in the `navigation_items` table.** If they do not exist, add them first. Only after confirming they render correctly from the database should you remove the hardcoded versions.

**Common hardcoded links that need seeding:**
- 運営元について (About Us) → `/about-us` → `menuLocation: 'footer'`
- プライバシーポリシー (Privacy Policy) → `/privacy-policy` → `menuLocation: 'footer'`

**Verification steps:**
1. Query `navigation_items` for the target `menuLocation` and confirm the items exist.
2. If missing, insert them via `POST /api/navigation-items` with `itemType: 'custom_page'`, `isActive: true`.
3. Refresh the live site and confirm the links render correctly from the database.
4. **Only then** remove the hardcoded `<Link>` elements from the component code.

If you skip this step and just delete the hardcoded links, the footer/header will silently lose those links with no error — they simply won't appear.

---

## Common Gotchas & Bug Fixes

1. **Missing Footer Links After Migration:** If you remove hardcoded footer links (About Us, Privacy Policy) without first seeding them into `navigation_items`, they will silently disappear. Always verify the database has the items before removing hardcoded code. See Section 9.

2. **`revalidate` Must Be Set on Layout:** The Header and Footer are rendered in `app/layout.tsx`. If `layout.tsx` does not have `export const dynamic = 'force-dynamic'` (or `export const revalidate = 0`), Next.js will cache the navigation data and admin changes from the Navigation Manager will not reflect on the live site for minutes. Always add `dynamic = 'force-dynamic'` to `layout.tsx`.

3. **Category Bar Items Disappear When `top_header` Items Exist (Fallback Anti-Pattern)**
   When fetching both `top_header` items (`menuNodes`) and `category_bar` items (`categoryBarNodes`) from `navigation_items`, a common mistake is to show `categoryBarNodes` **only as a fallback** when `menuNodes` is empty:

   ```tsx
   // ❌ WRONG — categories disappear the moment any top_header item exists
   {menuNodes.length === 0 && categoryBarNodes.map(...)}
   ```

   This causes the entire category bar to vanish silently as soon as the admin adds even one `top_header` custom page (like `test2`). There is no error — the categories just stop rendering.

   **The correct pattern is to render BOTH arrays unconditionally:**

   ```tsx
   // ✅ CORRECT — categories always visible, custom pages always visible
   {categoryBarNodes.map((node) => (
     <NavigationItemNode key={node.id} node={node} depth={0} />
   ))}
   {menuNodes.map((node) => (
     <NavigationItemNode key={node.id} node={node} depth={0} />
   ))}
   ```

   Apply the same fix to the mobile menu — replace the ternary `menuNodes.length > 0 ? ... : categoryBarNodes` pattern with two unconditional `.map()` calls.

---

## Testing Steps
1. Go to the live website. Confirm all existing menu items are still visible in their correct locations (Top Header, Category Bar, Footer).
2. Check a category that has child links. Hover over it on desktop — confirm the dropdown opens and shows the children.
3. Check a category with no children. Hover over it — confirm NO dropdown/chevron appears, and clicking navigates to the URL.
4. On mobile, tap the arrow (▾) next to a parent item. Confirm the dropdown expands. Tap the label. Confirm it navigates.
5. Go to the Navigation Manager (Phase 6). Toggle a link OFF. Click "Save Changes". Refresh the live website. Confirm the link has disappeared from the menu instantly (no caching delay).
6. Toggle it back ON. Confirm it reappears.
7. Confirm that no links from another site's `site_id` are showing on this website.
8. Confirm the footer shows 運営元について and プライバシーポリシー links — these must come from `navigation_items`, NOT from hardcoded `<Link>` elements.
9. Create a new custom page in the editor. **Do NOT publish it.** Go to `/sitemap.xml` — confirm the page does **NOT** appear.
10. Click Publish in the editor. Refresh `/sitemap.xml` — confirm the page now appears **immediately** (no cache delay).
11. Delete the page. Refresh `/sitemap.xml` — confirm it disappears immediately.
12. On any published custom page, use a tool like [opengraph.xyz](https://www.opengraph.xyz) or share the URL on LINE/Slack — confirm a proper title + image preview card appears.


---

## Section 10: Sitemap (`app/sitemap.ts`)

### Problem
The default Next.js sitemap is cached. New/deleted pages take several minutes to appear or disappear.
Also, pages in **draft state** (never published) were appearing in the sitemap, which can hurt SEO by exposing thin/empty content to Google.

### Solution: Publish-state Detection Without New DB Columns

The "published vs draft" state is already encoded in `homepage_content.content`:
- **Draft only / freshly created**: `content.puckData.content = []` (empty array — the initial default canvas)
- **Published at least once**: `content.puckData.content = [... blocks ...]` (has actual Puck blocks)

Use this to filter the sitemap without any schema migration.

### Required Changes to `app/sitemap.ts`

```typescript
// 1. Force dynamic — never cache sitemap
export const dynamic = 'force-dynamic';

// 2. Fetch custom_pages + homepage_content together (one query each, no N+1)
const allCustomPages = await db.select().from(customPages).where(eq(customPages.siteId, SITE_ID));
const allCanvases   = await db.select({ siteId, content, updatedAt }).from(homepageContent);
const canvasMap     = new Map(allCanvases.map(c => [c.siteId, c]));

// 3. Filter: only pages that are indexable AND have been published
customPagesList = allCustomPages.filter(page => {
  if (page.indexable === false) return false;
  const canvas = canvasMap.get(pathToSiteId(`/${page.slug}`));
  if (!canvas) return false;
  const blocks = (canvas.content as any)?.puckData?.content ?? [];
  return blocks.length > 0;   // published = has at least one block
});

// 4. Use canvas.updatedAt (= last Publish click) not page.updatedAt (= last settings edit)
lastModified: canvas?.updatedAt ?? page.updatedAt ?? new Date()
```

### Behaviour After Fix

| Operation | Before | After |
|---|---|---|
| Create page (draft) | Appears in sitemap immediately ❌ | Hidden until Publish ✅ |
| Publish page | Took minutes (cache) ❌ | Appears instantly ✅ |
| Update + re-publish | `lastModified` may be stale ❌ | `lastModified` = publish timestamp ✅ |
| Delete page | Took minutes to disappear ❌ | Disappears instantly ✅ |

---

## Section 11: Custom Page SEO Metadata (`app/[slug]/page.tsx`)

### Problem
Custom pages created from the Page Editor were missing critical SEO tags that blog pages already have:
- No Open Graph tags → blank previews when sharing on LINE, Twitter, Facebook, Slack
- No Twitter Card
- No explicit `robots` meta tag (the admin `indexable` toggle had no effect on crawlers)
- No auto-generated structured data (WebPage JSON-LD)

### Required Changes to `generateMetadata()` in `app/[slug]/page.tsx`

All data already exists in `custom_pages` — no DB changes needed.

```typescript
export async function generateMetadata({ params }): Promise<Metadata> {
  const page = await getCustomPageBySlug(slug);
  const isIndexable = page.indexable !== false;
  const ogImageUrl  = `${siteUrl}/api/og?title=${encodeURIComponent(page.metaTitle || page.title)}`;

  return {
    title: page.metaTitle || page.title,
    description: page.metaDescription || undefined,
    alternates: { canonical: page.canonicalUrl || `${siteUrl}/${slug}` },

    // Explicit robots — wires the admin indexable toggle to actual crawler behaviour
    robots: {
      index: isIndexable,
      follow: isIndexable,
      googleBot: { index: isIndexable, follow: isIndexable },
    },

    // Open Graph — enables rich previews on LINE, Twitter, Facebook, Slack
    openGraph: {
      title: page.metaTitle || page.title,
      description: page.metaDescription || undefined,
      url: page.canonicalUrl || `${siteUrl}/${slug}`,
      siteName: '住宅知識ポータル',
      type: 'website',
      locale: 'ja_JP',
      images: [{ url: ogImageUrl, width: 1200, height: 630 }],
    },

    // Twitter / X Card
    twitter: {
      card: 'summary_large_image',
      title: page.metaTitle || page.title,
      description: page.metaDescription || undefined,
      images: [ogImageUrl],
    },
  };
}
```

### WebPage JSON-LD (auto-injected in page body)

In addition to metadata, inject a `WebPage` structured data block directly in the page render. This runs even if the admin leaves the manual Schema JSON field empty:

```tsx
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify({
  '@context': 'https://schema.org',
  '@type': 'WebPage',
  name: page.metaTitle || page.title,
  description: page.metaDescription || undefined,
  url: canonicalUrl,
  inLanguage: 'ja',
  isPartOf: { '@type': 'WebSite', name: '住宅知識ポータル', url: siteUrl },
}) }} />
```

> [!NOTE]
> The manual `schemaJson` from Page Settings still works and is injected **after** the auto WebPage schema. Google accepts multiple JSON-LD blocks on the same page — they coexist without conflict.

### OG Image Generation

The `/api/og` route already exists and generates a 1200×630 image from query params. Custom pages use it with just `?title=...` (no category/author needed). This means every published custom page automatically gets a branded social preview image — no manual image upload required from the admin.

---

## Testing Steps
1. Go to the live website. Confirm all existing menu items are still visible in their correct locations (Top Header, Category Bar, Footer).
2. Check a category that has child links. Hover over it on desktop — confirm the dropdown opens and shows the children.
3. Check a category with no children. Hover over it — confirm NO dropdown/chevron appears, and clicking navigates to the URL.
4. On mobile, tap the arrow (▼) next to a parent item. Confirm the dropdown expands. Tap the label. Confirm it navigates.
5. Go to the Navigation Manager (Phase 6). Toggle a link OFF. Click "Save Changes". Refresh the live website. Confirm the link has disappeared from the menu instantly (no caching delay).
6. Toggle it back ON. Confirm it reappears.
7. Confirm that no links from another site's `site_id` are showing on this website.
8. Confirm the footer shows 運営元について and プライバシーポリシー links — these must come from `navigation_items`, NOT from hardcoded `<Link>` elements.
9. Create a new custom page in the editor. **Do NOT publish it.** Go to `/sitemap.xml` — confirm the page does **NOT** appear.
10. Click Publish in the editor. Refresh `/sitemap.xml` — confirm the page now appears **immediately** (no cache delay).
11. Delete the page. Refresh `/sitemap.xml` — confirm it disappears immediately.
12. On any published custom page, share the URL on LINE or use [opengraph.xyz](https://www.opengraph.xyz) — confirm a proper title + image preview card appears.


---

## ✅ Phase 7 Completion Verification

Run this checklist to confirm Phase 7 is fully implemented:

**Navigation**
- [ ] `npx tsc --noEmit` passes with 0 errors
- [ ] Live site Header fetches links dynamically from `navigation_items` (not hardcoded)
- [ ] Live site Footer fetches links dynamically from `navigation_items` (not hardcoded)
- [ ] `export const dynamic = 'force-dynamic'` added to `app/layout.tsx`
- [ ] `categoryBarNodes` and `menuNodes` both rendered unconditionally (not as fallback)
- [ ] Hover dropdowns work for parent items with children
- [ ] Mobile menu shows all navigation items with tap-to-expand behavior
- [ ] Toggling a link OFF in Nav Manager → Save → refresh live site → link disappears
- [ ] Toggling it back ON → link reappears
- [ ] No links from another site's `site_id` are displayed
- [ ] All menu locations render correctly: `top_header`, `category_bar`, `footer`

**Sitemap**
- [ ] `export const dynamic = 'force-dynamic'` added to `app/sitemap.ts`
- [ ] Draft pages do NOT appear in `/sitemap.xml`
- [ ] Published pages appear in `/sitemap.xml` immediately after clicking Publish
- [ ] Deleted pages disappear from `/sitemap.xml` immediately
- [ ] `lastModified` in sitemap reflects last Publish time (not settings edit time)

**Custom Page SEO**
- [ ] Published custom pages have `<meta property="og:title">` in `<head>`
- [ ] Published custom pages have `<meta property="og:image">` (uses `/api/og` generator)
- [ ] Published custom pages have `<meta name="twitter:card">` in `<head>`
- [ ] Pages with `indexable = false` have `<meta name="robots" content="noindex, nofollow">`
- [ ] Auto WebPage JSON-LD is present in the page source
- [ ] Sharing a custom page URL on LINE shows a proper preview card

> **Phase 7 is complete when ALL boxes above are checked.**

