# Phase 9: Popup Modal Component

## Goal
Add a **newsletter subscription popup modal** to the Puck CMS editor as a fully configurable component. The popup:
- Appears automatically on the live site after a configurable delay
- Collects email subscriptions via the existing `/api/newsletter/subscribe` endpoint
- Is managed entirely from the Puck editor canvas (no code changes needed to update it)
- Shows a static design preview inside the editor (never pops up while editing)
- Remembers dismissal within a session (`sessionStorage`) so it never double-shows

---

## Architecture Overview

```
Editor Canvas (/page_editor_admin)
  ↓ Admin drags "Popup Modal" from "Promotions & CTA" category
  ↓ PopupModalEditorPreview renders (static card, no timer, no popup)
  ↓ Admin configures: heading, description, delay, button color, image
  ↓ Admin clicks Publish

Live Website (/, /[slug], etc.)
  ↓ Puck <Render> renders PopupModal component
  ↓ useEffect fires after [delaySeconds] seconds
  ↓ sessionStorage check: skip if already shown this session
  ↓ Popup animates in (opacity + translateY + scale)
  ↓ User submits email → POST /api/newsletter/subscribe
  ↓ Subscriber saved to `subscribers` table with siteId
  ↓ Success state shown → popup auto-closes after 2.2s
```

---

## Key Architecture Decisions

### 1. Editor Detection via `window.location.pathname` (CRITICAL)
```tsx
const inEditor =
  typeof window !== 'undefined' &&
  window.location.pathname.startsWith('/page_editor_admin');

if (inEditor) return <PopupModalEditorPreview {...p} />;
return <PopupModal {...p} />;
```

> [!CAUTION]
> **NEVER use `!!props.puck` to detect the editor environment.**
> Puck injects `props.puck` in BOTH the editor AND the live `<Render>` component — it is always truthy. Using `props.puck` will permanently lock the component into static preview on the live page too. Use `window.location.pathname` ONLY.

### 2. Two Named Exports — One File
`components/sections/PopupModal.tsx` exports:
- **`PopupModal`** — Live component. Has `useState`, `useEffect`, `setTimeout`, `sessionStorage`, `window`. Uses `'use client'` directive.
- **`PopupModalEditorPreview`** — Static card preview. No state, no effects, no timers. Renders instantly in the Puck canvas.

### 3. Session Guard
```ts
const SESSION_KEY = 'popup_modal_shown';
if (sessionStorage.getItem(SESSION_KEY)) return; // skip
```
Once shown (or dismissed), the key is set. The popup never shows again in the same browser session. This prevents it from re-triggering on every page navigation within a session.

### 4. Body Scroll Lock
```tsx
useEffect(() => {
  document.body.style.overflow = visible ? 'hidden' : '';
  return () => { document.body.style.overflow = ''; };
}, [visible]);
```
Prevents background scrolling while popup is open.

### 5. Use `<img>` NOT `next/image`
The popup image is user-uploaded via the Puck MediaUploadField. The CDN domain varies at runtime and cannot be added to `next.config.js` at build time. Use plain `<img>` to avoid hostname errors.

### 6. siteId — Where Subscribers Are Saved
The `PopupModal` component itself **does not handle siteId**. It calls:
```
POST /api/newsletter/subscribe
body: { email: "user@example.com" }
```
The subscribe API internally uses a hardcoded `SITE_ID` constant:
```ts
// app/api/newsletter/subscribe/route.ts
const SITE_ID = '[YOUR_SITE_ID]';
```
Subscribers are saved to the `subscribers` table in Neon with:
```
siteId = '[YOUR_SITE_ID]'
email = 'user@example.com'
subscribedAt = NOW()
isActive = true
```

> [!IMPORTANT]
> The existing `/api/newsletter/subscribe` route MUST already exist before implementing this component. Do NOT create a new one.

---

## Implementation — Files Changed

### [NEW] `components/sections/PopupModal.tsx`
Two named exports: `PopupModal` and `PopupModalEditorPreview`.

**`PopupModal` props:**
| Prop | Type | Default | Description |
|---|---|---|---|
| `image` | `string` | `''` | Optional image URL for top of popup |
| `heading` | `string` | `'メルマガに登録しませんか？'` | Main heading text |
| `description` | `string` | `'サイトの最新情報をメールでお届けします...'` | Subtext below heading |
| `delaySeconds` | `number` | `3` | Seconds after page load before popup appears |
| `buttonLabel` | `string` | `'無料で登録する'` | Submit button text |
| `buttonColor` | `string` | `'#FFCFDF'` | Submit button background (hex) — user-configurable |
| `emailPlaceholder` | `string` | `'メールアドレスを入力'` | Email input placeholder |
| `dismissible` | `boolean` | `true` | Whether to show the ✕ close button |

**`PopupModalEditorPreview` props:** Same interface. No state or effects.

### [MODIFY] `lib/puck/config.tsx`

**Import added:**
```tsx
import { PopupModal, PopupModalEditorPreview } from "@/components/sections/PopupModal";
```

**Category updated:**
```tsx
promotions: {
  title: "Promotions & CTA",
  components: ["BannerCTA", "CTAButton", "AppPromoSection", "PopupModal"],
},
```

