Insight-first monetization: trust-building free tier and pricing

Adjusts the paywall strategy from withholding toward insight-first:

- Free-tier instructions rewritten around the monetization philosophy:
  the goal is not to interrupt the conversation but to build trust. The
  free experience should leave the user feeling understood, respected
  and curious to continue, and should help the user reach a meaningful
  insight of their own. The rule is included verbatim: 'Never
  manufacture suspense. Create genuine curiosity by helping the user
  reach a meaningful insight, then offer a deeper level of analysis in
  Premium.'
- Premium transition sharpened: analysis_ready may only fire when the
  conversation has reached a meaningful point and the natural next step
  is a complete analysis, structured framework, personalised strategy,
  practical exercises or a step-by-step action plan — pausing
  immediately before that delivery, never mid-sentence or mid-
  explanation. Transition example updated to the new wording. Product
  feeling encoded: always 'I genuinely want to continue this
  conversation', never 'they stopped me just to make me pay'.
- Pricing: paywall now shows plan prices — localized prices fetched
  from the store via react-native-iap, falling back to $5.99/month and
  $49.99/year (~30% below monthly) until store config exists — plus
  the Premium includes list (unlimited conversations, unlimited
  analyses, personalised guidance, future feature updates).
- README: monetization philosophy, product feeling and pricing sections
  updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0118DaxZR36RpnY524vRqx3z
This commit is contained in:
Claude
2026-08-03 15:15:46 +00:00
parent 77d0322c09
commit 720a8fdca4
4 changed files with 136 additions and 26 deletions
+45
View File
@@ -2,6 +2,7 @@ import { Platform } from 'react-native';
import {
endConnection,
getAvailablePurchases,
getSubscriptions,
initConnection,
requestSubscription,
type SubscriptionPurchase,
@@ -11,6 +12,17 @@ import { appConfig } from '../config';
export type Plan = 'monthly' | 'yearly';
export interface PlanPrices {
monthly: string;
yearly: string;
}
/** Shown until the store answers; real localized prices come from the store. */
export const DEFAULT_PRICES: PlanPrices = {
monthly: '$5.99 / month',
yearly: '$49.99 / year',
};
const platform: 'ios' | 'android' = Platform.OS === 'ios' ? 'ios' : 'android';
export function productIdFor(plan: Plan): string {
@@ -34,6 +46,39 @@ async function withConnection<T>(fn: () => Promise<T>): Promise<T> {
}
}
function priceOf(product: unknown): string | undefined {
if (!product) return undefined;
// iOS exposes localizedPrice; Android nests it in the first offer's pricing phases.
const p = product as {
localizedPrice?: string;
subscriptionOfferDetails?: {
pricingPhases?: { pricingPhaseList?: { formattedPrice?: string }[] };
}[];
};
return (
p.localizedPrice ??
p.subscriptionOfferDetails?.[0]?.pricingPhases?.pricingPhaseList?.[0]?.formattedPrice
);
}
/** Localized prices from the store, falling back to the defaults. */
export async function getPrices(): Promise<PlanPrices> {
try {
return await withConnection(async () => {
const products = await getSubscriptions({
skus: [productIdFor('monthly'), productIdFor('yearly')],
});
const find = (plan: Plan) => products.find((p) => p.productId === productIdFor(plan));
return {
monthly: priceOf(find('monthly')) ?? DEFAULT_PRICES.monthly,
yearly: priceOf(find('yearly')) ?? DEFAULT_PRICES.yearly,
};
});
} catch {
return DEFAULT_PRICES;
}
}
/**
* Runs the native purchase flow, then lets the backend verify the receipt
* with the store. Returns the resulting subscription status.
+31 -3
View File
@@ -1,7 +1,14 @@
import { useEffect, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { getAccessToken, signIn, type Provider } from '../auth/session';
import { purchase, restore, type Plan } from '../purchases';
import {
DEFAULT_PRICES,
getPrices,
purchase,
restore,
type Plan,
type PlanPrices,
} from '../purchases';
import { colors, spacing, type } from '../theme';
interface Props {
@@ -9,17 +16,26 @@ interface Props {
onDismiss: () => void;
}
const PREMIUM_INCLUDES = [
'Unlimited conversations',
'Unlimited analyses',
'Personalised guidance',
'Future feature updates',
];
/**
* Shown when the backend has a complete analysis ready. Guests sign in
* first (purchases must attach to an account), then choose a plan.
*/
export function PaywallScreen({ onSubscribed, onDismiss }: Props) {
const [signedIn, setSignedIn] = useState<boolean | null>(null);
const [prices, setPrices] = useState<PlanPrices>(DEFAULT_PRICES);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getAccessToken().then((token) => setSignedIn(token !== null));
getPrices().then(setPrices);
}, []);
async function handleSignIn(provider: Provider) {
@@ -59,9 +75,16 @@ export function PaywallScreen({ onSubscribed, onDismiss }: Props) {
<View style={styles.top}>
<Text style={styles.title}>Continue your journey</Text>
<Text style={styles.subtitle}>
Your analysis and recommended steps are ready. Premium unlocks them and lets the
conversation continue without limits.
Your full analysis and personalised action plan are ready. Premium is the natural next
step of the conversation you have already started.
</Text>
<View style={styles.includes}>
{PREMIUM_INCLUDES.map((item) => (
<Text key={item} style={styles.includesItem}>
{item}
</Text>
))}
</View>
</View>
<View style={styles.actions}>
{signedIn === false ? (
@@ -92,9 +115,11 @@ export function PaywallScreen({ onSubscribed, onDismiss }: Props) {
<>
<Pressable style={styles.primaryButton} onPress={() => buy('monthly')} disabled={busy}>
<Text style={styles.primaryButtonText}>Monthly</Text>
<Text style={styles.priceText}>{prices.monthly}</Text>
</Pressable>
<Pressable style={styles.primaryButton} onPress={() => buy('yearly')} disabled={busy}>
<Text style={styles.primaryButtonText}>Yearly</Text>
<Text style={styles.priceText}>{prices.yearly}</Text>
</Pressable>
<Pressable style={styles.textButton} onPress={() => run(restore)} disabled={busy}>
<Text style={styles.textButtonText}>Restore Purchase</Text>
@@ -133,6 +158,9 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
primaryButtonText: { ...type.heading, color: colors.accentText },
priceText: { ...type.caption, color: colors.accentText, marginTop: 2 },
includes: { marginTop: spacing.l, gap: spacing.xs },
includesItem: { ...type.caption, textAlign: 'center' },
secondaryButton: {
backgroundColor: colors.surface,
borderColor: colors.border,