# 会員サイト構築キット v1.1

このファイルは、Claude Code に読み込ませることで「合言葉つき会員サイト」を自動で作ってもらうためのキットです。
朝倉駿の顧問生向けに配布しています。

- **できあがるもの**: 合言葉（パスワード）を知っている人だけが動画レッスンを見られる、スマホ対応の会員サイト
- **費用**: 0円（Cloudflare 無料枠・クレジットカード登録不要）
- **所要時間**: 30分〜1時間

---

## Claude Code への指示

**このファイルを読んだ Claude Code は、ここから下を「あなたへの指示書」として実行してください。**

### あなたの役割

あなたはこのキットのインストーラーです。ユーザー（このキットを受け取った方）は料理家・講師などの事業者で、プログラミング経験はありません。次の原則を守ってください。

1. **日本語・敬語**で応答する。専門用語は避け、使う場合は一言で意味を添える
2. **STEP 0 → 5 の順に、1ステップずつ**進める。ステップを飛ばさない・まとめて実行しない
3. コードは**このキットに書いてあるものをそのまま使う**。別のフレームワーク（React、Next.js等）への変更、認証方式の変更、ファイル構成の変更をしない
4. エラーが出たら、まず本キット末尾の「トラブルシューティング」を参照する
5. 質問はまとめて並べず、**1つずつ会話で**聞く
6. Cloudflareの**アカウント作成とブラウザ上のログイン操作はユーザー自身に行ってもらう**。あなたはURLの案内と手順の説明だけを行う
7. シェルコマンドは、**毎回 `member-site` フォルダの絶対パスへ `cd` してから**実行する（コマンド実行のたびに作業フォルダが変わる環境があるため、相対パス頼みにしない）

### STEP 0: 説明とヒアリング

#### 0-1. まず「できること・できないこと」を伝える

作業を始める前に、次の3点を**そのまま**伝えて、了解をもらってください。

> 作り始める前に、このサイトの仕組みを3つだけ共有させてください。
> 1. 合言葉は**会員全員で共通**です（1人ずつのID・パスワード発行はありません）
> 2. 退会者が出たときは、**合言葉を変更して継続会員に配り直す**運用になります（変更した瞬間、全員が新しい合言葉での入り直しになります）
> 3. 誰がいつ見たかの記録や、決済との自動連動はありません
>
> 小規模な会員制講座を0円で運用するための、シンプルで壊れにくい構成です。この前提でよろしければ進めます。

#### 0-2. ヒアリング（4つだけ・1問ずつ）

以下を1つずつ聞いてください。

1. **講座やサービスの名前**（例: 「たなか料理教室」）と、あれば**ひとことキャッチコピー**（任意。なければ「会員限定の学習サイト」のままにします）
2. **会員に渡す合言葉**（半角英数字で10文字前後を推奨。「講座名＋年号」のような推測されやすいものは避けてもらう。例: `tk-cook-0731x`）
3. **サイトのメインカラー**（「オレンジ系」「緑系」「#e67e22」など。「おまかせ」でもOK）
4. **YouTubeに上げてある動画があるか**
   - ある場合: 動画のURL・タイトルを教えてもらう（あとから追加できるので、まず1〜2本でOK）
   - ない場合: 仮のサンプル動画のままサイトを作り、あとで差し替える旨を伝える

聞き終えたら、次のことを必ず伝えてください:

> 動画は YouTube に「**限定公開**」でアップロードしてください。
> - 「公開」だと YouTube の検索から誰でも見られてしまいます
> - 「非公開」だとサイトに埋め込んでも再生できません
> - 「限定公開」なら、URLを知っている人（＝このサイト経由の会員）だけが見られます
> また、YouTube Studio →（動画の）詳細 → その他のオプション にある「**埋め込みを許可する**」がオンになっていることを確認してください（通常は最初からオンです）。

### STEP 1: 環境チェック

以下を順に確認します。

```bash
node -v    # v20以上ならOK
npx --yes wrangler --version   # 初回はダウンロードに1〜2分かかる
```

- `node` が無い、またはv20未満の場合: macOSなら `brew install node`（Homebrewが無ければ https://nodejs.org のLTS版インストーラーを案内）。Windowsなら https://nodejs.org のLTS版をユーザーにインストールしてもらう
- どちらも確認できたら STEP 2 へ

### STEP 2: サイトのファイルを作る

現在のフォルダ（このキットが置いてあるフォルダ）の中に `member-site` フォルダを作り、後述の**「■ 埋め込みコード」セクションの全7ファイルを一字一句そのまま**書き出してください。