**Component definition added** (inside `components: { ... }`):
```tsx
PopupModal: {
  label: 'Popup Modal',
  fields: {
    image:            { type: 'custom',   label: 'Popup Image (optional)', render: MediaUploadField },
    heading:          { type: 'text',     label: 'Heading' },
    description:      { type: 'textarea', label: 'Description' },
    delaySeconds:     { type: 'number',   label: 'Show after (seconds)', min: 0, max: 60 },
    emailPlaceholder: { type: 'text',     label: 'Email Placeholder' },
    buttonLabel:      { type: 'text',     label: 'Button Label' },
    buttonColor:      { type: 'text',     label: 'Button Color (hex)' },
    dismissible:      { type: 'radio',    label: 'Show Close (×) Button',
                        options: [{ label: 'Yes', value: true }, { label: 'No', value: false }] },
  },
  defaultProps: { ... },
  render: (props) => {
    // Uses window.location.pathname to detect editor
    // Returns PopupModalEditorPreview in editor, PopupModal on live site
  },
},
```

---

## How the Component Registers in `/page_editor_admin`

This is what makes the component usable inside the Puck CMS editor. All three parts below are required.

### 1. Sidebar Entry (Category)
The component appears in the **left sidebar** of `/page_editor_admin` under the category it is registered in.

```tsx
// lib/puck/config.tsx — categories block
promotions: {
  title: "Promotions & CTA",
  components: ["BannerCTA", "CTAButton", "AppPromoSection", "PopupModal"], // ← added here
},
```

The text that appears in the sidebar is the `label` from the component definition:
```tsx
PopupModal: {
  label: 'Popup Modal',  // ← this is what shows in the sidebar
```

Without registering the key in a category, the component exists in `puckConfig.components` but is INVISIBLE in the editor sidebar — the admin cannot drag it.

### 2. Right Sidebar Config Panel (Fields)
When the admin clicks on a `PopupModal` block in the canvas, the **right sidebar** shows the configuration panel. This is built from the `fields` object:

| Field key | Field type | Renders as in editor |
|---|---|---|
| `image` | `custom` (MediaUploadField) | File upload button |
| `heading` | `text` | Single-line text input |
| `description` | `textarea` | Multi-line text input |
| `delaySeconds` | `number` | Number input (0–60) |
| `emailPlaceholder` | `text` | Single-line text input |
| `buttonLabel` | `text` | Single-line text input |
| `buttonColor` | `text` | Text input (admin types a hex code) |
| `dismissible` | `radio` | Radio buttons: Yes / No |

The values the admin sets here are passed directly as props to the `render` function.

### 3. Canvas Preview (EditorPreview Export)
When the admin drags **Popup Modal** onto the canvas, the `render` function is called. It detects the editor environment and returns `PopupModalEditorPreview` — a static card that shows what the popup looks like without actually triggering any timers or overlays.

```tsx
render: (props: any) => {
  const p = stripPuck(props);
  const inEditor =
    typeof window !== 'undefined' &&
    window.location.pathname.startsWith('/page_editor_admin');
  if (inEditor) return <PopupModalEditorPreview {...p} />;  // ← shown in editor
  return <PopupModal {...p} />;                             // ← shown on live site
},
```

> [!IMPORTANT]
> **Why two exports instead of one?**
> `PopupModal` has `useEffect`, `setTimeout`, `sessionStorage`, and `window`. In the editor, this would cause the popup to actually appear as an overlay over the canvas after 3 seconds — making editing impossible. `PopupModalEditorPreview` renders the same visual design instantly as an inline card, with no side effects.

### Summary — What the Admin Sees at `/page_editor_admin`

```
Left Sidebar:
  Promotions & CTA
    ├── BannerCTA
    ├── CTAButton
    ├── AppPromoSection
    └── Popup Modal          ← new entry

Canvas (after drag):
    [Static preview card showing heading, email input, button]
    [Label: "ポップアップモーダル · 3秒後に表示"]

Right Sidebar (after click):
    Popup Image (optional)  [Upload]
    Heading                 [____________]
    Description             [____________]
    Show after (seconds)    [3          ]
    Email Placeholder       [____________]
    Button Label            [____________]
    Button Color (hex)      [____________]
    Show Close (×) Button   (●) Yes  ( ) No
```

---

## Brand Colors — Example Extraction

Extracted from `app/globals.css` `:root {}` block:

| Role | CSS Variable | Resolved Hex |
|---|---|---|
| Primary (Sakura Pink) | `--primary: 340 100% 91%` | `#FFCFDF` |
| Secondary (Cream Yellow) | `--secondary: 58 97% 89%` | `#FEFDCA` |
| Accent (Matcha Green) | `--accent: 82 86% 84%` | `#E0F9B5` |
| Text | `--foreground: 0 0% 18%` | `#2D2D2D` |
| Font | `font-family: 'Noto Sans JP'` | Already loaded |

**Top gradient bar:**
```css
background: linear-gradient(to right, #FFCFDF, #FEFDCA, #E0F9B5);
```

---

## Testing Steps

1. Open `/page_editor_admin` → sidebar → **Promotions & CTA** → drag **Popup Modal** onto canvas
2. Confirm `PopupModalEditorPreview` card renders instantly in the canvas (no timer, no popup overlay)
3. In the right sidebar, configure: heading, description, delay (e.g. 2 seconds), button color (`#FFCFDF`)
4. Optionally upload an image via the **Popup Image** field
5. Click **Publish**
6. Open the live site (e.g. `/`) in a **new tab** — wait `delaySeconds` → popup should animate in
7. Submit a test email → confirm **"登録ありがとうございます！"** success state shows → popup auto-closes
8. Open the 👥 **Subscribers Viewer** in the editor toolbar → confirm the email appears with `✓ Active` status
9. Reload the live page → popup should **NOT** appear again (session guard active)
10. DevTools → Application → Session Storage → delete `popup_modal_shown` key → refresh → popup appears again

