# 機能設計書 71-Portal

## 概要

本ドキュメントは、Ghost CMSのフロントエンド会員ウィジェット「Portal」の機能設計書である。PortalはReactベースのシングルページアプリケーションとして実装されており、サイト訪問者向けのメンバー登録、ログイン、アカウント管理機能を提供する。

### 本機能の処理概要

Portalは、Ghostサイトのフロントエンドに埋め込まれるウィジェットで、会員（メンバー）向けの各種機能をポップアップモーダルとして提供する。サイトテーマに依存せず、一貫したUI/UXでメンバーシップ機能を提供できる点が特徴である。

**業務上の目的・背景**：従来のブログプラットフォームでは、会員登録やログイン機能を実装するにはテーマ側で個別対応が必要であった。Portalは、テーマに関係なく統一された会員管理UIを提供することで、サイト運営者の実装負荷を軽減し、読者に対しては一貫した体験を提供する。これにより、サブスクリプションビジネスモデルへの移行を容易にする。

**機能の利用シーン**：
- サイト訪問者がメンバー登録を行う際
- 既存メンバーがログインする際
- メンバーがアカウント情報（プロフィール、購読プラン、ニュースレター設定等）を管理する際
- 有料プランへのアップグレードや決済を行う際
- ニュースレターの購読・解除を行う際
- オファー（割引クーポン）を適用する際

**主要な処理内容**：
1. サイトデータ・メンバーセッションの初期化とAPI連携
2. 会員登録（無料/有料プラン選択、Stripe決済連携）
3. マジックリンク認証によるパスワードレスログイン
4. ワンタイムコード（OTC）認証
5. アカウントホーム画面でのプロフィール・購読管理
6. プラン変更・解約処理
7. ニュースレター購読設定の管理
8. レコメンデーション機能との連携
9. オーディエンスフィードバック（いいね/悪いね）の収集

**関連システム・外部連携**：
- Ghost Members API（`/members/api/`）：認証・会員情報管理
- Ghost Content API：サイト設定・Tier情報取得
- Stripe：有料プラン決済処理
- Sentry：エラートラッキング
- FirstPromoter：アフィリエイト追跡

**権限による制御**：
- 未ログイン状態：サインアップ/サインインページのみアクセス可能
- ログイン状態（無料会員）：アカウント管理、プラン選択が可能
- ログイン状態（有料会員）：上記に加え、Stripe Billing Portal経由での支払い情報管理が可能

## 関連画面

| 画面No | 画面名 | 関連種別 | 関連する操作・処理 |
|--------|--------|----------|------------------|
| 46 | ポータル設定 | 主機能 | 会員ポータルのカスタマイズ設定 |
| 78 | サインインページ | 主機能 | 会員のメールアドレスによるサインイン |
| 79 | サインアップページ | 主機能 | 新規会員の登録処理 |
| 80 | アカウントホームページ | 主機能 | 会員アカウント情報の表示 |
| 81 | プラン選択ページ | 主機能 | 購読プランの選択・変更 |
| 82 | プロフィール編集ページ | 主機能 | 会員プロフィール情報の編集 |
| 83 | メール設定ページ | 主機能 | メール購読設定の管理 |
| 84 | ニュースレター選択ページ | 主機能 | 購読ニュースレターの選択 |
| 85 | 購読解除ページ | 主機能 | メール購読の解除処理 |
| 86 | マジックリンクページ | 主機能 | マジックリンク送信完了の表示 |
| 87 | オファーページ | 主機能 | 特別オファーの表示・適用 |
| 88 | フィードバックページ | 主機能 | 投稿への評価送信 |
| 89 | メール停止通知ページ | 主機能 | メール配信停止状態の通知表示 |
| 90 | サポートページ | 主機能 | サポートリクエストの送信 |
| 91 | レコメンデーションページ | 主機能 | おすすめサイトの表示 |
| 92 | ローディングページ | 主機能 | 読み込み中状態の表示 |

## 機能種別

CRUD操作 / 認証処理 / 外部API連携 / UI表示

## 入力仕様

### 入力パラメータ