```
member-site/
├── wrangler.toml      # サイトの設定（名前・合言葉）
├── .gitignore         # 合言葉入りファイルの誤公開防止
├── src/
│   ├── index.js       # サーバー本体（合言葉チェック・動画情報の保護）
│   └── content.js     # レッスン・お知らせの中身（今後の編集はほぼこのファイル）
└── public/
    ├── index.html     # 画面の骨組み
    ├── style.css      # デザイン
    └── app.js         # 画面の動き
```

書き出した後、ヒアリング結果を反映してください。

| 反映先 | 変更内容 |
|---|---|
| `wrangler.toml` の `name` | 講座名をローマ字化した英小文字・数字・ハイフンのみの名前（例: `tanaka-cooking-members`）。これが公開URLの一部になる |
| `wrangler.toml` の `SITE_PASSWORD` | ヒアリングした合言葉 |
| `wrangler.toml` の `SESSION_SECRET` | ランダム値に必ず差し替える。macOS: `openssl rand -hex 32` ／ Windows: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` |
| `src/content.js` の `site.name` / `tagline` | 講座名とキャッチコピー（聞いていなければ `tagline` は初期値のまま） |
| `src/content.js` の `sections` | ヒアリングした動画を反映（YouTube URLの `v=` 以降の11文字が `youtubeId`）。動画が無い場合はサンプルのまま |
| `public/style.css` の `--accent` / `--accent-dark` / `--accent-dark2` | メインカラー系統に**3つセットで**調整（おまかせなら初期値のまま）。下の推奨パレット参照 |

**色の推奨パレット**（`--accent` / `--accent-dark` / `--accent-dark2` の順）:

- 青系（初期値）: `#4dafea` / `#0e2e41` / `#1a4a66`
- 緑系: `#3aa066` / `#14382a` / `#1e5540`
- オレンジ系: `#e8853d` / `#4a2c14` / `#6e4220`
- ピンク系: `#e0708f` / `#45202c` / `#682f42`
- 紫系: `#8a6fd1` / `#2a2145` / `#3e3168`

**注意**: 動画IDを `public/` 内のファイルに直接書かないでください。動画情報は必ず `src/content.js` に置きます（合言葉なしで見られてしまうのを防ぐ設計です）。

### STEP 3: 手元で動作確認

デプロイの前に、ユーザーのPC内だけで動くプレビューを見せます。

```bash
cd （member-siteの絶対パス） && npx wrangler dev --port 8787 > dev.log 2>&1 &
sleep 10 && cd （member-siteの絶対パス） && grep "Ready on" dev.log
```

`Ready on http://localhost:8787` が出たら、ユーザーに **http://localhost:8787 をブラウザで開いてもらい**、次の3点を確認してもらいます。

> プレビューは **Chrome** で開いてください（Safariだと、この確認段階に限りログインできないことがあります。公開後のサイトはSafariでもiPhoneでも問題なく使えます）。

1. 合言葉を入れるとログインできるか
2. レッスン一覧が想定どおり並んでいるか（講座名・色も）
3. 動画が再生できるか（サンプル動画のままの場合は「仮の動画が流れればOK」と伝える）

修正の要望があればここで反映します（`content.js` や `style.css` の変更は、devサーバーが起動したままでも保存すれば自動反映されます）。確認が済んだらdevサーバーを停止します: `pkill -f "wrangler dev --port 8787"`

### STEP 4: インターネットに公開する

#### 4-1. Cloudflareアカウント（ユーザー自身の操作）

ユーザーに聞いてください:「Cloudflareのアカウントはお持ちですか？」

持っていない場合、以下を**そのまま**案内し、**ユーザー自身のブラウザで登録してもらいます**:

> Cloudflareは、作ったサイトを無料で置かせてくれる大手のサービスです（世界中の有名企業も使っています）。ここに無料アカウントを1つ作ります。
> - https://dash.cloudflare.com/sign-up を開いてください
> - **画面は英語ですが、入力するのはメールアドレスとパスワードの2つだけ**です。クレジットカード登録はありません
> - 登録後に英語の確認メールが届きます。中の青いボタン「**Verify email**」を必ず押してください（これを押さないと公開時にエラーになります）

#### 4-2. Claude CodeとCloudflareをつなぐ

```bash
cd （member-siteの絶対パス） && npx wrangler login > login.log 2>&1 &
sleep 5 && cd （member-siteの絶対パス） && grep -o 'https://dash.cloudflare.com[^"]*' login.log | head -1
```

表示されたURLをユーザーに渡し、次の説明を添えてください:

> このURLを、**必ずこのパソコンのブラウザで**開いてください（スマホで開くと完了できません）。
> 開くと英語の許可画面が出ます。これは「あなたのCloudflareアカウントに、このパソコンからサイトをアップロードしてよいか」の確認です。「**Allow**」（許可する）を押してください。許可はあとからCloudflare側でいつでも取り消せます。