---

---

# Universal Agent Prompt

> Use this prompt to implement the Popup Modal component on any website that uses the same Puck CMS architecture. Copy it and feed it to an AI agent. The agent will discover the project-specific values before writing any code.

---

```
# AI Agent Task: Add Popup Modal Component

## Your First Step — Discover Before You Write

Before writing a single line of code, you MUST complete these discovery steps. Do NOT assume any values.

### Discovery Step A — Find the siteId
1. Open `app/api/newsletter/subscribe/route.ts`
2. Find the line that declares the SITE_ID constant (e.g. `const SITE_ID = 'example.com'`)
3. Record this value. This is used to tag all subscriber records in the DB.
4. Confirm the route exists and has a working POST handler. Do NOT create a new subscribe API.

### Discovery Step B — Read the website's brand theme
1. Open `app/globals.css`
2. Find the `:root { }` or `@theme { }` block at the top
3. Extract:
   - Primary brand color (hex or HSL)
   - Secondary / action color
   - Accent / surface color
   - Primary text color
   - Page background
   - Font family (name of the Google Font or system font)
4. Convert any HSL values to hex. You will use these as inline styles in the component.
5. Build the top accent bar gradient:
   `background: 'linear-gradient(to right, [primary], [secondary], [accent])'`

### Discovery Step C — Understand the project structure
1. Open `app/page.tsx` — check if a `CLIENT_ONLY_COMPONENTS` Set exists
2. Check if `components/puck/PageRenderer.tsx` exists
3. Open `lib/puck/config.tsx` — find the `promotions` (or equivalent) category
4. Find the `stripPuck` helper function — confirm its name
5. Find any existing import of MediaUploadField — note the path
6. Check which field types are available (type: 'text', 'textarea', 'number', 'radio', 'custom')

Record all findings before proceeding.

---

## Step 1 — CREATE `components/sections/PopupModal.tsx`

This file exports TWO named exports from a single file.

### Rules for this component:
- Top of file: `'use client';`
- Import: `import React, { useState, useEffect, useCallback } from 'react';`
- Use the brand colors you extracted in Discovery Step B — NEVER hardcode unrelated hex values
- Use plain `<img>` for the popup image (NOT next/image) — avoids hostname config errors
- Use `window.location.pathname.startsWith('/page_editor_admin')` to detect the editor
- NEVER use `!!props.puck` or `!!props.editMode` for editor detection — these are always truthy in both editor and live <Render>

### Export A: `PopupModal` (shown on the live website)

Props interface:
```ts
interface PopupModalProps {
  image?: string;
  heading?: string;
  description?: string;
  delaySeconds?: number;    // seconds before popup shows (default 3)
  buttonLabel?: string;
  buttonColor?: string;     // hex — user-configurable via CMS
  emailPlaceholder?: string;
  dismissible?: boolean;
}
```

State:
```ts
const [visible, setVisible]     = useState(false);
const [animateIn, setAnimateIn] = useState(false);
const [email, setEmail]         = useState('');
const [submitted, setSubmitted] = useState(false);
const [errorMsg, setErrorMsg]   = useState('');
```

Session key:
```ts
const SESSION_KEY = 'popup_modal_shown';
```

useEffect 1 — timer trigger (empty dependency array — fires ONCE only):
```ts
useEffect(() => {
  if (typeof window === 'undefined') return;
  if (window.location.pathname.startsWith('/page_editor_admin')) return;
  if (sessionStorage.getItem(SESSION_KEY)) return;

  const ms = Math.max(0, Number(delaySeconds) || 3) * 1000;
  const timer = setTimeout(() => {
    setVisible(true);
    requestAnimationFrame(() => requestAnimationFrame(() => setAnimateIn(true)));
  }, ms);

  return () => clearTimeout(timer);
}, []);
```

useEffect 2 — body scroll lock:
```ts
useEffect(() => {
  document.body.style.overflow = visible ? 'hidden' : '';
  return () => { document.body.style.overflow = ''; };
}, [visible]);
```

close handler:
```ts
const close = useCallback(() => {
  setAnimateIn(false);
  sessionStorage.setItem(SESSION_KEY, '1');
  setTimeout(() => setVisible(false), 280);
}, []);
```

handleSubmit — calls the EXISTING subscribe API (siteId is handled internally by the API):
```ts
const handleSubmit = useCallback(async (e: React.FormEvent) => {
  e.preventDefault();
  if (!email.trim()) return;
  setErrorMsg('');
  try {
    const res = await fetch('/api/newsletter/subscribe', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email: email.trim() }),
    });
    const data = await res.json();
    if (data.success) {
      setSubmitted(true);
      sessionStorage.setItem(SESSION_KEY, '1');
      setTimeout(close, 2200);
    } else {
      setErrorMsg(data.message || 'エラーが発生しました。もう一度お試しください。');
    }
  } catch {
    setErrorMsg('ネットワークエラーが発生しました。もう一度お試しください。');
  }
}, [email, close]);
```

Render guard:
```tsx
if (!visible) return null;
```

JSX structure (use your discovered brand colors for all styles):
```
<> 
  Backdrop div — position fixed, z-[200000], bg black/60, backdrop-blur-sm
                 opacity animateIn ? 1 : 0, transition 300ms
                 onClick={dismissible ? close : undefined}

  Modal container div — position fixed, z-[200001], flex center
    Card div — max-w-sm, rounded-2xl, bg white, shadow-2xl, overflow hidden
               opacity + translateY(28px) + scale(0.96) animation, 300ms ease

      Top accent bar — height 6px, gradient from brand colors (inline style)

      Close button (if dismissible) — absolute top-right, rounded-full, bg-gray-100

      Image or brand icon placeholder

      Body:
        Brand mark (small circle with Leaf icon + site name in primary color)
        
        [if submitted]:
          Success state — checkmark icon, thank you message

        [else]:
          Heading (h2)
          Description (p)
          Form:
            Email input with Mail icon
            Submit button — style={{ backgroundColor: buttonColor }} (inline — runtime value)
          Error message (if errorMsg)
          Spam disclaimer text
