# 画面設計書 56-オファー設定

## 概要

本ドキュメントは、Ghost管理画面における「オファー設定」画面の設計仕様を記述するものである。

### 本画面の処理概要

オファー設定画面は、有料会員プラン（Tier）に対する割引やクーポン、無料トライアルなどの特別オファーを作成・管理するための画面である。作成したオファーには固有のURLが発行され、マーケティングキャンペーンなどで活用できる。

**業務上の目的・背景**：有料サブスクリプションの獲得において、割引や無料トライアルは強力なインセンティブとなる。オファー機能により、期間限定キャンペーン、特定チャネル向けの割引、インフルエンサー経由の特別価格などを柔軟に設定できる。各オファーの利用回数（リデンプション数）も追跡でき、マーケティング効果の測定にも役立つ。

**画面へのアクセス方法**：管理画面のサイドメニューから「Settings」を選択し、「Growth」セクション内の「Offers」項目をクリックしてアクセスする。URLパスは`#/settings/offers`となる。

**主要な操作・処理内容**：
1. アクティブなオファーの一覧表示（最新3件）
2. オファー管理モーダルへの遷移
3. 新規オファーの作成
4. 既存オファーの編集・アーカイブ
5. オファーリンクのコピー

**画面遷移**：設定画面のGrowthセクションからアクセスする。「Manage offers」ボタンからはオファー一覧モーダル（offers/edit）へ遷移する。「Add offer」ボタンからは新規オファー作成モーダル（offers/new）へ遷移する。各オファーカードクリックで編集画面（offers/edit/:id）へ遷移する。

**権限による表示制御**：Administrator（管理者）およびOwner（オーナー）ロールのみがこの設定にアクセス・編集可能である。Stripeが設定されていない場合、ボタンは無効化される。

## 関連機能

| 機能No | 機能名 | 関連種別 | 関連する操作・処理 |
|--------|--------|----------|------------------|
| 16 | オファー管理 | 主機能 | 割引・クーポンオファーの作成・管理 |
| 14 | Stripe連携 | 補助機能 | 決済プラットフォームとの連携確認 |
| 15 | Tier（プラン）管理 | 補助機能 | オファー適用対象のTier情報取得 |

## 画面種別

一覧（カード形式）

## URL/ルーティング

- 管理画面URL: `/ghost/#/settings/offers`
- 内部ルーティング: `navid='offers'`
- ReactコンポーネントID: `TopLevelGroup` with `testId='offers'`
- モーダルルート（一覧）: `/ghost/#/settings/offers/edit`
- モーダルルート（新規作成）: `/ghost/#/settings/offers/new`
- モーダルルート（編集）: `/ghost/#/settings/offers/edit/:offerId`

## 入出力項目

| 項目名 | 項目ID | データ型 | 入力/出力 | 必須 | 最大長 | 説明 |
|--------|--------|----------|-----------|------|--------|------|
| （この画面自体には入力項目なし - モーダルで設定） | - | - | - | - | - | - |

## 表示項目

| 項目名 | 表示条件 | 説明 |
|--------|----------|------|
| オファーカード | activeOffers.length > 0 | 最新3件のアクティブオファーをカード形式で表示 |
| オファータイトル | 各カード | オファーの名称 |
| 割引表示 | 各カード | 割引率（%）、固定金額割引、または無料トライアル日数 |
| Tier名・課金周期 | 各カード | 対象プラン名と月額/年額の表示 |
| リデンプション数 | 各カード | オファーの利用回数（リンク付き） |
| コピーリンクボタン | 各カード（ホバー時） | オファーURLをクリップボードにコピー |
| 総オファー数 | signupOffers.length > 3 | 「X offers in total」の表示 |
| Manage offersボタン | signupOffers.length > 0 | オファー一覧モーダルを開くボタン |
| Add offerボタン | paidActiveTiers.length > 0 && signupOffers.length === 0 | 新規オファー作成モーダルを開くボタン |
| Tierなし警告 | paidActiveTiers.length === 0 | 有料Tierが必要な旨のメッセージとリンク |

## イベント仕様

### 1-オファーカードクリック

OfferContainerコンポーネントのクリックイベントでgoToOfferEdit関数が呼び出される。sessionStorageにeditOfferPageSource='offers'を保存し、updateRoute('offers/edit/:id')を実行して編集モーダルが表示される。

