# 画面設計書 87-オファーページ

## 概要

本ドキュメントは、Ghost会員向けポータル（Portal）のオファーページ（OfferPage）の画面設計書である。特別割引オファー（クーポン）を適用して有料プランに登録するための画面を定義する。

### 本画面の処理概要

オファーページは、Ghost会員サイトのPortalウィジェット内で、特別割引オファー（固定額割引、パーセント割引、無料トライアル）を適用して有料プランに登録するための画面である。

**業務上の目的・背景**：サブスクリプション型サイトにおいて、割引オファーは新規会員獲得の重要なマーケティングツールである。期間限定割引、初回登録特典、トライアル期間などを提供することで、購読のハードルを下げ、コンバージョン率を向上させる。この画面は、オファーの詳細（割引額、期間、適用条件）を明確に表示し、スムーズな登録フローを提供する。

**画面へのアクセス方法**：オファー専用URL（`#/portal/offer/{offerId}`）をクリックすることでアクセスする。このURLは通常、マーケティングキャンペーンやプロモーションで配布される。

**主要な操作・処理内容**：
1. オファー詳細情報の表示（タイトル、説明、割引額、適用期間）
2. 商品（Tier）情報と価格の表示（割引前後の価格比較）
3. ユーザー情報（名前、メール）の入力
4. 利用規約チェックボックス（設定有効時）
5. サインアップ処理の実行（Stripe決済へ遷移）
6. 複数ニュースレター時のニュースレター選択画面

**画面遷移**：
- 遷移元：外部リンク（オファーURL）
- 遷移先：マジックリンクページ（無料登録時）、Stripe決済ページ（有料登録時）、サインインページ

**権限による表示制御**：
- 未認証状態でアクセス可能
- ログイン済み会員の場合は入力フォームの一部が自動入力・無効化
- 無効なオファーや該当商品がない場合は表示されない

## 関連機能

| 機能No | 機能名 | 関連種別 | 関連する操作・処理 |
|--------|--------|----------|------------------|
| 16 | オファー管理 | 主機能 | 特別オファーの表示・適用 |
| 14 | Stripe連携 | 補助機能 | 割引決済処理 |
| 71 | Portal | 補助機能 | Portalウィジェット内でのUI表示 |

## 画面種別

登録

## URL/ルーティング

- ハッシュルート：`#/portal/offer/{offerId}`
- Pages.jsでの登録キー：`offer`

## 入出力項目

| 項目名 | 項目ID | 型 | 必須 | 入力/出力 | 説明 |
|--------|--------|-----|------|----------|------|
| 名前 | name | string | 設定依存 | 入力 | ユーザーの表示名 |
| メールアドレス | email | string | ○ | 入力 | ユーザーのメールアドレス |
| 電話番号 | phonenumber | string | - | 入力（非表示） | ハニーポットフィールド |
| 利用規約同意 | termsCheckboxChecked | boolean | 設定依存 | 入力 | 利用規約チェックボックス |

## 表示項目

| 項目名 | データソース | 説明 |
|--------|-------------|------|
| サイトロゴ | site.icon | サイトアイコン画像 |
| サイトタイトル | site.title | サイト名 |
| オファータイトル | offer.display_title | オファーの表示タイトル |
| オファー説明 | offer.display_description | オファーの説明文 |
| 割引ラベル | offer.type, offer.amount | "X% off"、"$X off"、"X days free" |
| 商品名 | product.name | Tier（プラン）名 |
| 元価格 | product.monthlyPrice/yearlyPrice | 割引前価格（取り消し線付き） |
| 割引後価格 | 計算値 | 割引適用後の価格 |
| オファー期間説明 | offer.duration | "forever"、"first X months"など |
| 商品説明 | product.description | 商品の説明文 |
| 商品特典 | product.benefits | 商品に含まれる特典リスト |

## イベント仕様

### 1-入力変更

**トリガー**：名前/メール入力フィールドの値変更

**処理フロー**：
1. handleInputChange で対応するstate項目を更新
2. 画面再描画

### 2-サインアップ実行

**トリガー**：「Continue」/「Start X-day free trial」ボタンクリック、またはEnterキー押下

**処理フロー**：
1. 入力バリデーション実行（getFormErrors）
2. 利用規約チェックボックス確認（設定有効時）
3. エラーがある場合：エラーメッセージ表示
4. エラーがない場合：
   - 複数ニュースレター時：ニュースレター選択画面へ遷移
   - 単一ニュースレター時：`doAction('signup', {name, email, plan, offerId, phonenumber})` 実行
5. Stripe決済ページへリダイレクト

**API呼び出し**：
- POST `/members/api/create-stripe-checkout-session/`

### 3-サインインページへ遷移

**トリガー**：「Already a member? Sign in」リンククリック

**処理フロー**：
1. `doAction('switchPage', {page: 'signin'})` 実行
2. サインインページへ遷移

### 4-ニュースレター選択から戻る

**トリガー**：ニュースレター選択画面の戻るボタン