</>
```

### Export B: `PopupModalEditorPreview` (shown ONLY in the Puck canvas)

Same props interface. No state, no effects, no timers. Renders instantly.

Shows:
- Top gradient accent bar (same brand colors)
- Label: `"ポップアップモーダル · {delaySeconds}秒後に表示"`
- Optional image or brand icon placeholder
- Heading + truncated description
- Non-functional email input + styled button preview
- Dismissible status text

---

## Step 2 — EDIT `lib/puck/config.tsx`

### 2a. Add import near the top (with other section imports):
```tsx
import { PopupModal, PopupModalEditorPreview } from "@/components/sections/PopupModal";
```

### 2b. Find the `promotions` (or "Promotions & CTA") category and add `"PopupModal"`:
```tsx
promotions: {
  title: "Promotions & CTA",
  components: ["BannerCTA", "CTAButton", "AppPromoSection", "PopupModal"],
},
```
Note: If the category has a different name, add to whichever category best fits promotional/newsletter components.

> [!IMPORTANT]
> **Adding to the category is REQUIRED for the component to appear in the `/page_editor_admin` sidebar.**
> If you only add the component definition in `components: { ... }` but do NOT add it to a category, the component exists in the config but is completely invisible to the admin — they cannot drag it onto the canvas.

### 2c. Add inside `components: { ... }` (before the closing `}`):

Use the `stripPuck` helper you found in Discovery Step C.
Use the `MediaUploadField` import path you found in Discovery Step C.

```tsx
PopupModal: {
  label: 'Popup Modal',
  fields: {
    image: {
      type: 'custom',
      label: 'Popup Image (optional)',
      render: ({ value, onChange }: any) => (
        <MediaUploadField value={value || ''} onChange={onChange} accept="image" />
      ),
    },
    heading:          { type: 'text',     label: 'Heading' },
    description:      { type: 'textarea', label: 'Description' },
    delaySeconds:     { type: 'number',   label: 'Show after (seconds, e.g. 3)', min: 0, max: 60 },
    emailPlaceholder: { type: 'text',     label: 'Email Placeholder' },
    buttonLabel:      { type: 'text',     label: 'Button Label' },
    buttonColor:      { type: 'text',     label: 'Button Color (hex)' },
    dismissible: {
      type: 'radio',
      label: 'Show Close (×) Button',
      options: [
        { label: 'Yes – user can close it', value: true },
        { label: 'No – user must submit',   value: false },
      ],
    },
  },
  defaultProps: {
    image:            '',
    heading:          'メルマガに登録しませんか？',
    description:      'サイトの最新情報をメールでお届けします。いつでも配信停止できます。',
    delaySeconds:     3,
    emailPlaceholder: 'メールアドレスを入力',
    buttonLabel:      '無料で登録する',
    buttonColor:      '[PRIMARY_BRAND_COLOR_HEX]', // replace with hex from Discovery Step B
    dismissible:      true,
  },
  render: (props: any) => {
    const p = stripPuck(props);
    // ⚠️ CRITICAL: pathname check only — props.puck is always truthy in both editor AND live
    const inEditor =
      typeof window !== 'undefined' &&
      window.location.pathname.startsWith('/page_editor_admin');
    if (inEditor) return <PopupModalEditorPreview {...p} />;
    return <PopupModal {...p} />;
  },
},
```

---

## Step 3 — Check if CLIENT_ONLY_COMPONENTS exists

From Discovery Step C:

**If `CLIENT_ONLY_COMPONENTS` Set EXISTS in `app/page.tsx`:**
```tsx
const CLIENT_ONLY_COMPONENTS = new Set([
  'NavigationGrid', 'LatestArticlesGrid', ..., 'PopupModal', // ← add here
]);
```

**If it does NOT exist:** Skip this step entirely. Puck's RSC `<Render>` handles `'use client'` components automatically via Next.js server/client boundary.

**If `components/puck/PageRenderer.tsx` EXISTS:** Apply the same CLIENT_ONLY_COMPONENTS change there too.

**If it does NOT exist:** Skip.

---

## siteId Reference (Important for Debugging)

- The `PopupModal` component does NOT pass siteId to the API
- siteId is hardcoded in `app/api/newsletter/subscribe/route.ts` as `const SITE_ID = '[value you found]'`
- Submitted emails are saved to the `subscribers` table in Neon with `siteId = [that value]`
- To verify subscriptions worked: query `SELECT * FROM subscribers WHERE site_id = '[value]'`

---

## Verification

1. Go to `/page_editor_admin` → Promotions & CTA → drag **Popup Modal** onto canvas
2. Confirm the static preview card renders (no popup overlay, no timer)
3. Configure fields in the right sidebar
4. Click **Publish**
5. Open the live page in a new tab → wait `delaySeconds` → popup animates in
6. Submit email → success state shows → popup closes
7. Reload → popup does NOT appear again (session guard)
8. DevTools → Application → Session Storage → delete `popup_modal_shown` → refresh → popup reappears
```