| パラメータ名 | 型 | 必須 | 説明 | バリデーション |
|-------------|-----|-----|------|---------------|
| email | string | Yes | メンバーのメールアドレス | メール形式チェック |
| name | string | No | メンバー名 | 文字列長制限 |
| plan | string | No | 選択プラン（free/monthly/yearly/priceId） | 有効なプランID |
| tierId | string | No | Tier ID | 有効なTier ID |
| cadence | string | No | 支払い周期（month/year） | month または year |
| offerId | string | No | オファーID | 有効なオファーID |
| newsletters | array | No | 購読ニュースレターのリスト | 有効なニュースレターID配列 |
| subscriptionId | string | No | サブスクリプションID | 有効なサブスクリプションID |
| postId | string | No | フィードバック対象の記事ID | 有効な記事ID |
| score | number | No | フィードバックスコア（0または1） | 0 または 1 |

### 入力データソース

- ブラウザURL（ハッシュフラグメント）：`#/portal/signup`, `#/portal/account`等
- HTMLデータ属性：`data-portal`, `data-ghost`, `data-key`等
- フォーム入力：メールアドレス、名前等
- Ghost Content API：サイト設定、Tier情報、ニュースレター情報
- Ghost Members API：メンバーセッション情報

## 出力仕様

### 出力データ

| 項目名 | 型 | 説明 |
|--------|-----|------|
| showPopup | boolean | ポップアップ表示状態 |
| page | string | 現在表示中のページ名 |
| member | object | ログインメンバー情報（null可） |
| site | object | サイト設定情報 |
| offers | array | 適用可能なオファー一覧 |
| popupNotification | object | 通知メッセージ情報 |
| action | string | 現在実行中のアクション状態 |

### 出力先

- DOM（iframeベースのポップアップモーダル）
- ブラウザURL（履歴操作）
- 外部サービス（Stripe決済ページへのリダイレクト）

## 処理フロー

### 処理シーケンス

```
1. 初期化（initSetup）
   └─ スクリプトタグからサイトURL・APIキーを取得
   └─ Ghost APIからサイト・メンバー・オファー情報をフェッチ
   └─ プレビュー/リンクデータを解析
   └─ i18n言語設定を初期化
   └─ Sentry・FirstPromoterのセットアップ

2. ページ表示制御
   └─ URL・データ属性からページを決定
   └─ ログイン状態に応じてデフォルトページを設定
   └─ PopupModalコンポーネントでiframe内にレンダリング

3. アクション実行（dispatchAction）
   └─ ActionHandlerでアクション種別に応じた処理を実行
   └─ API呼び出し結果に基づいて状態を更新
   └─ 成功/失敗の通知を表示

4. 認証フロー
   └─ サインアップ：マジックリンク送信 → メール確認 → ログイン
   └─ サインイン：マジックリンク送信 → OTC入力 or メールリンククリック → ログイン
   └─ 有料登録：Stripeチェックアウトセッション作成 → リダイレクト → コールバック

5. アカウント管理
   └─ プロフィール更新：API経由でメンバー情報を更新
   └─ プラン変更：Stripe経由でサブスクリプションを更新
   └─ 請求管理：Stripe Billing Portalへリダイレクト
```

### フローチャート

```mermaid
flowchart TD
    A[Portal初期化] --> B{メンバーログイン済み?}
    B -->|Yes| C[アカウントホーム表示]
    B -->|No| D{招待制サイト?}
    D -->|Yes| E[サインイン画面表示]
    D -->|No| F[サインアップ画面表示]

    F --> G{プラン選択}
    G -->|無料| H[マジックリンク送信]
    G -->|有料| I[Stripeチェックアウト]

    H --> J[マジックリンクページ]
    J --> K{OTC入力 or メールクリック}
    K -->|認証成功| L[メンバー作成/ログイン]

    I --> M[Stripe決済ページ]
    M -->|成功| N[コールバック処理]
    N --> L

    L --> O{レコメンデーション有効?}
    O -->|Yes| P[レコメンデーション表示]
    O -->|No| C
    P --> C

    C --> Q{アクション選択}
    Q -->|プロフィール編集| R[プロフィールページ]
    Q -->|プラン変更| S[プランページ]
    Q -->|ニュースレター設定| T[メール設定ページ]
    Q -->|ログアウト| U[セッション終了]
```

## ビジネスルール

### 業務ルール