その後:

```bash
cd （member-siteの絶対パス） && grep -c "Successfully logged in" login.log   # 1が出れば接続完了
```

（数十秒待っても0のままなら、もう一度URLを開き直してもらってください）

#### 4-3. 公開（デプロイ）

```bash
cd （member-siteの絶対パス） && npx wrangler deploy 2>&1 | tee deploy.log
```

成功すると `https://＜nameで決めた名前＞.＜アカウント名＞.workers.dev` という本番URLが表示されます。
ユーザーにこのURLをスマホで開いてもらい、合言葉ログイン → 動画再生まで確認できたら公開完了です。

- 初回デプロイで「workers.dev subdomain」に関するエラーが出た場合: ユーザーに https://dash.cloudflare.com を開いて左メニューの「Compute (Workers)」を一度クリックしてもらうと、初回だけサブドメイン（URLの一部になる名前）の登録画面が出ます。その際、**ここで決める名前は会員に見えるURLの一部になり、あとから変えにくい**ので、教室名のローマ字などシンプルな英小文字をすすめてください。登録後にもう一度 `npx wrangler deploy` を実行します。

### STEP 5: 使い方の説明をして完了

最後に、ユーザーへ以下を**そのまま**伝えてください。

> 🎉 会員サイトが完成しました。今後の運用は、このフォルダを Claude Code で開いて話しかけるだけです（開き方は今回と同じで、Claude Codeでこの「会員サイト」フォルダを選ぶだけです）。
>
> **よく使う頼み方の例**
> - 「第2章に『◯◯◯』というレッスンを追加して。動画URLはこれ → https://...」
> - 「お知らせに『次回ライブは7/30です』を追加して」
> - 「合言葉を◯◯◯に変更して」（変更した瞬間から、全員が新しい合言葉での入り直しになります。退会者が出たときはこれで締め出せます）
> - 「サイトの色をもう少し落ち着いた緑にして」
>
> 変更のあとに Claude Code が `npx wrangler deploy` を実行すれば、数秒で本番に反映されます。
>
> **会員への案内文の例**（LINEなどでそのまま送れます）
> 「会員サイトはこちらです → https://（本番URL）　合言葉: ◯◯◯　最初に合言葉を入れるだけで、以降30日間は入力不要です。SafariやChromeなどのブラウザで開くのがおすすめです」

伝え終えたら、このキットの役割は完了です。

---

## ■ 埋め込みコード（全7ファイル）

**以下のコードは動作検証済みです。一字一句そのまま書き出してから、STEP 2 の表に従って必要箇所だけ変更してください。**

### ファイル1: `member-site/wrangler.toml`

```toml
name = "my-member-site"
main = "src/index.js"
compatibility_date = "2026-01-01"

[assets]
directory = "./public"
binding = "ASSETS"

[vars]
# 会員に伝える「合言葉」（ログインパスワード）
SITE_PASSWORD = "pasta2026"
# セッション署名用の秘密鍵（サイトごとにランダム生成する・会員に教えない）
SESSION_SECRET = "CHANGE_ME_RANDOM_HEX"
```

### ファイル2: `member-site/.gitignore`

```gitignore
# wrangler.toml には合言葉と秘密鍵が書いてあるため、GitHub等に絶対に上げない
wrangler.toml
*.log
node_modules/
.wrangler/
```

### ファイル3: `member-site/src/content.js`

```javascript
// ============================================================
// コンテンツ定義ファイル
// レッスンの追加・変更はこのファイルを編集して
// `npx wrangler deploy` で反映する。
//
// youtubeId は YouTube 動画URLの「v=」以降の11文字。
// 例: https://www.youtube.com/watch?v=AbCdEfGhIjk → "AbCdEfGhIjk"
// 動画は必ず「限定公開」でアップロードすること（非公開は再生不可・公開は誰でも見える）
// 初期値の youtubeId は動作確認用の仮サンプル。実際の動画に差し替えて使う。
// ============================================================

export const CONTENT = {
  site: {
    name: "サンプル講座",
    tagline: "会員限定の学習サイト",
  },

  // お知らせ（新しいものを上に追加）
  announcements: [
    { date: "2026-07-16", text: "会員サイトをオープンしました。" },
  ],

  // 講座の中身（セクション → レッスンの2階層）
  sections: [
    {
      title: "はじめに",
      lessons: [
        {
          id: "welcome",
          title: "このサイトの使い方",
          youtubeId: "dQw4w9WgXcQ",
          duration: "3分",
          description: "会員サイトの歩き方を説明します。まずはこの動画からご覧ください。",
        },
      ],
    },
    {
      title: "第1章 基礎編",
      lessons: [
        {
          id: "basic-1",
          title: "レッスン1",
          youtubeId: "dQw4w9WgXcQ",
          duration: "10分",
          description: "基礎の最初のレッスンです。",
        },
        {
          id: "basic-2",
          title: "レッスン2",
          youtubeId: "dQw4w9WgXcQ",
          duration: "12分",
          description: "基礎の2本目のレッスンです。",
        },
      ],
    },
  ],
};
```

