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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user