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);
+}