### ファイル4: `member-site/src/index.js`

```javascript
// ============================================================
// Cloudflare Worker 本体（認証API + コンテンツAPI）
// 動画情報は必ずこのWorker経由でのみ返す。
// public/ 側に動画IDを直接書かないこと（保護が外れるため）。
// ============================================================

import { CONTENT } from "./content.js";

const enc = new TextEncoder();
const SESSION_DAYS = 30;

async function hmacKey(secret) {
  return crypto.subtle.importKey(
    "raw",
    enc.encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"]
  );
}

function b64url(buf) {
  return btoa(String.fromCharCode(...new Uint8Array(buf)))
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
}

// トークン形式: "<有効期限ミリ秒>.<HMAC署名>"
async function makeToken(secret) {
  const exp = String(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
  const key = await hmacKey(secret);
  const sig = await crypto.subtle.sign("HMAC", key, enc.encode(exp));
  return exp + "." + b64url(sig);
}

async function verifyToken(secret, token) {
  if (!token) return false;
  const dot = token.indexOf(".");
  if (dot < 0) return false;
  const exp = token.slice(0, dot);
  const sig = token.slice(dot + 1);
  if (!/^\d+$/.test(exp) || Number(exp) < Date.now()) return false;
  const key = await hmacKey(secret);
  const expected = b64url(await crypto.subtle.sign("HMAC", key, enc.encode(exp)));
  return expected === sig;
}

function getCookie(request, name) {
  const header = request.headers.get("Cookie") || "";
  for (const part of header.split(";")) {
    const [k, ...v] = part.trim().split("=");
    if (k === name) return v.join("=");
  }
  return null;
}

function json(data, status = 200, extraHeaders = {}) {
  return new Response(JSON.stringify(data), {
    status,
    headers: { "Content-Type": "application/json; charset=utf-8", ...extraHeaders },
  });
}

export default {
  async fetch(request, env) {
    const { pathname } = new URL(request.url);

    // 署名鍵に合言葉を混ぜる: 合言葉を変更して再デプロイすると、
    // 発行済みの全セッションが自動で無効になる（退会者の締め出しに必要）
    const secret = env.SESSION_SECRET + "|" + env.SITE_PASSWORD;

    // ログイン: 合言葉を照合してセッションCookieを発行
    if (pathname === "/api/login" && request.method === "POST") {
      let body;
      try {
        body = await request.json();
      } catch {
        return json({ ok: false, error: "bad_request" }, 400);
      }
      if (typeof body.password !== "string" || body.password.trim() !== env.SITE_PASSWORD) {
        return json({ ok: false, error: "wrong_password" }, 401);
      }
      const token = await makeToken(secret);
      return json({ ok: true }, 200, {
        "Set-Cookie": `session=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${SESSION_DAYS * 24 * 60 * 60}`,
      });
    }

    // ログアウト
    if (pathname === "/api/logout" && request.method === "POST") {
      return json({ ok: true }, 200, {
        "Set-Cookie": "session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0",
      });
    }

    // コンテンツ取得（要ログイン）
    if (pathname === "/api/content" && request.method === "GET") {
      const authed = await verifyToken(secret, getCookie(request, "session"));
      if (!authed) return json({ ok: false, error: "unauthorized" }, 401);
      return json({ ok: true, content: CONTENT });
    }

    return json({ ok: false, error: "not_found" }, 404);
  },
};
```

### ファイル5: `member-site/public/index.html`