| ルールNo | ルール名 | 内容 | 適用条件 |
|---------|---------|------|---------|
| BR-71-01 | 招待制サイト制御 | 招待制サイトではサインアップ不可、サインインのみ | site.members_signup_access === 'invite' |
| BR-71-02 | 無料プラン制御 | 無料プランが無効な場合は有料プランのみ選択可能 | portal_plans に 'free' が含まれない |
| BR-71-03 | 有料プラン制御 | Stripe未連携時は有料プラン選択不可 | site.is_stripe_configured === false |
| BR-71-04 | 補完会員制御 | 補完会員（comped）は一部のプラン変更が制限される | member.subscription.status === 'comped' |
| BR-71-05 | マジックリンク有効期限 | マジックリンクは一定時間で無効化 | リンク発行から設定時間経過後 |
| BR-71-06 | OTC認証制限 | OTC入力は規定回数失敗で無効化 | 失敗回数が上限を超過 |

### 計算ロジック

- 年額プラン割引率計算：`((monthlyPrice * 12) - yearlyPrice) / (monthlyPrice * 12) * 100`
- サブスクリプション更新日計算：current_period_end フィールドから取得

## データベース操作仕様

### 操作別データベース影響一覧

| 操作 | 対象テーブル | 操作種別 | 概要 |
|-----|-------------|---------|------|
| サインアップ | members | INSERT | 新規メンバーレコード作成 |
| プロフィール更新 | members | UPDATE | 名前・メール等の更新 |
| ニュースレター設定 | members_newsletters | UPDATE | 購読設定の更新 |
| サブスクリプション更新 | members_stripe_customers_subscriptions | UPDATE | Stripe連携情報の更新 |
| フィードバック送信 | members_feedback | INSERT | フィードバックレコード作成 |

### テーブル別操作詳細

#### members

| 操作 | 項目（カラム名） | 更新値・取得条件 | 備考 |
|-----|-----------------|-----------------|------|
| INSERT | email, name, status | フォーム入力値 | サインアップ時 |
| UPDATE | name | フォーム入力値 | プロフィール更新時 |
| SELECT | * | email = 入力値 | セッション取得時 |

## エラー処理

### エラーケース一覧

| エラーコード | エラー種別 | 発生条件 | 対処方法 |
|------------|----------|---------|---------|
| signin:failed | 認証エラー | マジックリンク送信失敗 | エラーメッセージ表示、再試行促進 |
| signup:failed | 登録エラー | 会員登録処理失敗 | エラーメッセージ表示、入力値確認促進 |
| checkoutPlan:failed | 決済エラー | Stripeセッション作成失敗 | エラーメッセージ表示、再試行促進 |
| updateSubscription:failed | 更新エラー | サブスクリプション更新失敗 | エラーメッセージ表示、サポート案内 |
| verifyOTC:failed | 認証エラー | OTC検証失敗 | エラーメッセージ表示、再入力促進 |

### リトライ仕様

- API呼び出し失敗時：自動リトライなし、ユーザー操作による再試行
- ネットワークエラー時：ユーザーへのエラー表示とリトライ促進

## トランザクション仕様

- Portalはクライアントサイドアプリケーションのため、直接的なDBトランザクション制御は行わない
- サーバーサイド（Ghost Core）のMembers APIがトランザクション管理を担当
- Stripe連携処理はStripe側のトランザクション管理に依存

## パフォーマンス要件

- 初期ロード時間：2秒以内（API呼び出し含む）
- ポップアップ表示：100ms以内
- アクション応答：500ms以内（API応答時間除く）
- バンドルサイズ：UMDビルドで最小化を維持

## セキュリティ考慮事項

- Integrity Token：不正リクエスト防止のためのトークン検証
- CSRF対策：same-origin credentialsによるセッション管理
- XSS対策：DOMPurifyによるHTML sanitization
- Honeypot：ボット対策用の隠しフィールド
- Sentry連携：allowUrlsによる正規ソースからのエラーのみ収集

## 備考

- Portalは`apps/portal/`ディレクトリに配置されたReactアプリケーション
- ビルド成果物はUMD形式で`umd/`ディレクトリに出力
- テーマでは`{{ghost_head}}`ヘルパーで自動的に読み込まれる
- 多言語対応は`@tryghost/i18n`パッケージと連携

---