---

---

# Step 2: Subscribers Viewer

## Goal
Add a **Subscribers Viewer** button to the Puck editor toolbar (`/page_editor_admin`) so the admin can view, paginate, and permanently delete newsletter subscribers — all without leaving the editor. Data is fetched live from the Neon `subscribers` table filtered by `siteId`.

---

## Architecture Overview

```
Editor Toolbar (/page_editor_admin)
  👥 button (green, icon-only, next to 🧭 Nav Manager)
    ↓ onClick → setIsSubscribersModalOpen(true)

SubscribersModal (client component)
  ↓ on open → GET /api/newsletter/subscribers?page=1&limit=10
  ↓ shows table: Email | Status | Subscribed At | Actions
  ↓ 🗑️ clicked → inline confirm row appears below that subscriber
  ↓ "Yes, Delete" → DELETE /api/newsletter/subscribers/[id]
  ↓ DB row removed (filtered by BOTH id AND siteId for safety)
  ↓ list re-fetches automatically
```

---

## Key Implementation Details

### siteId Safety on Delete
The DELETE endpoint uses an `AND` condition — both `id` AND `siteId` must match:
```ts
.where(and(eq(subscribers.id, numericId), eq(subscribers.siteId, SITE_ID)))
```
This prevents cross-site deletion if the same DB is shared across multiple sites.

### React key Warning — Use `React.Fragment` NOT `<>`
Each row in the subscriber table optionally renders a second row (the confirm row). This means the `map()` returns two `<tr>` elements per subscriber. The correct pattern:

```tsx
// ❌ WRONG — key prop is on inner <tr>, not on the fragment root
{subscribers.map(sub => (
  <>
    <tr key={sub.id}>...</tr>
    {confirmRow && <tr key={`confirm-${sub.id}`}>...</tr>}
  </>
))}

// ✅ CORRECT — key is on React.Fragment (the actual root element)
{subscribers.map(sub => (
  <React.Fragment key={sub.id}>
    <tr>...</tr>
    {confirmRow && <tr>...</tr>}
  </React.Fragment>
))}
```

### Inline Delete Confirmation (not a separate modal)
The delete confirmation is an inline row that appears BELOW the subscriber row when 🗑️ is clicked. It uses `confirmDeleteId` state:
```tsx
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
// Click 🗑️ → setConfirmDeleteId(sub.id)   or null to cancel
// Click confirm → handleDelete(sub.id) → DELETE API → refetch
```

### Pagination
- Default: 10 per page
- URL params: `?page=N&limit=10`
- Response includes: `totalCount`, `totalPages`, `currentPage`
- Prev/Next buttons disabled at boundaries

### Next.js 15+ Dynamic Route Params
In Next.js 15+, dynamic route params in API handlers must be awaited:
```ts
// app/api/newsletter/subscribers/[id]/route.ts
export async function DELETE(
  _req: Request,
  { params }: { params: Promise<{ id: string }> }  // ← Promise, not plain object
) {
  const { id } = await params;  // ← must await
```

---

## Implementation — Files Created/Changed

### [NEW] `app/api/newsletter/subscribers/route.ts`
`GET` handler — paginated subscriber list filtered by siteId:

```ts
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { subscribers } from '@/shared/schema';
import { eq, desc, count } from 'drizzle-orm';

export const dynamic = 'force-dynamic';
const SITE_ID = '[YOUR_SITE_ID]'; // ← use your site's SITE_ID

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const page  = Math.max(1, parseInt(searchParams.get('page')  ?? '1',  10));
  const limit = Math.min(50, Math.max(1, parseInt(searchParams.get('limit') ?? '10', 10)));
  const offset = (page - 1) * limit;

  const [rows, [{ total }]] = await Promise.all([
    db.select().from(subscribers)
      .where(eq(subscribers.siteId, SITE_ID))
      .orderBy(desc(subscribers.subscribedAt))
      .limit(limit).offset(offset),
    db.select({ total: count() }).from(subscribers)
      .where(eq(subscribers.siteId, SITE_ID)),
  ]);

  return NextResponse.json({
    subscribers: rows,
    totalCount: Number(total),
    totalPages: Math.ceil(Number(total) / limit),
    currentPage: page,
  });
}
```

### [NEW] `app/api/newsletter/subscribers/[id]/route.ts`
`DELETE` handler — removes subscriber with siteId guard:

```ts
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { subscribers } from '@/shared/schema';
import { and, eq } from 'drizzle-orm';

export const dynamic = 'force-dynamic';
const SITE_ID = '[YOUR_SITE_ID]'; // ← use your site's SITE_ID

export async function DELETE(
  _req: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const numericId = parseInt(id, 10);
  if (isNaN(numericId))
    return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });

  const deleted = await db.delete(subscribers)
    .where(and(eq(subscribers.id, numericId), eq(subscribers.siteId, SITE_ID)))
    .returning();

  if (deleted.length === 0)
    return NextResponse.json({ error: 'Not found' }, { status: 404 });

  return NextResponse.json({ success: true, deleted: deleted[0] });
}
```