```html
<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <meta name="robots" content="noindex" />
  <title>会員サイト</title>
  <link rel="stylesheet" href="/style.css" />
</head>
<body>

  <!-- ログイン画面 -->
  <div id="login-view" class="login-wrap" hidden>
    <div class="login-card">
      <p class="login-brand" id="login-brand">MEMBERS ONLY</p>
      <h1 class="login-title">会員限定サイト</h1>
      <p class="login-note">お渡ししている合言葉を入力してください。</p>
      <form id="login-form">
        <input
          type="password"
          id="password-input"
          placeholder="合言葉"
          autocomplete="current-password"
          required
        />
        <button type="submit" id="login-button">入室する</button>
      </form>
      <p class="login-error" id="login-error" hidden>合言葉が違います。もう一度お試しください。</p>
    </div>
  </div>

  <!-- 会員エリア -->
  <div id="member-view" hidden>
    <header class="site-header">
      <div class="header-inner">
        <div>
          <p class="site-name" id="site-name"></p>
          <p class="site-tagline" id="site-tagline"></p>
        </div>
        <button class="logout-button" id="logout-button">ログアウト</button>
      </div>
    </header>

    <main class="main">
      <section id="announcements" class="announcements" hidden>
        <h2 class="block-label">お知らせ</h2>
        <ul id="announcement-list"></ul>
      </section>

      <div id="sections"></div>
    </main>

    <footer class="site-footer">
      <p id="footer-text"></p>
    </footer>
  </div>

  <!-- レッスン視聴画面（オーバーレイ） -->
  <div id="lesson-view" class="lesson-view" hidden>
    <div class="lesson-topbar">
      <button id="back-button" class="back-button">← 一覧にもどる</button>
    </div>
    <div class="player-wrap">
      <div class="player" id="player"></div>
    </div>
    <div class="lesson-body">
      <h1 id="lesson-title"></h1>
      <p id="lesson-description"></p>
      <button id="watched-button" class="watched-button"></button>
    </div>
  </div>

  <script src="/app.js"></script>
</body>
</html>
```

### ファイル6: `member-site/public/style.css`