### 2-Manage offers押下

ボタン押下時にopenOfferListModal関数が呼び出される。updateRoute('offers/edit')を実行してオファー一覧モーダル（OffersIndexModal）が表示される。

### 3-Add offer押下

ボタン押下時にopenAddModal関数が呼び出される。updateRoute('offers/new')を実行して新規オファー作成モーダルが表示される。

### 4-Copy link押下

CopyLinkButtonコンポーネントのクリックイベントでnavigator.clipboard.writeTextが呼び出される。オファーURLがクリップボードにコピーされ、2秒間「Copied」表示になる。

### 5-リデンプション数リンク押下

リデンプション数が0より大きい場合、クリックするとメンバー一覧画面（/ghost/#/members?filter=...）にオファーでフィルタリングした状態で遷移する。

### 6-Manage tiersリンク押下

有料Tierが存在しない場合に表示されるリンクをクリックすると、updateRoute('/tiers')を実行してTier設定画面へ遷移する。

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

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

| 操作（イベント） | 対象テーブル | 操作種別 | 概要 |
|----------------|-------------|---------|------|
| オファー作成（モーダル） | offers | INSERT | 新規オファーレコードを作成 |
| オファー編集（モーダル） | offers | UPDATE | オファーレコードを更新 |
| オファーアーカイブ（モーダル） | offers | UPDATE | status='archived'に更新 |

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

#### offers

| 操作 | 項目（カラム名） | 更新値・取得条件 | 備考 |
|-----|-----------------|-----------------|------|
| INSERT/UPDATE | name | オファー名 | |
| INSERT/UPDATE | code | オファーコード（URLスラッグ） | |
| INSERT/UPDATE | type | 'percent'/'fixed'/'trial' | |
| INSERT/UPDATE | amount | 割引額・割引率・日数 | |
| INSERT/UPDATE | cadence | 'month'/'year' | |
| INSERT/UPDATE | duration | 'once'/'repeating'/'forever' | |
| INSERT/UPDATE | tier_id | 対象TierのID | |
| UPDATE | status | 'active'/'archived' | アーカイブ時 |

## メッセージ仕様

| メッセージID | 種類 | メッセージ内容 | 表示条件 |
|-------------|------|---------------|----------|
| MSG-001 | 情報 | Create discounts & coupons to boost new subscriptions. | 説明文 |
| MSG-002 | リンク | Learn more | オファーがない場合のヘルプリンク |
| MSG-003 | 情報 | X offers in total | オファーが4件以上ある場合 |
| MSG-004 | 警告 | You must have an active tier to create an offer. | 有料Tierがない場合 |
| MSG-005 | ラベル | Copied | リンクコピー後2秒間 |
| MSG-006 | ラベル | Copy link | リンクコピー前 |

## 例外処理

| 例外条件 | 処理内容 |
|----------|----------|
| Stripe未設定 | ボタンがdisabled状態となり操作不可（checkStripeEnabled判定） |
| 有料Tierなし | 「Manage tiers」リンク付きの警告メッセージを表示 |
| セッション切れ | 認証画面へリダイレクト |

## 備考

- オファータイプは3種類：
  - percent: 割引率（例：20% off）
  - fixed: 固定金額割引（例：10 USD off）
  - trial: 無料トライアル（例：14 days free）
- 割引の適用期間（duration）：
  - once: 初回支払いのみ
  - repeating: 複数回（duration_in_monthsで指定）
  - forever: 永続
- redemption_type='signup'のオファーのみが一覧に表示される
- Tierがアーカイブされると、関連するオファーも事実上無効化される

---

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

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

### 推奨読解順序

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

Offer型とTier型の構造を理解する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 1-1 | offers.ts | `apps/admin-x-framework/src/api/offers.ts` | Offer型の定義（4-26行目） |
| 1-2 | tiers.ts | `apps/admin-x-framework/src/api/tiers.ts` | Tier型の定義、getPaidActiveTiers関数 |

**読解のコツ**: Offerにはtype（percent/fixed/trial）、amount、duration、cadenceなどの組み合わせで割引条件を表現する。

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

オファー設定画面のメインファイルを読み解く。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 2-1 | offers.tsx | `apps/admin-x-settings/src/components/settings/growth/offers.tsx` | コンポーネント全体の構造、オファー一覧の表示 |

