diff --git a/README.md b/README.md index b342d5c..ad9d637 100644 --- a/README.md +++ b/README.md @@ -71,18 +71,32 @@ it is not built. ### The intelligent paywall +**Monetization philosophy.** The goal is not to interrupt the conversation +— the goal is to build trust. The free experience should make the user feel +understood, respected, and curious to continue; only when the conversation +has reached a meaningful point is Premium introduced. The rule given to the +model, verbatim: _"Never manufacture suspense. Create genuine curiosity by +helping the user reach a meaningful insight, then offer a deeper level of +analysis in Premium."_ + The paywall is not a hardcoded message count. Free conversations run in _discovery mode_: the model asks relevant follow-up questions, names -patterns, shows understanding, and builds toward an analysis — without -delivering the full solution. When the problem is described, the information -is sufficient, and a concrete action plan is ready, the model signals -`analysis_ready`, writes a calm transition ("…I have a concrete strategy I -would recommend. Continue with Premium to see the analysis and the -recommended steps."), and the conversation pauses. +patterns, shows understanding, and helps the user reach a meaningful +insight of their own. When the conversation has reached that point and the +natural next step is a complete analysis, a structured framework, a +personalised strategy, practical exercises, or a step-by-step action plan, +the model pauses immediately _before_ delivering it — never mid-sentence, +never in the middle of an explanation — signals `analysis_ready`, and +writes a calm transition: "I think I understand the core pattern behind +what you've described. There are a few recurring themes that stand out, and +I have a structured way of working through them with you. Unlock Premium to +continue with the full analysis and your personalised action plan." -The instructions explicitly forbid manufactured urgency, emotional pressure, -fake readiness, and stopping mid-answer — Premium should feel like the -natural continuation of an already valuable dialogue. +**Product feeling.** Every interaction should leave the user thinking +"this understands me". Every Premium conversion should feel like "I +genuinely want to continue this conversation" — never "they stopped me just +to make me pay". Manufactured urgency, emotional pressure and fake +readiness are explicitly forbidden in the instructions. On unlock, the app resends the transcript; premium mode then delivers the full analysis, recommended strategies and concrete exercises immediately, @@ -109,6 +123,13 @@ provider differs per platform behind one `PaymentProvider` interface: Plans: Monthly and Yearly. Nothing else. +**Pricing.** $5.99/month, $49.99/year (≈30 % below the monthly rate). +Premium includes unlimited conversations, unlimited analyses, personalised +guidance, and future feature updates. Prices are configured in App Store +Connect / Play Console on the products `semantika_monthly` and +`semantika_yearly`; the paywall fetches localized prices from the store and +falls back to these defaults until the store answers. + ## Getting started ```sh diff --git a/apps/mobile/src/purchases/index.ts b/apps/mobile/src/purchases/index.ts index f9e9368..b4e1e68 100644 --- a/apps/mobile/src/purchases/index.ts +++ b/apps/mobile/src/purchases/index.ts @@ -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(fn: () => Promise): Promise { } } +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 { + 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. diff --git a/apps/mobile/src/screens/PaywallScreen.tsx b/apps/mobile/src/screens/PaywallScreen.tsx index b39d719..558c848 100644 --- a/apps/mobile/src/screens/PaywallScreen.tsx +++ b/apps/mobile/src/screens/PaywallScreen.tsx @@ -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(null); + const [prices, setPrices] = useState(DEFAULT_PRICES); const [busy, setBusy] = useState(false); const [error, setError] = useState(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) { Continue your journey - 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. + + {PREMIUM_INCLUDES.map((item) => ( + + {item} + + ))} + {signedIn === false ? ( @@ -92,9 +115,11 @@ export function PaywallScreen({ onSubscribed, onDismiss }: Props) { <> buy('monthly')} disabled={busy}> Monthly + {prices.monthly} buy('yearly')} disabled={busy}> Yearly + {prices.yearly} run(restore)} disabled={busy}> Restore Purchase @@ -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, diff --git a/services/api/src/chat.ts b/services/api/src/chat.ts index 48ffaf3..3e7eee6 100644 --- a/services/api/src/chat.ts +++ b/services/api/src/chat.ts @@ -83,33 +83,49 @@ Principles: const FREE_INSTRUCTIONS = `# Conversation mode: free tier (discovery) +The goal is not to interrupt the conversation. The goal is to build trust. +The free experience should make the user feel understood, respected, and +curious to continue. + Work in discovery mode. In every reply you should: - ask relevant follow-up questions (one at a time), - identify and name patterns you notice, - show genuine understanding of the user's situation, -- build toward a complete analysis. +- help the user reach a meaningful insight of their own. -Do not yet present the full analysis, the recommended strategy, or concrete -exercises. +Never manufacture suspense. Create genuine curiosity by helping the user +reach a meaningful insight, then offer a deeper level of analysis in +Premium. -Set "analysis_ready" to true ONLY when all of the following are genuinely met: -- the user has described their problem, -- you have enough information to give a concrete, personal recommendation, -- a specific action plan is ready to present. +Set "analysis_ready" to true ONLY when all of the following are genuinely +met: +- the user has described their situation and the conversation has reached + a meaningful point — a first real insight, +- you have enough information to give concrete, personal recommendations, +- the natural next step is one of: a complete analysis, a structured + framework, a personalised strategy, practical exercises, or a + step-by-step action plan. -When analysis_ready is true, the reply must be a calm, natural transition — -not an interruption mid-answer. Summarize at a high level what you have -understood and that a concrete analysis is ready. Example of tone: -"I'm starting to see some recurring patterns in what you describe. I have -a concrete analysis and several recommendations that build on what we have -explored. Unlock Premium to continue." +Pause immediately BEFORE delivering that next step — never mid-sentence, +never in the middle of an explanation, never in the middle of answering a +direct question. + +When analysis_ready is true, the reply must be a calm, natural transition. +Meaningful work has already happened; Premium is simply the natural next +step. Example of tone: +"I think I understand the core pattern behind what you've described. There +are a few recurring themes that stand out, and I have a structured way of +working through them with you. Unlock Premium to continue with the full +analysis and your personalised action plan." Never: - manufacture urgency or emotional pressure to drive a purchase, - claim readiness or insight you do not have, -- stop in the middle of answering a direct question, - mention Premium in any other situation. +The user should always feel "I genuinely want to continue this +conversation" — never "they stopped me just to make me pay." + Otherwise, set analysis_ready to false.`; const PREMIUM_INSTRUCTIONS = `# Conversation mode: premium