## コードリーディングガイド

本機能を理解するために参照すべきファイルと、推奨する読み解き順序を以下に示す。

### 推奨読解順序

#### Step 1: データ構造を理解する

まず、Portalで使用される主要なデータ構造を理解することが重要である。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 1-1 | fixtures.js | `apps/portal/src/utils/fixtures.js` | 開発・テスト用のモックデータ構造（site, member, offer等） |
| 1-2 | helpers.js | `apps/portal/src/utils/helpers.js` | データ変換・抽出ユーティリティ関数群 |

**読解のコツ**: Fixturesファイルには実際のAPIレスポンス構造に準拠したモックデータが定義されている。これを参照することで、site（サイト設定）、member（会員情報）、offer（オファー情報）等の構造を把握できる。

#### Step 2: エントリーポイントを理解する

処理の起点となるファイル・関数を特定する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 2-1 | index.js | `apps/portal/src/index.js` | アプリケーションのエントリーポイント |
| 2-2 | App.js | `apps/portal/src/App.js` | メインアプリケーションコンポーネント |

**主要処理フロー（index.js）**:
1. **8-13行目**: ROOT_DIV_ID定義とDOM要素追加
2. **15-32行目**: スクリプトタグからサイトデータ取得（data-ghost, data-key, data-api等）
3. **47-58行目**: init関数でReactアプリをマウント

**主要処理フロー（App.js）**:
1. **46-67行目**: コンストラクタで初期state設定
2. **199-256行目**: initSetup()でAPIデータ取得・初期化
3. **688-728行目**: dispatchAction()でアクションハンドリング
4. **1033-1046行目**: render()でコンポーネントツリー構築

#### Step 3: アクションハンドラを理解する

ユーザー操作に対する処理ロジックを理解する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 3-1 | actions.js | `apps/portal/src/actions.js` | 全アクションハンドラの定義 |

**主要処理フロー**:
- **81-111行目**: signin - マジックリンク認証処理
- **160-199行目**: signup - 会員登録処理（無料/有料分岐）
- **201-225行目**: checkoutPlan - Stripeチェックアウト処理
- **227-260行目**: updateSubscription - サブスクリプション更新
- **536-598行目**: updateProfile - プロフィール更新

#### Step 4: API通信層を理解する

バックエンドとの通信処理を理解する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 4-1 | api.js | `apps/portal/src/utils/api.js` | Ghost API通信ラッパー |

**主要処理フロー**:
- **4-11行目**: setupGhostApi関数とエンドポイント構築
- **35-137行目**: api.site - サイト情報取得（settings, tiers, newsletters, recommendations）
- **182-702行目**: api.member - メンバー関連操作（認証、更新、決済）
- **704-743行目**: api.init - 初期化処理（並列データ取得）

#### Step 5: ページコンポーネントを理解する

各画面のUI実装を理解する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 5-1 | pages.js | `apps/portal/src/pages.js` | ページルーティング定義 |
| 5-2 | popup-modal.js | `apps/portal/src/components/popup-modal.js` | モーダル表示制御 |
| 5-3 | signup-page.js | `apps/portal/src/components/pages/signup-page.js` | サインアップページUI |
| 5-4 | signin-page.js | `apps/portal/src/components/pages/signin-page.js` | サインインページUI |
| 5-5 | account-home-page.js | `apps/portal/src/components/pages/AccountHomePage/account-home-page.js` | アカウントホームUI |

**読解のコツ**: 各ページコンポーネントはAppContextを通じてグローバルステートにアクセスする。context.doAction()でアクションを発行し、App.jsのdispatchAction()経由で処理が実行される。

### プログラム呼び出し階層図

```
index.js (init)
    │
    └─ App.js
           │
           ├─ initSetup()
           │      ├─ fetchApiData()
           │      │      └─ api.js (setupGhostApi)
           │      │             ├─ api.site.settings()
           │      │             ├─ api.site.tiers()
           │      │             ├─ api.site.newsletters()
           │      │             └─ api.member.sessionData()
           │      │
           │      ├─ fetchLinkData()
           │      ├─ fetchPreviewData()
           │      └─ handleDataAttributes()
           │
           ├─ dispatchAction(action, data)
           │      └─ actions.js (ActionHandler)
           │             ├─ signin() → api.member.sendMagicLink()
           │             ├─ signup() → api.member.checkoutPlan() / sendMagicLink()
           │             ├─ updateProfile() → api.member.update()
           │             └─ checkoutPlan() → api.member.checkoutPlan()
           │
           └─ render()
                  ├─ PopupModal
                  │      └─ Pages[page] (動的ページコンポーネント)
                  │             ├─ SignupPage
                  │             ├─ SigninPage
                  │             ├─ AccountHomePage
                  │             └─ ...
                  ├─ TriggerButton
                  └─ Notification
```