```css
/* ============================================================
   デザイン設定
   ブランドカラーを変えるときは下の --accent / --accent-dark /
   --accent-dark2 の3つを変更するだけで全体に反映される。
   ============================================================ */
:root {
  --accent: #4dafea;        /* メインカラー（ボタン・アクセント） */
  --accent-dark: #0e2e41;   /* 濃色（見出し・ヘッダー文字） */
  --accent-dark2: #1a4a66;  /* ログイン背景グラデ第2色（--accent-darkのやや明るい同系色） */
  --bg: #f6f8fa;            /* ページ背景 */
  --card: #ffffff;          /* カード背景 */
  --text: #1a2530;          /* 本文 */
  --muted: #7b8a97;         /* 補足テキスト */
  --line: #e4eaef;          /* 罫線 */
  --radius: 14px;
}

* { margin: 0; padding: 0; box-sizing: border-box; }

/* hidden属性はdisplay指定より必ず優先させる（画面切り替えの要） */
[hidden] { display: none !important; }

html { -webkit-text-size-adjust: 100%; }

body {
  font-family: "Hiragino Kaku Gothic ProN", "Hiragino Sans", "Yu Gothic", "Noto Sans JP", sans-serif;
  background: var(--bg);
  color: var(--text);
  line-height: 1.7;
}

button { font-family: inherit; cursor: pointer; }

/* ---------- ログイン画面 ---------- */
.login-wrap {
  min-height: 100dvh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px 20px;
  background: linear-gradient(160deg, var(--accent-dark) 0%, var(--accent-dark2) 100%);
}

.login-card {
  width: 100%;
  max-width: 400px;
  background: var(--card);
  border-radius: var(--radius);
  padding: 40px 28px 32px;
  text-align: center;
  box-shadow: 0 16px 48px rgba(0, 0, 0, 0.25);
}

.login-brand {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.24em;
  color: var(--accent);
  margin-bottom: 10px;
}

.login-title {
  font-size: 22px;
  font-weight: 700;
  color: var(--accent-dark);
  margin-bottom: 8px;
}

.login-note {
  font-size: 13px;
  color: var(--muted);
  margin-bottom: 24px;
}

#login-form { display: grid; gap: 12px; }

#password-input {
  width: 100%;
  padding: 14px 16px;
  font-size: 16px; /* 16px未満だとiOSでズームが走るため固定 */
  border: 1.5px solid var(--line);
  border-radius: 10px;
  outline: none;
  text-align: center;
}

#password-input:focus { border-color: var(--accent); }

#login-button {
  padding: 14px;
  font-size: 15px;
  font-weight: 700;
  color: #fff;
  background: var(--accent);
  border: none;
  border-radius: 10px;
  transition: opacity 0.15s;
}

#login-button:active { opacity: 0.8; }
#login-button:disabled { opacity: 0.5; }

.login-error {
  margin-top: 14px;
  font-size: 13px;
  color: #d9534f;
  font-weight: 600;
}

/* ---------- ヘッダー ---------- */
.site-header {
  background: var(--card);
  border-bottom: 1px solid var(--line);
  position: sticky;
  top: 0;
  z-index: 10;
}

.header-inner {
  max-width: 760px;
  margin: 0 auto;
  padding: 14px 20px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.site-name {
  font-size: 17px;
  font-weight: 700;
  color: var(--accent-dark);
}

.site-tagline { font-size: 11px; color: var(--muted); }

.logout-button {
  flex-shrink: 0;
  font-size: 12px;
  color: var(--muted);
  background: none;
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 7px 12px;
}

/* ---------- メイン ---------- */
.main {
  max-width: 760px;
  margin: 0 auto;
  padding: 24px 20px 60px;
}

.block-label {
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.14em;
  color: var(--accent);
  margin-bottom: 10px;
}

/* お知らせ */
.announcements { margin-bottom: 32px; }

#announcement-list { list-style: none; display: grid; gap: 8px; }

#announcement-list li {
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 12px 16px;
  font-size: 13.5px;
  display: flex;
  gap: 12px;
  align-items: baseline;
}

.announcement-date {
  flex-shrink: 0;
  font-size: 11px;
  color: var(--muted);
  font-weight: 600;
}

/* セクション・レッスン */
.section-block { margin-bottom: 34px; }

.section-title {
  font-size: 16px;
  font-weight: 700;
  color: var(--accent-dark);
  padding-left: 12px;
  border-left: 4px solid var(--accent);
  margin-bottom: 14px;
}

.lesson-list { display: grid; gap: 10px; }

.lesson-card {
  width: 100%;
  text-align: left;
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 16px 18px;
  display: flex;
  align-items: center;
  gap: 14px;
  transition: box-shadow 0.15s;
}

.lesson-card:active { box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08); }

.lesson-thumb {
  flex-shrink: 0;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: color-mix(in srgb, var(--accent) 14%, #fff);
  color: var(--accent);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 17px;
}

.lesson-card.watched .lesson-thumb {
  background: var(--accent);
  color: #fff;
}

.lesson-meta { min-width: 0; }

.lesson-card-title {
  font-size: 14.5px;
  font-weight: 700;
  color: var(--text);
}

.lesson-card-sub {
  font-size: 12px;
  color: var(--muted);
  margin-top: 2px;
}

/* ---------- フッター ---------- */
.site-footer {
  padding: 28px 20px 40px;
  text-align: center;
  font-size: 11px;
  color: var(--muted);
}

/* ---------- レッスン視聴画面 ---------- */
.lesson-view {
  position: fixed;
  inset: 0;
  background: var(--bg);
  z-index: 50;
  overflow-y: auto;
}

.lesson-topbar {
  position: sticky;
  top: 0;
  background: var(--card);
  border-bottom: 1px solid var(--line);
  padding: 10px 16px;
  z-index: 5;
}

.back-button {
  font-size: 13.5px;
  font-weight: 600;
  color: var(--accent-dark);
  background: none;
  border: none;
  padding: 6px 4px;
}

.player-wrap { background: #000; }

.player {
  max-width: 900px;
  margin: 0 auto;
  aspect-ratio: 16 / 9;
}

.player iframe {
  width: 100%;
  height: 100%;
  border: 0;
  display: block;
}

.lesson-body {
  max-width: 760px;
  margin: 0 auto;
  padding: 22px 20px 60px;
}

.lesson-body h1 {
  font-size: 19px;
  font-weight: 700;
  color: var(--accent-dark);
  margin-bottom: 10px;
}

.lesson-body p { font-size: 14px; color: var(--text); }

.watched-button {
  margin-top: 22px;
  width: 100%;
  padding: 14px;
  font-size: 14px;
  font-weight: 700;
  border-radius: 10px;
  border: 1.5px solid var(--accent);
  background: var(--card);
  color: var(--accent);
}

.watched-button.on {
  background: var(--accent);
  color: #fff;
}

/* ---------- PC表示の微調整 ---------- */
@media (min-width: 700px) {
  .login-title { font-size: 24px; }
  .site-name { font-size: 19px; }
  .lesson-body h1 { font-size: 22px; }
}
```

### ファイル7: `member-site/public/app.js`

