Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions aniccaios/aniccaios/Onboarding/OnboardingBibleViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -702,27 +702,42 @@ 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 })
case .planSelection:
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)
}
}
}
15 changes: 13 additions & 2 deletions aniccaios/aniccaios/Onboarding/OnboardingFlowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -47,6 +48,9 @@ struct OnboardingFlowView: View {
PaywallFlowContainer(
onPurchaseSuccess: { customerInfo in
handlePaywallSuccess(customerInfo: customerInfo)
},
onDismiss: {
handlePaywallDismissedAsFree()
}
)
.interactiveDismissDisabled(true)
Expand Down Expand Up @@ -108,4 +112,11 @@ struct OnboardingFlowView: View {
appState.markOnboardingComplete()
showPaywall = false
}

/// ソフトペイウォール: 右上 × で課金せずメイン画面へ。
private func handlePaywallDismissedAsFree() {
AnalyticsManager.shared.track(.onboardingPaywallDismissedFree)
appState.markOnboardingComplete()
showPaywall = false
}
}
15 changes: 8 additions & 7 deletions aniccaios/aniccaios/Onboarding/PaywallVariantBView.swift
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 }
}

Expand Down Expand Up @@ -93,9 +97,6 @@ struct PaywallVariantBView: View {
selectedPackage = yearlyPackage ?? monthlyPackage
}
}
.task {
storefrontCountry = await Storefront.current?.countryCode ?? ""
}
.sheet(isPresented: $showRetention) {
RetentionOfferSheet { customerInfo in
showRetention = false
Expand Down
66 changes: 11 additions & 55 deletions aniccaios/maestro/onboarding/03-soft-paywall.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
23 changes: 15 additions & 8 deletions aniccaios/maestro/v1.8.0/04-paywall_variant_b_en.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -18,7 +18,7 @@ tags:
env:
PAYWALL_VARIANT: "test"

- runFlow: ../_onboarding_to_paywall.yaml
- runFlow: ./_onboarding_to_paywall.yaml

# Variant B paywall
- extendedWaitUntil:
Expand All @@ -30,29 +30,36 @@ 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"

# Verify review text
- 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"
optional: true

- extendedWaitUntil:
visible:
id: "mypath-root"
id: "feed-scroll"
timeout: 10000

- takeScreenshot: "v180-04-variant-b-en-home"
24 changes: 15 additions & 9 deletions aniccaios/maestro/v1.8.0/05-paywall_variant_b_ja.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -20,7 +20,7 @@ tags:
env:
PAYWALL_VARIANT: "test"

- runFlow: ../_onboarding_to_paywall.yaml
- runFlow: ./_onboarding_to_paywall.yaml

- extendedWaitUntil:
visible:
Expand All @@ -29,25 +29,31 @@ 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"
optional: true

- extendedWaitUntil:
visible:
id: "mypath-root"
id: "feed-scroll"
timeout: 10000

- takeScreenshot: "v180-05-variant-b-ja-home"
Loading