### [NEW] `components/modals/SubscribersModal.tsx`
Full modal component. Props: `isOpen: boolean`, `onClose: () => void`.

**Key structure:**
```
SubscribersModal
  ├── Overlay (fixed, z-[300000], click to close)
  ├── Modal card (fixed, z-[300001], max-w-2xl)
  │   ├── Header: "Subscribers" title + total count + ✕ close button
  │   ├── Loading state (spinner)
  │   ├── Error state (retry button)
  │   ├── Empty state
  │   └── Table
  │       ├── thead: Email | Status | Subscribed At | Actions
  │       └── tbody: map with React.Fragment key={sub.id}
  │           ├── <tr> subscriber row
  │           │   ├── email
  │           │   ├── status badge (✓ Active / ✗ Unsubscribed)
  │           │   ├── formatted date
  │           │   └── 🗑️ button → toggles confirmDeleteId
  │           └── <tr> (conditional) DeleteConfirmRow
  │               ├── ⚠️ "Permanently delete [email]?"
  │               ├── "Yes, Delete" button → DELETE API
  │               └── "Cancel" button → setConfirmDeleteId(null)
  └── Footer: Prev / Page N of M / Next pagination
```

**Internal sub-component `DeleteConfirmRow`:**
```tsx
function DeleteConfirmRow({ email, onConfirm, onCancel, isDeleting }) {
  return (
    <div style={{ background: '#fef2f2', border: '1px solid #fca5a5', borderRadius: '8px', padding: '8px 12px' }}>
      <span>⚠️ Permanently delete <strong>{email}</strong>?</span>
      <button onClick={onConfirm} disabled={isDeleting}>
        {isDeleting ? 'Deleting...' : 'Yes, Delete'}
      </button>
      <button onClick={onCancel}>Cancel</button>
    </div>
  );
}
```

**State:**
```tsx
const [page, setPage]                     = useState(1);
const [data, setData]                     = useState<SubscribersResponse | null>(null);
const [loading, setLoading]               = useState(false);
const [error, setError]                   = useState<string | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
const [deletingId, setDeletingId]         = useState<number | null>(null);
```

**Fetch logic:**
```tsx
const fetchSubscribers = useCallback(async () => {
  setLoading(true); setError(null);
  const res = await fetch(`/api/newsletter/subscribers?page=${page}&limit=10`);
  if (!res.ok) throw new Error('Failed to fetch');
  setData(await res.json());
}, [page]);

useEffect(() => {
  if (isOpen) fetchSubscribers();
}, [isOpen, page, fetchSubscribers]);
```

**Delete logic:**
```tsx
const handleDelete = useCallback(async (id: number) => {
  setDeletingId(id);
  const res = await fetch(`/api/newsletter/subscribers/${id}`, { method: 'DELETE' });
  if (res.ok) {
    setConfirmDeleteId(null);
    setPage(1); // go back to first page
    await fetchSubscribers();
  }
  setDeletingId(null);
}, [fetchSubscribers]);
```

### [MODIFY] `app/page_editor_admin/editor-client.tsx`

**Add import:**
```tsx
import { SubscribersModal } from '@/components/modals/SubscribersModal';
```

**Add state (inside `HeaderActions` component):**
```tsx
const [isSubscribersModalOpen, setIsSubscribersModalOpen] = useState(false);
```

**Add button to toolbar** (icon-only, next to 🧭 Nav Manager button):
```tsx
<button
  onClick={() => setIsSubscribersModalOpen(true)}
  style={{
    display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
    width: '34px', height: '34px',
    fontSize: '18px',
    background: '#f0fdf4',
    color: '#15803d',
    border: '1px solid #bbf7d0',
    borderRadius: '8px',
    cursor: 'pointer',
  }}
  title="Subscribers"
>
  👥
</button>
```

**Render modal** (alongside other modals at the bottom of `HeaderActions` JSX):
```tsx
<SubscribersModal
  isOpen={isSubscribersModalOpen}
  onClose={() => setIsSubscribersModalOpen(false)}
/>
```

---

## Toolbar Layout Result

After this step, the editor toolbar has these icon-only buttons:
```
[⚙️ Settings] [🗑️ Delete] [🧭 Nav Manager] [👥 Subscribers]   Saved  [Draft] [Publish]
```

---

## Testing Steps

1. Open `/page_editor_admin` → click the **👥** button in the top toolbar
2. Modal opens → shows paginated list of subscribers (10 per page)
3. Each row shows: email, ✓ Active / ✗ Unsubscribed badge, subscribed date
4. Click 🗑️ on any row → red inline confirmation row appears below it
5. Click **"Yes, Delete"** → spinner shows → row is deleted from Neon DB → list refreshes
6. Click **"Cancel"** → confirmation row closes, subscriber remains
7. Submit a new email via the popup on the live site → return to editor → open 👥 → new subscriber appears
8. Test pagination: if >10 subscribers, Prev/Next buttons should work correctly

---

---

# Universal Agent Prompt — Step 2: Subscribers Viewer