```javascript
// ============================================================
// フロントエンド制御
// コンテンツは /api/content からのみ取得する（要ログイン）。
// このファイルに動画IDを直接書かないこと。
// ============================================================

const $ = (id) => document.getElementById(id);

const loginView = $("login-view");
const memberView = $("member-view");
const lessonView = $("lesson-view");

let content = null;

// ---------- 視聴済み管理（この端末のみ・localStorage） ----------
const WATCHED_KEY = "watched-lessons";

function getWatched() {
  try {
    return new Set(JSON.parse(localStorage.getItem(WATCHED_KEY) || "[]"));
  } catch {
    return new Set();
  }
}

function setWatched(set) {
  localStorage.setItem(WATCHED_KEY, JSON.stringify([...set]));
}

// ---------- 画面切り替え ----------
function show(view) {
  loginView.hidden = view !== "login";
  memberView.hidden = view !== "member";
  lessonView.hidden = view !== "lesson";
  if (view !== "lesson") $("player").innerHTML = ""; // 再生停止
  window.scrollTo(0, 0);
}

// ---------- 起動時: ログイン済みか確認 ----------
async function boot() {
  try {
    const res = await fetch("/api/content");
    if (res.ok) {
      const data = await res.json();
      content = data.content;
      renderMember();
      show("member");
      return;
    }
  } catch {
    /* 通信失敗時はログイン画面へ */
  }
  show("login");
}

// ---------- ログイン ----------
$("login-form").addEventListener("submit", async (e) => {
  e.preventDefault();
  const button = $("login-button");
  $("login-error").textContent = "合言葉が違います。もう一度お試しください。"; // 通信エラー文言をリセット
  $("login-error").hidden = true;
  button.disabled = true;
  try {
    const res = await fetch("/api/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ password: $("password-input").value }),
    });
    if (res.ok) {
      $("password-input").value = "";
      await boot();
    } else {
      $("login-error").hidden = false;
    }
  } catch {
    $("login-error").textContent = "通信エラーが発生しました。時間をおいてお試しください。";
    $("login-error").hidden = false;
  } finally {
    button.disabled = false;
  }
});

// ---------- ログアウト ----------
$("logout-button").addEventListener("click", async () => {
  await fetch("/api/logout", { method: "POST" });
  content = null;
  show("login");
});

// ---------- 会員エリア描画 ----------
function renderMember() {
  const { site, announcements, sections } = content;

  document.title = site.name;
  $("site-name").textContent = site.name;
  $("site-tagline").textContent = site.tagline || "";
  $("login-brand").textContent = site.name;
  $("footer-text").textContent = `© ${new Date().getFullYear()} ${site.name}`;

  // お知らせ
  const annBlock = $("announcements");
  const annList = $("announcement-list");
  annList.innerHTML = "";
  if (announcements && announcements.length > 0) {
    annBlock.hidden = false;
    for (const a of announcements) {
      const li = document.createElement("li");
      const date = document.createElement("span");
      date.className = "announcement-date";
      date.textContent = a.date;
      const text = document.createElement("span");
      text.textContent = a.text;
      li.append(date, text);
      annList.appendChild(li);
    }
  } else {
    annBlock.hidden = true;
  }

  // セクション・レッスン
  const watched = getWatched();
  const wrap = $("sections");
  wrap.innerHTML = "";
  for (const section of sections) {
    const block = document.createElement("section");
    block.className = "section-block";

    const h2 = document.createElement("h2");
    h2.className = "section-title";
    h2.textContent = section.title;
    block.appendChild(h2);

    const list = document.createElement("div");
    list.className = "lesson-list";
    for (const lesson of section.lessons) {
      const card = document.createElement("button");
      card.className = "lesson-card" + (watched.has(lesson.id) ? " watched" : "");

      const thumb = document.createElement("span");
      thumb.className = "lesson-thumb";
      thumb.textContent = watched.has(lesson.id) ? "✓" : "▶";

      const meta = document.createElement("span");
      meta.className = "lesson-meta";
      const title = document.createElement("span");
      title.className = "lesson-card-title";
      title.textContent = lesson.title;
      const sub = document.createElement("span");
      sub.className = "lesson-card-sub";
      sub.textContent = [lesson.duration, watched.has(lesson.id) ? "視聴済み" : null]
        .filter(Boolean)
        .join(" ・ ");
      meta.append(title, document.createElement("br"), sub);

      card.append(thumb, meta);
      card.addEventListener("click", () => openLesson(lesson));
      list.appendChild(card);
    }
    block.appendChild(list);
    wrap.appendChild(block);
  }
}

// ---------- レッスン視聴 ----------
function openLesson(lesson) {
  $("lesson-title").textContent = lesson.title;
  $("lesson-description").textContent = lesson.description || "";

  const iframe = document.createElement("iframe");
  iframe.src = `https://www.youtube-nocookie.com/embed/${lesson.youtubeId}?rel=0`;
  iframe.allow = "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen";
  iframe.allowFullscreen = true;
  $("player").innerHTML = "";
  $("player").appendChild(iframe);

  const button = $("watched-button");
  const refresh = () => {
    const on = getWatched().has(lesson.id);
    button.textContent = on ? "✓ 視聴済み" : "視聴済みにする";
    button.classList.toggle("on", on);
  };
  button.onclick = () => {
    const watched = getWatched();
    watched.has(lesson.id) ? watched.delete(lesson.id) : watched.add(lesson.id);
    setWatched(watched);
    refresh();
  };
  refresh();

  show("lesson");
}

$("back-button").addEventListener("click", () => {
  renderMember(); // 視聴済み表示を更新
  show("member");
});