### データフロー図

```
[入力]                    [処理]                         [出力]

URLハッシュ (#/portal/*) ─┐
                          │
data-portal属性 ─────────┼─▶ App.js
                          │   ├─ getPageFromLinkPath()  ─▶ page state更新
API Response ─────────────┤   ├─ fetchApiData()         ─▶ site/member state更新
  (site, member, offers)  │   └─ dispatchAction()       ─▶ action実行
                          │
フォーム入力 ─────────────┘
  (email, name, plan)          │
                               ▼
                         actions.js
                               │
                               ▼
                          api.js
                               │
                               ▼
                    Ghost Members API ───▶ DB更新
                               │
                               ▼
                    Stripe API (有料プラン) ───▶ 決済処理
                               │
                               ▼
                         state更新 ───▶ UI再レンダリング
                               │
                               ▼
                         PopupModal (iframe)
                               │
                               ▼
                         [画面表示]
```

### 関連ファイル一覧

| ファイル | パス | 種別 | 役割 |
|---------|------|------|------|
| index.js | `apps/portal/src/index.js` | ソース | アプリエントリーポイント |
| App.js | `apps/portal/src/App.js` | ソース | メインアプリコンポーネント |
| actions.js | `apps/portal/src/actions.js` | ソース | アクションハンドラ定義 |
| api.js | `apps/portal/src/utils/api.js` | ソース | API通信ラッパー |
| pages.js | `apps/portal/src/pages.js` | ソース | ページルーティング定義 |
| helpers.js | `apps/portal/src/utils/helpers.js` | ソース | ユーティリティ関数群 |
| fixtures.js | `apps/portal/src/utils/fixtures.js` | ソース | モックデータ定義 |
| popup-modal.js | `apps/portal/src/components/popup-modal.js` | ソース | モーダルコンポーネント |
| trigger-button.js | `apps/portal/src/components/trigger-button.js` | ソース | トリガーボタンコンポーネント |
| notification.js | `apps/portal/src/components/notification.js` | ソース | 通知コンポーネント |
| signup-page.js | `apps/portal/src/components/pages/signup-page.js` | ソース | サインアップページ |
| signin-page.js | `apps/portal/src/components/pages/signin-page.js` | ソース | サインインページ |
| account-home-page.js | `apps/portal/src/components/pages/AccountHomePage/account-home-page.js` | ソース | アカウントホーム |
| account-plan-page.js | `apps/portal/src/components/pages/account-plan-page.js` | ソース | プラン選択ページ |
| account-profile-page.js | `apps/portal/src/components/pages/account-profile-page.js` | ソース | プロフィール編集 |
| account-email-page.js | `apps/portal/src/components/pages/account-email-page.js` | ソース | メール設定ページ |
| offer-page.js | `apps/portal/src/components/pages/offer-page.js` | ソース | オファーページ |
| feedback-page.js | `apps/portal/src/components/pages/feedback-page.js` | ソース | フィードバックページ |
| magic-link-page.js | `apps/portal/src/components/pages/magic-link-page.js` | ソース | マジックリンクページ |
| recommendations-page.js | `apps/portal/src/components/pages/recommendations-page.js` | ソース | レコメンデーションページ |
| i18n.js | `apps/portal/src/utils/i18n.js` | ソース | 国際化設定 |
| errors.js | `apps/portal/src/utils/errors.js` | ソース | エラーハンドリング |
| data-attributes.js | `apps/portal/src/data-attributes.js` | ソース | データ属性ハンドリング |
| package.json | `apps/portal/package.json` | 設定 | パッケージ定義・依存関係 |
| vite.config.js | `apps/portal/vite.config.js` | 設定 | ビルド設定 |