> Use this prompt to add the Subscribers Viewer to any Puck CMS editor that already has Phase 9 Step 1 (Popup Modal) implemented. Feed this to an AI agent.

---

```
# AI Agent Task: Add Subscribers Viewer to Page Editor

## Prerequisites — Verify Before Starting
1. The `subscribers` table exists in the Neon DB with columns: `id`, `siteId`, `email`, `subscribedAt`, `isActive`
2. Open `app/api/newsletter/subscribe/route.ts` — find the `SITE_ID` constant (e.g. `'example.com'`). Note this value — you will use it in ALL new API routes.
3. Open `app/page_editor_admin/editor-client.tsx` — find where other icon-only toolbar buttons are rendered (look for `title="Navigation Manager"` or similar). You will add the new button here.
4. Check if `components/modals/` directory exists. If not, create it.

## Step 1 — CREATE `app/api/newsletter/subscribers/route.ts`
GET handler. Use the SITE_ID from Prerequisites step 2.
- Accepts `?page=N&limit=N` query params
- Returns: `{ subscribers, totalCount, totalPages, currentPage }`
- Filter: `WHERE siteId = SITE_ID`
- Order: `ORDER BY subscribedAt DESC`
- Max limit: 50
- Export `dynamic = 'force-dynamic'`
- Use drizzle-orm: `db.select().from(subscribers).where(eq(...)).orderBy(desc(...)).limit().offset()`
- Run count query in parallel with `Promise.all`

## Step 2 — CREATE `app/api/newsletter/subscribers/[id]/route.ts`
DELETE handler. Use the SITE_ID from Prerequisites step 2.
- In Next.js 15+, params must be typed as `Promise<{ id: string }>` and awaited
- Parse id to integer, return 400 if NaN
- Delete condition: `AND(eq(id, numericId), eq(siteId, SITE_ID))` — BOTH conditions required
- Use `.returning()` to detect if row was found
- Return 404 if `deleted.length === 0`
- Return `{ success: true, deleted: deleted[0] }` on success

## Step 3 — CREATE `components/modals/SubscribersModal.tsx`
'use client' component. Props: `{ isOpen: boolean; onClose: () => void }`.

### Internal sub-component: `DeleteConfirmRow`
Renders inside a `<td colSpan={4}>` when the admin clicks 🗑️:
- Red/pink background, warning icon, shows email being deleted
- "Yes, Delete" button (disabled + shows "Deleting..." when isDeleting)
- "Cancel" button → calls onCancel

### State:
- `page` (number, default 1) — current page
- `data` (SubscribersResponse | null) — API response
- `loading` (boolean)
- `error` (string | null)
- `confirmDeleteId` (number | null) — which row's confirm is open
- `deletingId` (number | null) — which delete is in progress

### Types:
```ts
interface Subscriber {
  id: number;
  email: string;
  isActive: boolean;
  subscribedAt: string;
  siteId: string | null;
}
interface SubscribersResponse {
  subscribers: Subscriber[];
  totalCount: number;
  totalPages: number;
  currentPage: number;
}
```

### Fetch:
- `useCallback` fetching `GET /api/newsletter/subscribers?page=${page}&limit=10`
- `useEffect` runs when `isOpen` is true OR `page` changes
- Reset page to 1 and re-fetch after a successful delete

### Delete:
```ts
const handleDelete = async (id: number) => {
  setDeletingId(id);
  const res = await fetch(`/api/newsletter/subscribers/${id}`, { method: 'DELETE' });
  if (res.ok) { setConfirmDeleteId(null); setPage(1); await fetchSubscribers(); }
  setDeletingId(null);
};
```

### Table body — CRITICAL key pattern:
```tsx
{data.subscribers.map((sub) => (
  <React.Fragment key={sub.id}>           {/* ← key MUST be on React.Fragment */}
    <tr>
      <td>{sub.email}</td>
      <td>{sub.isActive ? '✓ Active' : '✗ Unsubscribed'}</td>
      <td>{formatDate(sub.subscribedAt)}</td>
      <td>
        <button onClick={() => setConfirmDeleteId(
          confirmDeleteId === sub.id ? null : sub.id
        )}>🗑️</button>
      </td>
    </tr>
    {confirmDeleteId === sub.id && (
      <tr>
        <td colSpan={4}>
          <DeleteConfirmRow
            email={sub.email}
            onConfirm={() => handleDelete(sub.id)}
            onCancel={() => setConfirmDeleteId(null)}
            isDeleting={deletingId === sub.id}
          />
        </td>
      </tr>
    )}
  </React.Fragment>
))}
```

> [!CAUTION]
> Never use `<>` (bare fragment) as the root of a `.map()` when returning multiple elements per item. React cannot attach a `key` to bare fragments. Always use `<React.Fragment key={...}>` explicitly.

### z-index:
- Overlay: `z-[300000]` (higher than Puck editor UI)
- Modal card: `z-[300001]`

## Step 4 — MODIFY `app/page_editor_admin/editor-client.tsx`

1. Import: `import { SubscribersModal } from '@/components/modals/SubscribersModal';`
2. Add state: `const [isSubscribersModalOpen, setIsSubscribersModalOpen] = useState(false);`
3. Add icon-only button in the toolbar, next to the Nav Manager button:
   - Green color scheme: `background: '#f0fdf4'`, `border: '1px solid #bbf7d0'`, `color: '#15803d'`
   - Size: `width: '34px'`, `height: '34px'`
   - Icon: 👥 emoji
   - `title="Subscribers"` for tooltip
4. Render `<SubscribersModal isOpen={isSubscribersModalOpen} onClose={() => setIsSubscribersModalOpen(false)} />` alongside other modals

## siteId Note
The SITE_ID in the two new API routes MUST match the value in `app/api/newsletter/subscribe/route.ts`.
This ensures that subscribers created by the popup are visible in the viewer and deletable.
If they don't match, the viewer will show 0 subscribers even though data exists in the DB.

## Verification
1. Open `/page_editor_admin` → click 👥 button in toolbar
2. Modal opens, shows subscriber list
3. Click 🗑️ → red confirm row appears below the subscriber row
4. Click "Yes, Delete" → row disappears from DB + list refreshes
5. Click "Cancel" → confirm row closes, no deletion
6. Test pagination if >10 subscribers exist
```

