From c4b8dda328d2c92fc90b8e990a1ce1f18ea1b569 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 12:43:46 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E3=82=BD=E3=83=95=E3=83=88=E3=83=9A?= =?UTF-8?q?=E3=82=A4=E3=82=A6=E3=82=A9=E3=83=BC=E3=83=AB=E5=8C=96=20+=20?= =?UTF-8?q?=E6=97=A5=E6=9C=AC=E8=AA=9E=E8=A9=B1=E8=80=85=E3=81=B8=20lifeti?= =?UTF-8?q?me=20=E8=A1=A8=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PaywallVariantBView: lifetime(買い切り) を storefront国(JPN)判定から 端末優先言語の日本語判定へ変更。async storefront 依存のレースで 非表示になるバグを解消。英語ユーザーには従来通り非表示。 - PaywallFlowContainer: 右上に平均サイズの × (paywall-close-button) を追加。 課金せずメイン画面(FeedRootView)へ遷移するソフトペイウォールに変更。 - OnboardingFlowView: onDismiss を handlePaywallDismissedAsFree に配線 (onboardingPaywallDismissedFree track + markOnboardingComplete)。 - Maestro: 03-soft-paywall / 04(en, lifetime非表示) / 05(ja, 買い切り表示) を現行フロー・feed-scroll・paywall-close-button に整合。 - spec: docs/superpowers/specs/2026-07-03-soft-paywall-and-jp-lifetime-design.md Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01C84bsKHEy9pzDbicoPGg3h --- .../Onboarding/OnboardingBibleViews.swift | 21 +++- .../Onboarding/OnboardingFlowView.swift | 15 ++- .../Onboarding/PaywallVariantBView.swift | 15 +-- .../maestro/onboarding/03-soft-paywall.yaml | 66 ++--------- .../v1.8.0/04-paywall_variant_b_en.yaml | 23 ++-- .../v1.8.0/05-paywall_variant_b_ja.yaml | 24 ++-- ...-03-soft-paywall-and-jp-lifetime-design.md | 108 ++++++++++++++++++ 7 files changed, 188 insertions(+), 84 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-03-soft-paywall-and-jp-lifetime-design.md diff --git a/aniccaios/aniccaios/Onboarding/OnboardingBibleViews.swift b/aniccaios/aniccaios/Onboarding/OnboardingBibleViews.swift index e05939dfd..6c857adad 100644 --- a/aniccaios/aniccaios/Onboarding/OnboardingBibleViews.swift +++ b/aniccaios/aniccaios/Onboarding/OnboardingBibleViews.swift @@ -702,16 +702,18 @@ struct PaywallValueTimelineStepView: View { } } -// MARK: - Paywall Flow Container (Hard Paywall — no dismiss) +// MARK: - Paywall Flow Container (Soft Paywall — 右上 × で無課金メインへ) struct PaywallFlowContainer: View { let onPurchaseSuccess: (CustomerInfo) -> Void + let onDismiss: () -> Void @State private var step: PaywallStep = .primer @EnvironmentObject private var appState: AppState var body: some View { - ZStack { + ZStack(alignment: .topTrailing) { AppBackground() + switch step { case .primer: PaywallPrimerStepView(next: { step = .planSelection }) @@ -719,10 +721,23 @@ struct PaywallFlowContainer: View { PaywallVariantBView( variant: "b", onPurchaseSuccess: onPurchaseSuccess, - onDismiss: { /* hard paywall: no-op */ } + onDismiss: onDismiss ) } + // ソフトペイウォール: 右上に平均サイズの × 。課金せずメイン画面へ。 + // primer / planSelection どちらの段でも常に表示。 + Button(action: onDismiss) { + Image(systemName: "xmark") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .accessibilityIdentifier("paywall-close-button") + .accessibilityLabel(Text(String(localized: "common_close"))) + .padding(.trailing, 12) + .padding(.top, 8) } } } diff --git a/aniccaios/aniccaios/Onboarding/OnboardingFlowView.swift b/aniccaios/aniccaios/Onboarding/OnboardingFlowView.swift index 949688bc7..9816f40e7 100644 --- a/aniccaios/aniccaios/Onboarding/OnboardingFlowView.swift +++ b/aniccaios/aniccaios/Onboarding/OnboardingFlowView.swift @@ -4,8 +4,9 @@ import RevenueCatUI import UserNotifications import StoreKit -/// v7 onboarding: 11 steps + 2-step HARD paywall. -/// Paywall presented via .fullScreenCover + .interactiveDismissDisabled. No × button, no swipe-dismiss, no NavigationStack wrap. +/// v7 onboarding: 11 steps + 2-step SOFT paywall. +/// Paywall presented via .fullScreenCover + .interactiveDismissDisabled (スワイプ離脱は防止)。 +/// 右上の × (paywall-close-button) タップでのみ課金せずメイン画面へ遷移する。 struct OnboardingFlowView: View { @EnvironmentObject private var appState: AppState @State private var step: OnboardingStep = .welcome @@ -47,6 +48,9 @@ struct OnboardingFlowView: View { PaywallFlowContainer( onPurchaseSuccess: { customerInfo in handlePaywallSuccess(customerInfo: customerInfo) + }, + onDismiss: { + handlePaywallDismissedAsFree() } ) .interactiveDismissDisabled(true) @@ -108,4 +112,11 @@ struct OnboardingFlowView: View { appState.markOnboardingComplete() showPaywall = false } + + /// ソフトペイウォール: 右上 × で課金せずメイン画面へ。 + private func handlePaywallDismissedAsFree() { + AnalyticsManager.shared.track(.onboardingPaywallDismissedFree) + appState.markOnboardingComplete() + showPaywall = false + } } diff --git a/aniccaios/aniccaios/Onboarding/PaywallVariantBView.swift b/aniccaios/aniccaios/Onboarding/PaywallVariantBView.swift index 141a26c82..2a8a99f5b 100644 --- a/aniccaios/aniccaios/Onboarding/PaywallVariantBView.swift +++ b/aniccaios/aniccaios/Onboarding/PaywallVariantBView.swift @@ -1,6 +1,5 @@ import SwiftUI import RevenueCat -import StoreKit /// Variant B paywall — CRO optimized design for RevenueCat Experiments A/B /// Offering swap is handled server-side by RevenueCat; this view always renders the `current` offering. @@ -17,15 +16,20 @@ struct PaywallVariantBView: View { @State private var hasShownRetention = false @State private var showRetention = false @State private var showReloadAfterTimeout = false - @State private var storefrontCountry = "" private var offering: Offering? { appState.cachedOffering } private var packages: [Package] { offering?.availablePackages ?? [] } private var yearlyPackage: Package? { packages.first { $0.packageType == .annual } } private var monthlyPackage: Package? { packages.first { $0.packageType == .monthly } } - // 2026-06-23 Dais: 買い切りは日本(JPストア)のみ表示する実験。US等では nil。 + // 2026-07-03 Dais: 買い切り(lifetime)は日本語話者にのみ表示する。 + // 旧実装は StoreKit の storefront 国(JPN)で判定していたが、 + // storefront は async 取得で初期値が空 → レースで非表示になるバグがあった。 + // 端末の優先言語で判定し (UserProfile / QuoteProvider と同一パターン)、非同期依存を撤廃する。 + private var isJapanese: Bool { + (Locale.preferredLanguages.first ?? "en").lowercased().hasPrefix("ja") + } private var lifetimePackage: Package? { - guard storefrontCountry == "JPN" else { return nil } + guard isJapanese else { return nil } return packages.first { $0.packageType == .lifetime } } @@ -93,9 +97,6 @@ struct PaywallVariantBView: View { selectedPackage = yearlyPackage ?? monthlyPackage } } - .task { - storefrontCountry = await Storefront.current?.countryCode ?? "" - } .sheet(isPresented: $showRetention) { RetentionOfferSheet { customerInfo in showRetention = false diff --git a/aniccaios/maestro/onboarding/03-soft-paywall.yaml b/aniccaios/maestro/onboarding/03-soft-paywall.yaml index 0376a1bc7..877d1c013 100644 --- a/aniccaios/maestro/onboarding/03-soft-paywall.yaml +++ b/aniccaios/maestro/onboarding/03-soft-paywall.yaml @@ -1,6 +1,6 @@ # Soft Paywall Display & Free Dismiss Test -# Tests: full onboarding -> paywall appears -> Close (X) tap -> MyPath arrival -# Custom close button (paywall-close-button) replaces RC SDK close button for testability +# Tests: full onboarding -> paywall appears -> Close (×) tap -> main feed arrival (課金なし) +# 右上 × (paywall-close-button) タップで無課金メイン画面(feed-scroll)へ遷移することを検証。 appId: ai.anicca.app.ios name: Soft Paywall dismiss to Free @@ -14,78 +14,34 @@ tags: - clearKeychain - launchApp -# Step 1: Welcome -- extendedWaitUntil: - visible: - id: "onboarding-welcome-cta" - timeout: 30000 - -- tapOn: - id: "onboarding-welcome-cta" - -# Step 2: Struggles -- extendedWaitUntil: - visible: - id: "onboarding-struggle-staying_up_late" - timeout: 15000 +# オンボーディング → ペイウォール(primer 通過して plan selection まで) +- runFlow: ../v1.8.0/_onboarding_to_paywall.yaml -- tapOn: - id: "onboarding-struggle-staying_up_late" - -- tapOn: - id: "onboarding-struggles-next" - -# Step 3: LIVE DEMO +# Paywall plan selection が表示される - extendedWaitUntil: visible: - id: "live_demo_trigger_button" + id: "paywall-plan-cta" timeout: 15000 -- tapOn: - id: "live_demo_trigger_button" - -- extendedWaitUntil: - visible: - id: "nudge-card-view" - timeout: 5000 - -- tapOn: - id: "nudge-primary-action" - -# Step 4: Notifications -- extendedWaitUntil: - visible: - id: "onboarding-notifications-allow" - timeout: 15000 - -- tapOn: - id: "onboarding-notifications-allow" - -# Handle system notification permission dialog (system dialog = optional: true allowed) -# Japanese: "許可" / English: "Allow" -- tapOn: - text: "許可|Allow" - optional: true - -# Step 5: Paywall appears (fullScreenCover with custom close button) +# 右上 × (常時表示のクローズボタン) - extendedWaitUntil: visible: id: "paywall-close-button" - timeout: 15000 + timeout: 5000 - tapOn: id: "paywall-close-button" -# Handle App Store review dialog (system dialog = optional: true allowed) +# App Store レビューダイアログ(システム) は optional # Japanese: "今はしない" / English: "Not Now" - tapOn: text: "今はしない|Not Now" optional: true -# Step 6: Verify main screen arrival after Free dismiss +# 無課金でメイン画面(feed)に到達することを検証 - extendedWaitUntil: visible: - id: "mypath-root" + id: "feed-scroll" timeout: 10000 - takeScreenshot: "soft-paywall-free-dismiss" diff --git a/aniccaios/maestro/v1.8.0/04-paywall_variant_b_en.yaml b/aniccaios/maestro/v1.8.0/04-paywall_variant_b_en.yaml index fab28b5c0..5b7118282 100644 --- a/aniccaios/maestro/v1.8.0/04-paywall_variant_b_en.yaml +++ b/aniccaios/maestro/v1.8.0/04-paywall_variant_b_en.yaml @@ -1,6 +1,6 @@ # Test 4: Paywall Variant B — English # Variant B (test): PaywallVariantBView -# Verifies: daily price, trial badge, review, trust copy, all elements +# Verifies: EN localization, plans, review copy, lifetime は英語では非表示, soft × で無課金メイン到達 appId: ai.anicca.app.ios name: Paywall Variant B EN @@ -18,7 +18,7 @@ tags: env: PAYWALL_VARIANT: "test" -- runFlow: ../_onboarding_to_paywall.yaml +- runFlow: ./_onboarding_to_paywall.yaml # Variant B paywall - extendedWaitUntil: @@ -30,11 +30,11 @@ tags: # Verify Variant B elements - assertVisible: - text: "Transform Your Daily Habits" + text: "Gentle words when you need them most" - assertVisible: - id: "paywall-dismiss" + text: "Monthly Plan" - assertVisible: - id: "paywall-maybe-later" + text: "Annual Plan" - assertVisible: id: "paywall-restore" @@ -42,9 +42,16 @@ tags: - assertVisible: text: ".*Anicca helped me.*" -# Dismiss +# 英語(非日本語)には lifetime(買い切り) を表示しない +- assertNotVisible: + text: "Lifetime" + +# Soft paywall: 右上 × で無課金メインへ +- assertVisible: + id: "paywall-close-button" + - tapOn: - id: "paywall-dismiss" + id: "paywall-close-button" - tapOn: text: "今はしない|Not Now" @@ -52,7 +59,7 @@ tags: - extendedWaitUntil: visible: - id: "mypath-root" + id: "feed-scroll" timeout: 10000 - takeScreenshot: "v180-04-variant-b-en-home" diff --git a/aniccaios/maestro/v1.8.0/05-paywall_variant_b_ja.yaml b/aniccaios/maestro/v1.8.0/05-paywall_variant_b_ja.yaml index c65980d52..5eb91d5b1 100644 --- a/aniccaios/maestro/v1.8.0/05-paywall_variant_b_ja.yaml +++ b/aniccaios/maestro/v1.8.0/05-paywall_variant_b_ja.yaml @@ -1,5 +1,5 @@ # Test 5: Paywall Variant B — Japanese -# Verifies: JA localization on Variant B +# Verifies: JA localization on Variant B + 日本語話者に lifetime(買い切り) が表示される + 右上 × で無課金メイン到達 appId: ai.anicca.app.ios name: Paywall Variant B JA @@ -20,7 +20,7 @@ tags: env: PAYWALL_VARIANT: "test" -- runFlow: ../_onboarding_to_paywall.yaml +- runFlow: ./_onboarding_to_paywall.yaml - extendedWaitUntil: visible: @@ -29,17 +29,23 @@ tags: - takeScreenshot: "v180-05-variant-b-ja" -# Verify JA Variant B title +# JA Variant B の要素 - assertVisible: - text: "毎日の習慣を変える" + text: ".*そっと届く言葉.*" -# Verify JA review - assertVisible: - text: ".*Aniccaのおかげで.*" + text: "月額プラン" -# Dismiss +- assertVisible: + text: "年額プラン" + +# 日本語話者には lifetime(買い切り) プランが表示される +- assertVisible: + text: "買い切り" + +# Soft paywall: 右上 × で無課金メインへ - tapOn: - id: "paywall-dismiss" + id: "paywall-close-button" - tapOn: text: "今はしない|Not Now" @@ -47,7 +53,7 @@ tags: - extendedWaitUntil: visible: - id: "mypath-root" + id: "feed-scroll" timeout: 10000 - takeScreenshot: "v180-05-variant-b-ja-home" diff --git a/docs/superpowers/specs/2026-07-03-soft-paywall-and-jp-lifetime-design.md b/docs/superpowers/specs/2026-07-03-soft-paywall-and-jp-lifetime-design.md new file mode 100644 index 000000000..e57bb8e63 --- /dev/null +++ b/docs/superpowers/specs/2026-07-03-soft-paywall-and-jp-lifetime-design.md @@ -0,0 +1,108 @@ +# Spec: ソフトペイウォール化 + 日本語話者への Lifetime 表示 + +- 日付: 2026-07-03 +- ブランチ: `claude/aicca-ios-pricing-paywall-nzkmax` +- 対象: iOS アプリ `aniccaios` (Anicca / ai.anicca.app.ios), MARKETING_VERSION 1.9.0 +- 依頼者: Dais + +## 1. Goal (Dais verbatim の要約) + +本番アプリのオンボーディング末尾のペイウォールに対し、次の2点を修正する。 + +| # | 現状 (バグ) | あるべき姿 (contract) | +|---|---|---| +| G1 | Lifetime(買い切り・一生)プランが日本語ユーザーに表示されない | 日本語話者には 月額 → 年額 → 買い切り の3プランを表示。英語(非日本語)には 月額 + 年額 のみ表示(買い切り非表示) | +| G2 | ハードペイウォール(閉じるボタンなし・課金しないとメイン画面に進めない) | ソフトペイウォール。画面右上に平均サイズのバツ(×)ボタン。タップで**課金せず**メイン画面(FeedRootView)へ遷移 | + +Dais verbatim: 「ライフタイムのプランが日本語で出ればいい。日本語の話者に対して出ればいい」「右上にバツボタンが大きすぎず小さすぎず平均サイズ…タップしたらそのPウォールから次の画面にいける」 + +## 2. 現状分析 (as-is, file:line 根拠) + +| 事実 | 根拠 | +|---|---| +| 本番ペイウォールは `PaywallVariantBView`。primer → planSelection の2段 | `Onboarding/OnboardingBibleViews.swift:707` `PaywallFlowContainer` | +| Lifetime は **ストアフロント国 == "JPN"** でゲート(言語ではない) | `Onboarding/PaywallVariantBView.swift:27-30` | +| `storefrontCountry` は `.task` 内で async 取得、初期値 `""` | `PaywallVariantBView.swift:20, 96-98` | +| 閉じるボタンが**存在しない**。`onDismiss` は no-op | `OnboardingBibleViews.swift:722` `onDismiss: { /* hard paywall: no-op */ }` | +| 提示は `.fullScreenCover` + `.interactiveDismissDisabled(true)` | `Onboarding/OnboardingFlowView.swift:46-53` | +| 日本語判定の既存パターン = `Locale.preferredLanguages.first` の `hasPrefix("ja")` | `Models/UserProfile.swift:48-49`, `Services/QuoteProvider.swift:770-771` | +| Lifetime のローカライズ文字列は全6言語に存在 | `paywall_b_plan_lifetime` / `paywall_b_per_lifetime` / `paywall_b_lifetime_badge` | +| Lifetime 商品は storekit / RC に定義済 | `Anicca.storekit` `ai.anicca.app.ios.lifetime` NonConsumable | +| 無課金 dismiss 用の分析イベントが既に定義済 | `AnalyticsManager` `onboardingPaywallDismissedFree` | +| 無課金 dismiss 用ハンドラ名が既にコメントに参照済 | `AppState.swift:171` `handlePaywallDismissedAsFree` | +| ソフトペイウォール用 Maestro テストが既存(実装が追いついていない) | `maestro/onboarding/03-soft-paywall.yaml` が `paywall-close-button` を期待 | + +→ ソフトペイウォール設計は元々存在したが、あるコミットでハード化され、閉じるボタンとdismiss配線が失われた。本 spec でそれを復元 + lifetime を言語判定へ変更する。 + +## 3. Contract + +### 3.1 Lifetime 表示 (G1) + +``` +入力: 端末の優先言語 (Locale.preferredLanguages.first), RevenueCat current offering の availablePackages +出力: lifetimePackage: Package? +規則: + - isJapanese == true かつ offering に .lifetime あり → その Package を返す + - それ以外 → nil (表示しない) + - isJapanese := (Locale.preferredLanguages.first ?? "en").lowercased().hasPrefix("ja") +不変条件: + - 月額(.monthly)・年額(.annual)は言語に依らず常に表示 (従来通り) + - 並び順は 月 → 年 → 買い切り (従来通り, PaywallVariantBView.swift:138) + - storefront(国)への依存を撤廃 (async レース起因の非表示を根絶) +``` + +### 3.2 ソフトペイウォール (G2) + +``` +入力: ユーザーが右上 × をタップ +出力: ペイウォールを閉じ、FeedRootView(メイン)へ遷移。課金は発生しない +規則: + - × ボタン: 右上 (topTrailing)、SF Symbol "xmark"、平均サイズ (アイコン ~17pt / タップ領域 ~44pt) + - accessibilityIdentifier = "paywall-close-button" + - primer/planSelection どちらの段でも常に表示 (PaywallFlowContainer レベルの overlay) + - タップ時: onboardingPaywallDismissedFree を track → appState.markOnboardingComplete() → showPaywall = false +不変条件: + - 購入成功パス(handlePaywallSuccess)は不変 + - .interactiveDismissDisabled(true) は維持 (スワイプ誤操作での離脱は防ぐ。離脱は明示的な × のみ) + - 課金しない dismiss でも isOnboardingComplete=true (以降ペイウォールを再表示しない) +``` + +## 4. 触るファイル (境界) + +| ファイル | 変更 | +|---|---| +| `aniccaios/aniccaios/Onboarding/PaywallVariantBView.swift` | lifetime ゲートを言語判定へ。未使用の storefront コードを削除 | +| `aniccaios/aniccaios/Onboarding/OnboardingBibleViews.swift` | `PaywallFlowContainer` に `onDismiss` param + 右上 × overlay | +| `aniccaios/aniccaios/Onboarding/OnboardingFlowView.swift` | `onDismiss` を `handlePaywallDismissedAsFree` に配線 | +| `aniccaios/maestro/onboarding/03-soft-paywall.yaml` | 現行フロー/`feed-scroll` に整合、lifetime(ja)検証追加 | +| `aniccaios/maestro/v1.8.0/05-paywall_variant_b_ja.yaml` | stale な dismiss id / 文言を現行に整合 | + +## 5. E2E 判定 (GATE 3) + +| 項目 | 判定 | +|---|---| +| E2E 必要か | **必要**。ペイウォールは課金導線 = user-facing の中核 | +| テスト | `maestro/onboarding/03-soft-paywall.yaml` (× → feed-scroll 到達, 無課金) + ja 起動で lifetime "買い切り" 可視を assert | +| 実機確認 | Dais が simulator/実機で ① ja: 月/年/買い切り3枚 ② en: 月/年2枚 ③ × で無課金メイン到達 を目視 | + +## 6. 実行環境の制約 (honesty) + +本セッションは Linux リモートコンテナで実行されており、iOS toolchain(xcodebuild / swift / maestro / asc)は**存在しない**(`uname` = Linux, `which` 全て not found)。 +従ってビルド・Maestro 実走・App Store Connect 提出は**このセッションでは物理的に実行不可**。 + +- 本セッションの成果物 = コード変更 + Maestro yaml + 本 spec を push + draft PR。 +- ビルド/E2E/提出は Mac 上で以下を実行 (§7)。 + +## 7. Mac 側リリース手順 (Dais が実行) + +```bash +cd aniccaios +# 1. バージョン bump (App Store は同一ビルド番号を再提出不可) +fastlane set_version version:1.9.1 # or 適宜 +# 2. Maestro E2E (simulator) +maestro test maestro/onboarding/03-soft-paywall.yaml +# 3. 完全自動リリース (build → upload → wait → submit for review) +fastlane full_release +``` + +`fastlane full_release` = build+upload+wait_for_processing+submit_review。完了で「審査待ち(Waiting for Review)」状態になる。