**処理フロー**：
1. state.showNewsletterSelection を false に設定
2. オファー詳細画面に戻る

## データベース更新仕様

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

| 操作（イベント） | 対象テーブル | 操作種別 | 概要 |
|----------------|-------------|---------|------|
| サインアップ実行 | members | INSERT | 新規会員レコード作成（決済完了後） |
| サインアップ実行 | members_newsletters | INSERT | ニュースレター購読関連（決済完了後） |
| サインアップ実行 | members_stripe_customers | INSERT | Stripe顧客関連（決済完了後） |
| サインアップ実行 | members_stripe_customers_subscriptions | INSERT | サブスクリプション関連（決済完了後） |

### テーブル別更新項目詳細

#### members

| 操作 | 項目（カラム名） | 更新値・取得条件 | 備考 |
|-----|-----------------|-----------------|------|
| INSERT | name | 入力された名前 | nullable |
| INSERT | email | 入力されたメール | 必須、一意 |
| INSERT | status | 'paid' | オファー適用時は有料 |

## メッセージ仕様

| メッセージID | 種別 | メッセージ内容 | 表示条件 |
|-------------|------|--------------|---------|
| MSG-001 | ラベル | "Name" | 名前フィールド |
| MSG-002 | ラベル | "Email" | メールフィールド |
| MSG-003 | プレースホルダー | "Jamie Larson" | 名前フィールド |
| MSG-004 | プレースホルダー | "jamie@example.com" | メールフィールド |
| MSG-005 | ボタン | "Continue" | 通常時 |
| MSG-006 | ボタン | "Start {amount}-day free trial" | トライアルオファー時 |
| MSG-007 | ボタン | "Sending..." | 送信中 |
| MSG-008 | ボタン | "Retry" | 失敗後 |
| MSG-009 | タグ | "{amount} off" | 固定額/パーセント割引時 |
| MSG-010 | タグ | "{amount} days free" | トライアル時 |
| MSG-011 | 説明 | "{amount} off forever." | forever期間 |
| MSG-012 | 説明 | "{amount} off for first {period}." | once期間 |
| MSG-013 | 説明 | "{amount} off for first {number} months." | repeating期間 |
| MSG-014 | 説明 | "Try free for {amount} days, then {originalPrice}. Cancel anytime." | トライアル時 |
| MSG-015 | 説明 | "Renews at {price}." | 更新時価格 |
| MSG-016 | リンク | "Already a member?" / "Sign in" | サインインリンク |
| MSG-017 | プレースホルダー | "Black Friday" | オファータイトル未設定時 |
| MSG-018 | 商品 | "Monthly" / "Yearly" | 月額/年額表示 |

## 例外処理

| 例外条件 | 処理内容 | 表示メッセージ |
|---------|---------|--------------|
| オファーがない | null返却（何も表示しない） | - |
| 対象商品がない | null返却（何も表示しない） | - |
| バリデーションエラー | フィールドにエラーメッセージ表示 | フィールド固有エラー |
| 利用規約未チェック | チェックボックスエラー表示 | - |
| API通信エラー | 「Retry」ボタン表示 | - |

## 備考

- オファータイプ：'fixed'（固定額割引）、'percent'（パーセント割引）、'trial'（無料トライアル）
- オファー期間：'once'（初回のみ）、'forever'（永久）、'repeating'（指定月数）
- トライアルオファーの場合は元価格の取り消し線を非表示
- ログイン済み会員の場合は名前・メール入力欄は無効化
- portal_signup_checkbox_required設定時は利用規約チェックが必須
- 複数ニュースレターがある場合はニュースレター選択画面を挟む

---

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

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

### 推奨読解順序

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

オファーページで扱う主要なデータ構造を把握する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 1-1 | app-context.js | `apps/portal/src/app-context.js` | AppContextの構造（pageData=offer, site, member） |
| 1-2 | helpers.js | `apps/portal/src/utils/helpers.js` | getProductFromId、getCurrencySymbol関数 |

**読解のコツ**: pageDataにはオファー情報（id, type, amount, currency, duration, tier等）が含まれる。

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

ページコンポーネントの構造と初期化処理を把握する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 2-1 | offer-page.js | `apps/portal/src/components/pages/offer-page.js` | OfferPageクラスコンポーネント |
| 2-2 | pages.js | `apps/portal/src/pages.js` | ページルーティング定義 |

**主要処理フロー**:
1. **155-166行**: クラスコンポーネント定義、stateの初期化
2. **650-683行**: renderでオファー情報、フォーム、商品カード表示
3. **276-314行**: handleSignupでサインアップ処理

#### Step 3: オファー表示ロジックを理解する

オファータイプに応じた表示の分岐を把握する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 3-1 | offer-page.js | `apps/portal/src/components/pages/offer-page.js` | renderOfferTag、renderOfferMessage |

**主要処理フロー**:
- **436-462行**: renderOfferTagでオファーラベル表示（"X% off"など）
- **523-570行**: renderOfferMessageでオファー説明文表示