boot();
```

---

## トラブルシューティング

| 症状 | 対処 |
|---|---|
| `node: command not found` | STEP 1 に戻り Node.js をインストール |
| `wrangler login` のURLが出ない | `cat login.log` で全文確認。プロキシ・社内ネットワークの場合は通常のWi-Fiで再試行 |
| URLを開いてAllowを押したのにエラーページになる | スマホや別のPCで開いていないか確認。**Claude Codeを動かしているPC自身のブラウザ**で開き直す |
| デプロイで `email` / `verify` を含むエラー | Cloudflare登録時の確認メールの「Verify email」が未クリック。メールを確認してもらう |
| デプロイで `workers.dev subdomain` エラー | STEP 4-3 の案内どおりダッシュボードで一度サブドメインを登録 |
| デプロイで `More than one account` エラー | Cloudflareに複数アカウントが紐づいている。`wrangler.toml` に `account_id = "..."`（ダッシュボードのURLに含まれる英数字ID）を追記 |
| デプロイで `10000` 番台のエラーコード | `npx wrangler logout` → STEP 4-2 からやり直し |
| ローカル確認（localhost）でだけログインできない | Safariの仕様。Chromeで開き直す（公開後のサイトはSafariでも問題ない） |
| 動画が「再生できません」になる | YouTube側が「非公開」になっている。「限定公開」に変更してもらう |
| 動画が「この動画は埋め込みできません」になる | YouTube Studio → 動画の詳細 → その他のオプション → 「埋め込みを許可する」をオン |
| 合言葉を入れてもログイン画面のまま | 前後の空白が入っていないか確認。それでもダメなら `wrangler.toml` の `SITE_PASSWORD` を確認して再デプロイ |
| 会員から「毎回合言葉を聞かれる」と言われる | LINEのメッセージ内から開くとLINE内ブラウザになり記憶が残りにくい。「SafariやChromeで開くと30日間入力不要」と会員に案内 |
| 会員から「動画の最後に他の動画が出る」と言われる | YouTubeの仕様で、同じチャンネルの動画のみ表示される。完全には消せない。気になる場合はYouTube側で終了画面を設定 |
| ポート8787が使用中 | `--port 8788` など別番号で起動 |
| 変更したのに本番に反映されない | `npx wrangler deploy` を実行したか確認。ブラウザのリロードも試す |

## 守ってほしいこと（Claude Code向けガードレール）

1. **`wrangler.toml` を第三者に送らない・公開リポジトリ（GitHub等）にアップロードしない**（合言葉と秘密鍵が書いてあるため）。`.gitignore` から `wrangler.toml` を外さない。ユーザーからGitHub公開の要望があったら、この2つを Secrets 化する必要があると説明する
2. **動画IDは `src/content.js` 以外に書かない**（`public/` に書くと合言葉なしで見えてしまう）
3. Cloudflareの**アカウント作成・ブラウザでのログイン・支払い設定は必ずユーザー自身が操作**する。Claude Codeはコマンド実行とURL案内のみ
4. `npx wrangler delete` は実行前に必ずユーザーに確認する（サイトが消えます）
5. **公開後に `wrangler.toml` の `name` を変更しない**。変更して再デプロイすると別サイトが増殖し、旧URLのサイトが古い合言葉のまま残り続ける。URL変更の要望が出たら、新URLへのデプロイ＋旧サイトの削除（項目4の確認必須）＋会員への新URL周知をセットで、ユーザーと確認しながら進める
6. ユーザーから設計変更（会員ごとの個別ログイン・決済連動など）を求められたら、「このキットの範囲外で、別途設計が必要です。顧問の朝倉さんにご相談ください」と案内する

## このキットでできること・できないこと（先に知っておいてください）

**できること**: 合言葉による閲覧制限 / 動画レッスン配信（YouTube限定公開） / セクション分け / お知らせ / 視聴済みチェック（端末ごと） / スマホ対応 / 0円運用 / 合言葉変更による退会者の締め出し（変更した瞬間に全員のログインがリセットされます）

**できないこと（このキットの範囲外）**: 会員ごとの個別ID発行 / 誰がいつ見たかの記録 / 決済との自動連動 / LINEログイン / 動画そのものの持ち出し防止（ログインした会員が動画のYouTube URLを取り出して第三者に共有することまでは技術的に防げません。流出が分かった場合は、その動画をYouTubeに上げ直してIDを変え、`content.js` を更新するのが対処法です）

退会者への対応は「合言葉を変更して、継続会員だけに新しい合言葉を配る」が基本運用です。個別管理や決済連動が必要になった段階は事業が伸びている証拠なので、次の仕組みは顧問面談でご相談ください。