---

---

# Step 3: Sticky Navigation UI Fix

## Goal
Fix the overlapping issue between the global main header and the secondary `StickyNav` component (e.g. the bar containing "おすすめランキング", "比較表", "詳細レビュー").

When users scroll down, both the `SiteHeader` (which has a fixed height of `h-16` / 64px + 1px border-b = 65px) and the `StickyNav` were trying to stick to `top: 0`, causing them to overlap.

## Fix Implementation

### 1. Adjusting Sticky Position
The `StickyNav` wrapper was updated to stick *below* the main header instead of at the very top of the screen. Since the main header is `h-16` (64px) + 1px `border-b` = 65px total, we apply `top-[65px]` to the sticky nav.

```diff
  const wrapperClass = sticky
-   ? `sticky top-0 z-40 ${isScrolled ? t.scrollShadow : ''}`
+   ? `sticky top-[65px] z-40 ${isScrolled ? t.scrollShadow : ''}`
    : '';
```

### 2. Adjusting Scroll Offset
When clicking an anchor link in the `StickyNav`, the page scrolls to that section using `window.scrollTo()`. Because the fixed area at the top of the screen is now taller (65px header + ~45px nav + padding), the scroll offset was increased to `160px` so that section titles aren't hidden behind the navigation bars when scrolled into view.

```diff
  const handleClick = (anchorId: string) => {
    setActiveId(anchorId);
    const el = document.getElementById(anchorId);
    if (el) {
-     const offset = sticky ? 120 : 80;
+     const offset = sticky ? 160 : 80; // 65px for header + ~45px for sticky nav + padding
      const y = el.getBoundingClientRect().top + window.scrollY - offset;
      window.scrollTo({ top: y, behavior: 'smooth' });
    }
  };
```

## Files Changed
- `components/sections/StickyNav.tsx`

This ensures a clean, professional scroll experience where the secondary navigation sits perfectly flush below the main navigation bar.

---

# Phase 9 Implementation Gotchas

During the implementation of Phase 9, we discovered two important edge cases that developers must account for:

## 1. Local Testing & Session Storage
The `PopupModal` uses `sessionStorage.getItem('popup_modal_shown')` to ensure it only fires once per session. However, **session storage survives page refreshes**. This makes it difficult to test on `localhost` because after the popup appears once, refreshing the page will NOT make it appear again.

**Fix:** Add an environment bypass for local development so it ignores the session storage rule while testing.
```tsx
const isDev = process.env.NODE_ENV === 'development';
const hasSession = sessionStorage.getItem(SESSION_KEY);

if (!isDev && hasSession) {
  return;
}
```

## 2. Destructuring Props in HeaderActions
When adding the Subscribers Viewer button to the editor toolbar (`app/page_editor_admin/editor-client.tsx`), you must pass the `onOpenSubscribers` function down into the `HeaderActions` component. 

A common pitfall is adding it to the TypeScript interface but forgetting to destructure it from the function arguments, resulting in a `ReferenceError: onOpenSubscribers is not defined` when the button is clicked.

```tsx
// ❌ WRONG: Missing from destructured args
function HeaderActions({
  saveStatus,
  onOpenNavManager,
}: {
  saveStatus: string;
  onOpenNavManager: () => void;
  onOpenSubscribers: () => void;
})

// ✅ CORRECT: Must be extracted
function HeaderActions({
  saveStatus,
  onOpenNavManager,
  onOpenSubscribers, // <--- Required!
}: { ... })
```

---

## ? Phase 9 Completion Verification

Run this checklist to confirm Phase 9 is fully implemented:

- [ ] `npx tsc --noEmit` passes with 0 errors
- [ ] "Popup Modal" appears in the "Promotions & CTA" category in Puck sidebar
- [ ] Dragging it onto the canvas shows a static preview card (NOT a popup)
- [ ] Editor detection uses `window.location.pathname.startsWith('/page_editor_admin')` � NOT `!!props.puck`
- [ ] Publish ? live site ? popup appears after configured delay seconds
- [ ] `sessionStorage` guard works � popup does NOT re-show after dismissal in same session
- [ ] Email submission calls `POST /api/newsletter/subscribe` and saves to DB with correct `siteId`
- [ ] Success state shows after subscription ? popup auto-closes after ~2s
- [ ] Body scroll is locked while popup is open
- [ ] Popup image uses `<img>` (NOT `next/image`) to avoid CDN hostname issues
- [ ] Delete Page modal (if included) shows SEO warnings and dynamic URL

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