**主要処理フロー**:
1. **31-33行目**: useRouting/useGlobalData初期化
2. **35行目**: useBrowseOffersでオファー一覧を取得
3. **37-38行目**: useBrowseTiersでTier一覧を取得、getPaidActiveTiersでアクティブなTierを抽出
4. **40-52行目**: signupOffers/activeOffersのフィルタリングとソート
5. **66-68行目**: goToOfferEdit関数でsessionStorage設定と遷移

#### Step 3: オファーカードコンポーネントを理解する

個別オファーの表示ロジックを読み解く。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 3-1 | offers.tsx | `apps/admin-x-settings/src/components/settings/growth/offers.tsx` | OfferContainerコンポーネント（12-29行目） |

**主要処理フロー**:
- **13行目**: getOfferDiscount関数で割引表示を計算
- **15-28行目**: カードレイアウト（タイトル、割引、Tier、リデンプション数）

#### Step 4: オファー一覧モーダルを理解する

Manage offersボタンで開くモーダルの実装を読み解く。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 4-1 | offers-index.tsx | `apps/admin-x-settings/src/components/settings/growth/offers/offers-index.tsx` | OffersIndexModalコンポーネント |

**主要処理フロー**:
- **102-297行目**: モーダル本体の実装
- **117-120行目**: Active/Archivedのタブ分け
- **136-147行目**: ソート機能（date-added/name/redemptions）

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

```
Offers (offers.tsx)
    │
    ├─ useGlobalData() ... settings, config取得
    │      └─ checkStripeEnabled() ... Stripe有効化チェック
    │
    ├─ useBrowseOffers() ... オファー一覧取得
    │      └─ filter(redemption_type === 'signup')
    │
    ├─ useBrowseTiers() ... Tier一覧取得
    │      └─ getPaidActiveTiers() ... 有料アクティブTier抽出
    │
    ├─ OfferContainer (multiple)
    │      ├─ getOfferDiscount() ... 割引表示計算
    │      ├─ CopyLinkButton
    │      │      └─ clipboard.writeText()
    │      └─ onClick → goToOfferEdit()
    │
    └─ TopLevelGroup
           ├─ customButtons: Button 'Manage offers' or 'Add offer'
           └─ 条件分岐
                  ├─ オファーあり → オファーカード一覧
                  └─ Tierなし → 警告メッセージ + 'Manage tiers'リンク
```

### データフロー図

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

API取得
/offers/ ───▶ useBrowseOffers ───▶ allOffers[]
    │
/tiers/ ───▶ useBrowseTiers ───▶ allTiers[]
    │              │
    │              └─ getPaidActiveTiers() ───▶ paidActiveTiers[]
    │
    ▼
フィルタリング
allOffers ───▶ filter(redemption_type='signup') ───▶ signupOffers[]
    │
signupOffers ───▶ filter(status='active' && tier.active) ───▶ activeOffers[]
    │
activeOffers ───▶ sort(created_at desc) ───▶ ソート済み
    │
    └─ slice(0, 3) ───▶ latestThree[]

表示計算
offer + tier ───▶ getOfferDiscount() ───▶ discountOffer, prices

ユーザー操作
Card click ───▶ goToOfferEdit(id) ───▶ offers/edit/:id
Manage click ───▶ openOfferListModal() ───▶ offers/edit
Add click ───▶ openAddModal() ───▶ offers/new
```

### 関連ファイル一覧

| ファイル | パス | 種別 | 役割 |
|---------|------|------|------|
| offers.tsx | `apps/admin-x-settings/src/components/settings/growth/offers.tsx` | ソース | オファー設定画面のメインコンポーネント |
| offers-index.tsx | `apps/admin-x-settings/src/components/settings/growth/offers/offers-index.tsx` | ソース | オファー一覧モーダル |
| offers.ts | `apps/admin-x-framework/src/api/offers.ts` | ソース | Offers API定義 |
| tiers.ts | `apps/admin-x-framework/src/api/tiers.ts` | ソース | Tiers API定義、getPaidActiveTiers関数 |
| settings.ts | `apps/admin-x-framework/src/api/settings.ts` | ソース | checkStripeEnabled関数 |
| helpers.ts | `apps/admin-x-settings/src/utils/helpers.ts` | ソース | numberWithCommas関数 |
| currency.ts | `apps/admin-x-settings/src/utils/currency.ts` | ソース | currencyToDecimal, getSymbol関数 |