#### Step 4: 価格計算ロジックを理解する

割引後価格の計算を把握する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 4-1 | offer-page.js | `apps/portal/src/components/pages/offer-page.js` | getOriginalPrice、getUpdatedPrice |

**主要処理フロー**:
- **484-488行**: getOriginalPriceで元価格取得
- **490-502行**: getUpdatedPriceで割引後価格計算

#### Step 5: サインアップ処理を理解する

サインアップアクションの実行フローを把握する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 5-1 | offer-page.js | `apps/portal/src/components/pages/offer-page.js` | handleSignup |
| 5-2 | api.js | `apps/portal/src/utils/api.js` | member.checkoutPlan関数 |

**主要処理フロー**:
- **276-314行**: handleSignupでバリデーション後にsignupアクション実行
- **445-509行 (api.js)**: checkoutPlanでStripe Checkoutセッション作成

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

```
OfferPage (offer-page.js)
    │
    ├─ constructor()
    │      └─ state: {name, email, plan, showNewsletterSelection, termsCheckboxChecked}
    │
    ├─ render()
    │      ├─ !offer → null
    │      ├─ !product → null
    │      │
    │      ├─ renderFormHeader()
    │      │      └─ サイトロゴ、サイトタイトル
    │      │
    │      ├─ オファーバー
    │      │      ├─ display_title / "Black Friday"
    │      │      ├─ renderOfferTag()
    │      │      └─ display_description
    │      │
    │      ├─ renderForm()
    │      │      ├─ showNewsletterSelection
    │      │      │      └─ NewsletterSelectionPage
    │      │      │
    │      │      └─ InputForm
    │      │
    │      └─ renderProductCard()
    │             ├─ 商品名 + cadence
    │             ├─ renderOldTierPrice()
    │             ├─ renderUpdatedTierPrice()
    │             ├─ renderOfferMessage()
    │             ├─ product.description
    │             ├─ renderBenefits()
    │             ├─ renderSignupTerms()
    │             ├─ renderSubmitButton()
    │             └─ renderLoginMessage()
    │
    └─ handleSignup()
           ├─ getFormErrors() バリデーション
           │
           └─ hasMultipleNewsletters
                  ├─ true → showNewsletterSelection = true
                  └─ false → doAction('signup', {...})
```

### データフロー図

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

pageData (offer) ──────▶ オファー詳細取得 ───────────────▶ オファーUI表示
  ├─ type                     │
  ├─ amount                   ├─ renderOfferTag()
  ├─ duration                 └─ renderOfferMessage()
  └─ tier.id
           │
           └──────────────▶ getProductFromId() ─────────▶ 商品情報
                                  │
                                  ├─ getOriginalPrice()
                                  └─ getUpdatedPrice()

site.portal_name ──────▶ 名前フィールド表示判定

member (ログイン時) ───▶ 入力フィールド無効化

ユーザー入力
  │
  ├─ 名前/メール変更 ────────▶ handleInputChange() ──────▶ state更新
  │
  ├─ 利用規約チェック ────────▶ state.termsCheckboxChecked
  │
  └─ Continueボタン ─────────▶ handleSignup()
                                    │
                                    ├─ getFormErrors() バリデーション
                                    │
                                    └─ doAction('signup', {offerId, ...})
                                           │
                                           └─▶ api.member.checkoutPlan()
                                                  └─▶ Stripe決済ページ
```

### 関連ファイル一覧

| ファイル | パス | 種別 | 役割 |
|---------|------|------|------|
| offer-page.js | `apps/portal/src/components/pages/offer-page.js` | ソース | メインページコンポーネント |
| newsletter-selection-page.js | `apps/portal/src/components/pages/newsletter-selection-page.js` | ソース | ニュースレター選択コンポーネント |
| pages.js | `apps/portal/src/pages.js` | ソース | ページルーティング定義 |
| app-context.js | `apps/portal/src/app-context.js` | ソース | アプリケーションコンテキスト定義 |
| api.js | `apps/portal/src/utils/api.js` | ソース | API通信処理 |
| helpers.js | `apps/portal/src/utils/helpers.js` | ソース | ヘルパー関数群 |
| form.js | `apps/portal/src/utils/form.js` | ソース | フォームバリデーション |
| sanitize-html.js | `apps/portal/src/utils/sanitize-html.js` | ソース | HTMLサニタイズ |
| input-form.js | `apps/portal/src/components/common/input-form.js` | ソース | 入力フォームコンポーネント |
| action-button.js | `apps/portal/src/components/common/action-button.js` | ソース | アクションボタンコンポーネント |
| close-button.js | `apps/portal/src/components/common/close-button.js` | ソース | 閉じるボタンコンポーネント |
| checkmark.svg | `apps/portal/src/images/icons/checkmark.svg` | アセット | チェックマークアイコン |
| i18n.js | `apps/portal/src/utils/i18n.js` | ソース | 国際化ユーティリティ |
