diff --git a/__tests__/rntl/onboarding/ChatScreenSpotlight.test.tsx b/__tests__/rntl/onboarding/ChatScreenSpotlight.test.tsx index 7b5da323..0278e0bf 100644 --- a/__tests__/rntl/onboarding/ChatScreenSpotlight.test.tsx +++ b/__tests__/rntl/onboarding/ChatScreenSpotlight.test.tsx @@ -156,6 +156,7 @@ jest.mock('../../../src/components', () => ({ ...require('../../utils/spotlightMocks').createCustomAlertMock(), ToolPickerSheet: () => null, SharePromptSheet: () => null, + ProAhaSheet: () => null, })); jest.mock('../../../src/components/AnimatedPressable', () => diff --git a/__tests__/rntl/screens/ChatScreen.test.tsx b/__tests__/rntl/screens/ChatScreen.test.tsx index afd66525..ce10758a 100644 --- a/__tests__/rntl/screens/ChatScreen.test.tsx +++ b/__tests__/rntl/screens/ChatScreen.test.tsx @@ -423,6 +423,7 @@ jest.mock('../../../src/components', () => ({ ); }, SharePromptSheet: () => null, + ProAhaSheet: () => null, })); jest.mock('../../../src/components/AnimatedEntry', () => ({ diff --git a/__tests__/rntl/screens/SettingsScreen.test.tsx b/__tests__/rntl/screens/SettingsScreen.test.tsx index 25456e80..89755cee 100644 --- a/__tests__/rntl/screens/SettingsScreen.test.tsx +++ b/__tests__/rntl/screens/SettingsScreen.test.tsx @@ -90,7 +90,7 @@ describe('SettingsScreen', () => { it('renders version number', () => { const { getByText } = render(); - expect(getByText('1.0.0')).toBeTruthy(); + expect(getByText(/Version 1\.0\.0/)).toBeTruthy(); }); it('renders navigation items', () => { @@ -155,8 +155,8 @@ describe('SettingsScreen', () => { it('renders about section text', () => { const { getByText } = render(); - expect(getByText('Version')).toBeTruthy(); - expect(getByText(/Off Grid brings AI/)).toBeTruthy(); + expect(getByText('About')).toBeTruthy(); + expect(getByText(/Version/)).toBeTruthy(); }); it('renders Reset Onboarding button in __DEV__ mode', () => { diff --git a/src/components/MadeWithLove.tsx b/src/components/MadeWithLove.tsx index 0f57dd07..4bff365d 100644 --- a/src/components/MadeWithLove.tsx +++ b/src/components/MadeWithLove.tsx @@ -1,8 +1,6 @@ import React from 'react'; import { View, Text, Image, TouchableOpacity, Linking, StyleSheet } from 'react-native'; -import { TYPOGRAPHY } from '../constants'; - -const WEDNESDAY_URL = 'https://www.wednesday.is/?utm_source=off-grid-mobile-app'; +import { TYPOGRAPHY, WEDNESDAY_URL } from '../constants'; export const MadeWithLove: React.FC = () => ( Linking.openURL(WEDNESDAY_URL)} style={styles.container}> diff --git a/src/components/ProAhaSheet.tsx b/src/components/ProAhaSheet.tsx new file mode 100644 index 00000000..4be217a3 --- /dev/null +++ b/src/components/ProAhaSheet.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { View, Text, TouchableOpacity } from 'react-native'; +import Icon from 'react-native-vector-icons/Feather'; +import { AppSheet } from './AppSheet'; +import { useThemedStyles } from '../theme'; +import type { ThemeColors, ThemeShadows } from '../theme'; +import { SPACING, TYPOGRAPHY, PRO_AHA_FEATURES } from '../constants'; + +interface ProAhaSheetProps { + visible: boolean; + onClose: () => void; + onRegister: () => void; +} + +export const ProAhaSheet: React.FC = ({ visible, onClose, onRegister }) => { + const styles = useThemedStyles(createStyles); + + const handleCta = () => { + onClose(); + onRegister(); + }; + + return ( + + + Loving Off Grid? + + Help us build what's next - and get it free for life. + + + + {PRO_AHA_FEATURES.map(feature => ( + + + {feature} + + ))} + + + + Ship in 12 weeks or full refund. No questions asked. + + + + I am in 🔥 + + + + ); +}; + +const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ + content: { + paddingHorizontal: SPACING.xl, + paddingTop: SPACING.md, + paddingBottom: SPACING.xxl, + alignItems: 'center' as const, + }, + headline: { + ...TYPOGRAPHY.h2, + color: colors.text, + textAlign: 'center' as const, + marginBottom: SPACING.sm, + }, + subheadline: { + ...TYPOGRAPHY.body, + color: colors.textSecondary, + textAlign: 'center' as const, + marginBottom: SPACING.md, + }, + priceRow: { + marginBottom: SPACING.lg, + }, + price: { + ...TYPOGRAPHY.bodySmall, + color: colors.primary, + textAlign: 'center' as const, + }, + featureList: { + width: '100%' as const, + marginBottom: SPACING.lg, + gap: SPACING.sm, + }, + featureRow: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: SPACING.sm, + }, + checkIcon: { + color: colors.primary, + }, + featureText: { + ...TYPOGRAPHY.body, + color: colors.text, + }, + guarantee: { + ...TYPOGRAPHY.bodySmall, + color: colors.textMuted, + textAlign: 'center' as const, + marginBottom: SPACING.lg, + }, + ctaButton: { + width: '100%' as const, + paddingVertical: SPACING.md, + backgroundColor: colors.primary, + borderRadius: 8, + alignItems: 'center' as const, + justifyContent: 'center' as const, + marginBottom: SPACING.sm, + }, + ctaText: { + ...TYPOGRAPHY.body, + color: colors.background, + }, +}); diff --git a/src/components/index.ts b/src/components/index.ts index 2aa311a3..6108819f 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -22,6 +22,7 @@ export { ProjectSelectorSheet } from './ProjectSelectorSheet'; export { DebugSheet } from './DebugSheet'; export { ToolPickerSheet } from './ToolPickerSheet'; export { SharePromptSheet } from './SharePromptSheet'; +export { ProAhaSheet } from './ProAhaSheet'; export { OnboardingSheet, PulsatingIcon, diff --git a/src/constants/index.ts b/src/constants/index.ts index dfa3e32d..bf37768d 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,5 +1,15 @@ export { MODEL_RECOMMENDATIONS, RECOMMENDED_MODELS, TRENDING_FAMILIES, TRENDING_MODEL_IDS, MODEL_ORGS, QUANTIZATION_INFO } from './models'; +// External URLs +export const WEDNESDAY_URL = 'https://mobile.wednesday.is/hire-ai-native-mobile-squad?utm_source=off-grid-mobile-app&utm_medium=made-with-love&utm_campaign=in-app'; + +export const PRO_AHA_FEATURES = [ + 'Voice-native conversation', + 'Custom MCP servers', + 'Calendar and WhatsApp integration', + 'More, shipping monthly', +]; + // Hugging Face API configuration export const HF_API = { baseUrl: 'https://huggingface.co', diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 1d15b73a..fbbc4322 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -37,6 +37,8 @@ import { SecuritySettingsScreen, GalleryScreen, RemoteServersScreen, + ProDetailScreen, + AboutScreen, } from '../screens'; import { RootStackParamList, @@ -232,6 +234,16 @@ export const AppNavigator: React.FC = () => { + + { + const navigation = useNavigation(); + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + const focusTrigger = useFocusTrigger(); + + return ( + + + navigation.goBack()} style={styles.backButton}> + + + About + + + + + {/* App identity */} + + + Off Grid + Version {packageJson.version} + + Local AI that runs entirely on your phone. No cloud, no telemetry, nothing leaves the device. + + + + {/* Open Source row */} + + Linking.openURL(GITHUB_URL)} + > + + + + + Open Source + View the source on GitHub + + + + + + {/* Built by Wednesday row */} + + Linking.openURL(WEDNESDAY_MOBILE_URL)} + > + + + + + Built by Wednesday + We build mobile apps for enterprise teams + + + + + + + {/* Pinned footer */} + + + ); +}; + +const staticStyles = StyleSheet.create({ + appIcon: { width: 72, height: 72, borderRadius: 16, marginBottom: SPACING.md }, +}); + +const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ + container: { flex: 1, backgroundColor: colors.background }, + header: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + justifyContent: 'space-between' as const, + paddingHorizontal: SPACING.lg, + paddingVertical: SPACING.md, + minHeight: 60, + borderBottomWidth: 1, + borderBottomColor: colors.border, + backgroundColor: colors.surface, + ...shadows.small, + zIndex: 1, + }, + backButton: { width: 36, padding: SPACING.xs }, + headerTitle: { ...TYPOGRAPHY.h2, color: colors.text }, + content: { + paddingHorizontal: SPACING.lg, + paddingTop: SPACING.lg, + paddingBottom: SPACING.xxl, + }, + heroSection: { + alignItems: 'center' as const, + paddingVertical: SPACING.xxl, + marginBottom: SPACING.xl, + }, + appName: { + ...TYPOGRAPHY.h1, + color: colors.text, + marginBottom: SPACING.xs, + }, + version: { + ...TYPOGRAPHY.meta, + color: colors.textMuted, + marginBottom: SPACING.md, + }, + description: { + ...TYPOGRAPHY.body, + color: colors.textSecondary, + textAlign: 'center' as const, + lineHeight: 22, + paddingHorizontal: SPACING.md, + }, + navSection: { + backgroundColor: colors.surface, + borderRadius: 8, + marginBottom: SPACING.lg, + overflow: 'hidden' as const, + ...shadows.small, + }, + navItem: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + padding: SPACING.md, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + navItemLast: { borderBottomWidth: 0 }, + navItemIcon: { + width: 28, + height: 28, + borderRadius: 6, + alignItems: 'center' as const, + justifyContent: 'center' as const, + marginRight: SPACING.md, + }, + navItemContent: { flex: 1 }, + navItemTitle: { ...TYPOGRAPHY.body, color: colors.text }, + navItemDesc: { ...TYPOGRAPHY.bodySmall, color: colors.textMuted, marginTop: 2 }, + wednesdayLogo: { width: 24, height: 24, resizeMode: 'contain' as const }, +}); diff --git a/src/screens/ChatScreen/index.tsx b/src/screens/ChatScreen/index.tsx index 27c5475b..6d3635c2 100644 --- a/src/screens/ChatScreen/index.tsx +++ b/src/screens/ChatScreen/index.tsx @@ -1,11 +1,14 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { FlatList, KeyboardAvoidingView, InteractionManager } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useFocusEffect } from '@react-navigation/native'; +import { useFocusEffect, useNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { RootStackParamList } from '../../navigation/types'; import { useSpotlightTour } from 'react-native-spotlight-tour'; -import { CustomAlert, hideAlert, SharePromptSheet } from '../../components'; +import { CustomAlert, hideAlert, SharePromptSheet, ProAhaSheet } from '../../components'; import { consumePendingSpotlight } from '../../components/onboarding/spotlightState'; import { subscribeSharePrompt } from '../../utils/sharePrompt'; +import { subscribeProPrompt } from '../../utils/proPrompt'; import { VOICE_HINT_STEP_INDEX, IMAGE_SETTINGS_STEP_INDEX } from '../../components/onboarding/spotlightConfig'; import { useAppStore } from '../../stores/appStore'; import type { Conversation, Message } from '../../types'; @@ -24,6 +27,7 @@ function countConversationImages(conv: Conversation | undefined): number { export const ChatScreen: React.FC = () => { const flatListRef = React.useRef(null); const isNearBottomRef = React.useRef(true); + const rootNavigation = useNavigation>(); const { colors } = useTheme(); const styles = useThemedStyles(createStyles); const chat = useChatScreen(); @@ -32,6 +36,19 @@ export const ChatScreen: React.FC = () => { const [sharePromptVisible, setSharePromptVisible] = useState(false); useEffect(() => subscribeSharePrompt(() => setSharePromptVisible(true)), []); + + const [proAhaVisible, setProAhaVisible] = useState(false); + const proAhaShownThisSession = useRef(false); + useEffect(() => { + // Reset cycle on each new chat session so PRO sheet can fire again + useAppStore.getState().setProAhaTriggeredBy(null); + proAhaShownThisSession.current = false; + }, []); + useEffect(() => subscribeProPrompt(() => { + if (proAhaShownThisSession.current) return; + proAhaShownThisSession.current = true; + setProAhaVisible(true); + }), []); // Only ONE AttachStep mounted at a time to avoid waypoint dots/lines. // chatSpotlight controls which index is active (3, 12, 15, or 16). const [chatSpotlight, setChatSpotlight] = useState(null); @@ -227,6 +244,11 @@ export const ChatScreen: React.FC = () => { {alertEl} setSharePromptVisible(false)} /> + setProAhaVisible(false)} + onRegister={() => rootNavigation.navigate('ProDetail')} + /> ); }; diff --git a/src/screens/ProDetailScreen/index.tsx b/src/screens/ProDetailScreen/index.tsx new file mode 100644 index 00000000..7840cd9a --- /dev/null +++ b/src/screens/ProDetailScreen/index.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, ScrollView, Linking } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useNavigation } from '@react-navigation/native'; +import Icon from 'react-native-vector-icons/Feather'; +import { useTheme, useThemedStyles } from '../../theme'; +import type { ThemeColors, ThemeShadows } from '../../theme'; +import { SPACING, TYPOGRAPHY } from '../../constants'; +import { MadeWithLove } from '../../components/MadeWithLove'; +import { PRO_URL } from '../../utils/proPrompt'; +import { useAppStore } from '../../stores'; + +const FEATURES = [ + { icon: 'mic', title: 'Voice AI + Personas', desc: 'Talk to named AI assistants with personality and memory.' }, + { icon: 'calendar', title: 'Calendar Integration', desc: 'Read schedule, create events.' }, + { icon: 'mail', title: 'Email Integration', desc: 'Read inbox, draft replies.' }, + { icon: 'message-square', title: 'WhatsApp + Slack', desc: 'Summarize, draft, catch up.' }, + { icon: 'server', title: 'Custom MCP Servers', desc: 'Connect tools. Extend your AI.' }, +]; + +export const ProDetailScreen: React.FC = () => { + const navigation = useNavigation(); + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + const setHasRegisteredPro = useAppStore((s) => s.setHasRegisteredPro); + + const handleCTA = () => { + setHasRegisteredPro(true); + Linking.openURL(PRO_URL); + }; + + return ( + + + navigation.goBack()} style={styles.backButton}> + + + + + + Off Grid PRO + Lifetime access - Coming soon + + + {FEATURES.map(f => ( + + + + + + {f.title} + {f.desc} + + + ))} + + + + The first 100 get lifetime PRO at the lowest price we'll ever offer. + Register now - we'll send your purchase link when it's live. + + + + I am in 🔥 + + + + + + ); +}; + +const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ + flex: { flex: 1 }, + container: { flex: 1, backgroundColor: colors.background }, + header: { + paddingHorizontal: SPACING.lg, + paddingVertical: SPACING.sm, + }, + backButton: { + padding: SPACING.sm, + alignSelf: 'flex-start' as const, + }, + content: { + paddingHorizontal: SPACING.xl, + paddingBottom: SPACING.xxl, + }, + title: { + ...TYPOGRAPHY.h1, + color: colors.text, + marginBottom: SPACING.xs, + }, + subtitle: { + ...TYPOGRAPHY.bodySmall, + color: colors.textMuted, + marginBottom: SPACING.xl, + }, + featureList: { + gap: SPACING.lg, + marginBottom: SPACING.xl, + }, + featureRow: { + flexDirection: 'row' as const, + gap: SPACING.md, + }, + featureIconWrap: { + width: 28, + alignItems: 'center' as const, + paddingTop: 2, + }, + featureText: { flex: 1 }, + featureTitle: { + ...TYPOGRAPHY.body, + color: colors.text, + marginBottom: 2, + }, + featureDesc: { + ...TYPOGRAPHY.bodySmall, + color: colors.textSecondary, + }, + pitch: { + ...TYPOGRAPHY.bodySmall, + color: colors.textSecondary, + lineHeight: 20, + marginBottom: SPACING.xl, + }, + ctaButton: { + paddingVertical: SPACING.md, + backgroundColor: colors.primary, + borderRadius: 8, + alignItems: 'center' as const, + marginBottom: SPACING.xl, + }, + ctaText: { + ...TYPOGRAPHY.body, + color: colors.background, + }, +}); diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 9b543e0c..74376016 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -176,8 +176,29 @@ export const SettingsScreen: React.FC = () => { - {/* Community */} + {/* PRO */} + navigation.navigate('ProDetail')} + activeOpacity={0.75} + > + + + + + + Off Grid PRO + Voice. MCPs. Calendar. WhatsApp. + I am in 🔥 + + + + + + + {/* Community */} + Linking.openURL(GITHUB_URL)}> @@ -213,20 +234,23 @@ export const SettingsScreen: React.FC = () => { {/* About */} - - - - Version - {packageJson.version} - - - Off Grid brings AI to your device without compromising your privacy. - - + + + navigation.navigate('About')}> + + + + + About + Version {packageJson.version} + + + + {/* Privacy */} - + @@ -240,7 +264,7 @@ export const SettingsScreen: React.FC = () => { {/* Reset Onboarding */} - + @@ -320,4 +344,32 @@ const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ }, devButtonGroup: { gap: 12 }, devButtonText: { ...TYPOGRAPHY.bodySmall, color: colors.textMuted }, + proCard: { + backgroundColor: colors.surface, + borderRadius: 8, + marginBottom: SPACING.lg, + overflow: 'hidden' as const, + ...shadows.small, + }, + proCardContent: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + padding: SPACING.md, + gap: SPACING.md, + }, + proCardText: { flex: 1 }, + proTitle: { ...TYPOGRAPHY.body, color: colors.text }, + proDesc: { ...TYPOGRAPHY.bodySmall, color: colors.textMuted, marginTop: 2 }, + proIconContainer: { + width: 32, + height: 32, + borderRadius: 8, + backgroundColor: colors.primary, + alignItems: 'center' as const, + justifyContent: 'center' as const, + marginRight: SPACING.sm, + }, + proIcon: { color: colors.background }, + proChevron: { color: colors.textMuted }, + proCtaLink: { ...TYPOGRAPHY.bodySmall, color: colors.primary, marginTop: SPACING.xs }, }); diff --git a/src/screens/index.ts b/src/screens/index.ts index 9d5ec9b8..1ad2bb0d 100644 --- a/src/screens/index.ts +++ b/src/screens/index.ts @@ -21,3 +21,5 @@ export { DeviceInfoScreen } from './DeviceInfoScreen'; export { StorageSettingsScreen } from './StorageSettingsScreen'; export { SecuritySettingsScreen } from './SecuritySettingsScreen'; export { RemoteServersScreen } from './RemoteServersScreen'; +export { ProDetailScreen } from './ProDetailScreen'; +export { AboutScreen } from './AboutScreen'; diff --git a/src/services/generationService.ts b/src/services/generationService.ts index a732b370..ef5e9d55 100644 --- a/src/services/generationService.ts +++ b/src/services/generationService.ts @@ -7,6 +7,7 @@ import type { ToolResult } from './tools/types'; import { providerRegistry } from './providers'; import logger from '../utils/logger'; import { shouldShowSharePrompt, emitSharePrompt } from '../utils/sharePrompt'; +import { checkProPromptForText } from '../utils/proPrompt'; import { buildGenerationMetaImpl, buildToolLoopHandlersImpl, @@ -126,8 +127,9 @@ class GenerationService { private checkSharePrompt(delayMs = SHARE_PROMPT_DELAY_MS): void { const s = useAppStore.getState(); - if (s.hasEngagedSharePrompt) return; - if (shouldShowSharePrompt(s.incrementTextGenerationCount())) setTimeout(() => emitSharePrompt('text'), delayMs); + const count = s.incrementTextGenerationCount(); + if (!s.hasEngagedSharePrompt && shouldShowSharePrompt(count)) setTimeout(() => emitSharePrompt('text'), delayMs); + checkProPromptForText(delayMs); } private buildToolLoopHandlers() { return buildToolLoopHandlersImpl(this); } diff --git a/src/services/imageGenerationService.ts b/src/services/imageGenerationService.ts index 376f9280..d8212932 100644 --- a/src/services/imageGenerationService.ts +++ b/src/services/imageGenerationService.ts @@ -6,6 +6,7 @@ import { useAppStore, useChatStore } from '../stores'; import { GeneratedImage } from '../types'; import logger from '../utils/logger'; import { shouldShowSharePrompt, emitSharePrompt } from '../utils/sharePrompt'; +import { checkProPromptForImage } from '../utils/proPrompt'; import { buildEnhancementMessages, getConversationContext, cleanEnhancedPrompt, buildImageGenMeta } from './imageGenerationHelpers'; const SHARE_PROMPT_DELAY_MS = 2000; @@ -99,9 +100,10 @@ class ImageGenerationService { } private _checkSharePrompt(): void { - if (useAppStore.getState().hasEngagedSharePrompt) return; - const count = useAppStore.getState().incrementImageGenerationCount(); - if (shouldShowSharePrompt(count)) setTimeout(() => emitSharePrompt('image'), SHARE_PROMPT_DELAY_MS); + const s = useAppStore.getState(); + const count = s.incrementImageGenerationCount(); + if (!s.hasEngagedSharePrompt && shouldShowSharePrompt(count)) setTimeout(() => emitSharePrompt('image'), SHARE_PROMPT_DELAY_MS); + checkProPromptForImage(SHARE_PROMPT_DELAY_MS); } private async _resetLlmAfterEnhancement(): Promise { diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index 396a4963..1894a029 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -99,6 +99,11 @@ interface AppState { incrementImageGenerationCount: () => number; hasEngagedSharePrompt: boolean; setHasEngagedSharePrompt: (v: boolean) => void; + // PRO pre-order state + hasRegisteredPro: boolean; + setHasRegisteredPro: (v: boolean) => void; + proAhaTriggeredBy: 'image' | 'text' | null; + setProAhaTriggeredBy: (by: 'image' | 'text' | null) => void; loadedSettings: Partial | null; setLoadedSettings: (settings: Partial | null) => void; } @@ -278,6 +283,10 @@ export const useAppStore = create()( incrementImageGenerationCount: () => { const c = get().imageGenerationCount + 1; set({ imageGenerationCount: c }); return c; }, hasEngagedSharePrompt: false, setHasEngagedSharePrompt: (v) => set({ hasEngagedSharePrompt: v }), + hasRegisteredPro: false, + setHasRegisteredPro: (v) => set({ hasRegisteredPro: v }), + proAhaTriggeredBy: null, + setProAhaTriggeredBy: (by) => set({ proAhaTriggeredBy: by }), loadedSettings: null, setLoadedSettings: (settings) => set({ loadedSettings: settings }), }), @@ -297,6 +306,8 @@ export const useAppStore = create()( shownSpotlights: state.shownSpotlights, textGenerationCount: state.textGenerationCount, imageGenerationCount: state.imageGenerationCount, hasEngagedSharePrompt: state.hasEngagedSharePrompt, + hasRegisteredPro: state.hasRegisteredPro, + proAhaTriggeredBy: state.proAhaTriggeredBy, loadedSettings: state.loadedSettings, }), } diff --git a/src/utils/proPrompt.ts b/src/utils/proPrompt.ts new file mode 100644 index 00000000..49ca5928 --- /dev/null +++ b/src/utils/proPrompt.ts @@ -0,0 +1,49 @@ +import { useAppStore } from '../stores/appStore'; + +export const PRO_URL = 'https://offgridmobileai.co?utm_source=app&utm_medium=cta&utm_campaign=pro_preorder'; + +// Fires at count 3, then every 10 starting at 15 (3, 15, 25, 35...) +// Share sheet fires at 2, 10, 20, 30... so these never collide +const PRO_AHA_THRESHOLD = 3; +const PRO_AHA_REPEAT_START = 15; +const PRO_AHA_REPEAT_INTERVAL = 10; + +export function shouldShowProAha(count: number): boolean { + if (count === PRO_AHA_THRESHOLD) return true; + if (count >= PRO_AHA_REPEAT_START && (count - PRO_AHA_REPEAT_START) % PRO_AHA_REPEAT_INTERVAL === 0) return true; + return false; +} + +type ProPromptVariant = 'text' | 'image'; +type ProPromptListener = (variant: ProPromptVariant) => void; + +const listeners = new Set(); + +export function subscribeProPrompt(listener: ProPromptListener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function emitProPrompt(variant: ProPromptVariant): void { + listeners.forEach(l => l(variant)); +} + +// Called by generationService after each completed text response +export function checkProPromptForText(delayMs: number): void { + const s = useAppStore.getState(); + if (s.hasRegisteredPro) return; + if (s.proAhaTriggeredBy !== null) return; + if (!shouldShowProAha(s.textGenerationCount)) return; + s.setProAhaTriggeredBy('text'); + setTimeout(() => emitProPrompt('text'), delayMs); +} + +// Called by imageGenerationService after each completed image generation +export function checkProPromptForImage(delayMs: number): void { + const s = useAppStore.getState(); + if (s.hasRegisteredPro) return; + if (s.proAhaTriggeredBy !== null) return; + if (!shouldShowProAha(s.imageGenerationCount)) return; + s.setProAhaTriggeredBy('image'); + setTimeout(() => emitProPrompt('image'), delayMs); +}