From 719bc9b5114044d7a8ecca73f3ecb086d996c30d Mon Sep 17 00:00:00 2001 From: Dishit Date: Thu, 14 May 2026 11:57:51 +0530 Subject: [PATCH 01/10] feat(pro): add PRO pre-order flow, about screen, and debug state tooling Co-Authored-By: Dishit --- package-lock.json | 8 + package.json | 1 + src/components/MadeWithLove.tsx | 2 +- src/navigation/AppNavigator.tsx | 13 +- src/navigation/types.ts | 2 + src/screens/AboutScreen.tsx | 166 ++++++++++++++++++ src/screens/DebugStateScreen.tsx | 239 ++++++++++++++++++++++++++ src/screens/ProDetailScreen/index.tsx | 28 ++- src/screens/SettingsScreen.tsx | 35 +++- src/screens/index.ts | 3 +- src/stores/appStore.ts | 2 +- src/utils/proPrompt.ts | 24 ++- 12 files changed, 497 insertions(+), 26 deletions(-) create mode 100644 src/screens/AboutScreen.tsx create mode 100644 src/screens/DebugStateScreen.tsx diff --git a/package-lock.json b/package-lock.json index 8571ba17..5e884900 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,7 @@ "@types/node": "^25.3.5", "@types/react": "^19.2.0", "@types/react-test-renderer": "^19.1.0", + "babel-plugin-transform-inline-environment-variables": "^0.4.4", "eslint": "^8.19.0", "husky": "^9.1.7", "jest": "^29.6.3", @@ -5169,6 +5170,13 @@ "@babel/plugin-syntax-flow": "^7.12.1" } }, + "node_modules/babel-plugin-transform-inline-environment-variables": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-inline-environment-variables/-/babel-plugin-transform-inline-environment-variables-0.4.4.tgz", + "integrity": "sha512-bJILBtn5a11SmtR2j/3mBOjX4K3weC6cq+NNZ7hG22wCAqpc3qtj/iN7dSe9HDiS46lgp1nHsQgeYrea/RUe+g==", + "dev": true, + "license": "MIT" + }, "node_modules/babel-preset-current-node-syntax": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", diff --git a/package.json b/package.json index 5c1ffe0a..8c3fcf38 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "@types/node": "^25.3.5", "@types/react": "^19.2.0", "@types/react-test-renderer": "^19.1.0", + "babel-plugin-transform-inline-environment-variables": "^0.4.4", "eslint": "^8.19.0", "husky": "^9.1.7", "jest": "^29.6.3", diff --git a/src/components/MadeWithLove.tsx b/src/components/MadeWithLove.tsx index 0f57dd07..4ffa2241 100644 --- a/src/components/MadeWithLove.tsx +++ b/src/components/MadeWithLove.tsx @@ -2,7 +2,7 @@ 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'; +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 MadeWithLove: React.FC = () => ( Linking.openURL(WEDNESDAY_URL)} style={styles.container}> diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index d6028a19..ddaa184c 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -37,8 +37,9 @@ import { SecuritySettingsScreen, GalleryScreen, RemoteServersScreen, - DebugLogsScreen, ProDetailScreen, + AboutScreen, + DebugStateScreen, } from '../screens'; import { RootStackParamList, @@ -239,6 +240,16 @@ export const AppNavigator: React.FC = () => { component={ProDetailScreen} options={{ headerShown: false, animation: 'slide_from_bottom' }} /> + + { + 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/DebugStateScreen.tsx b/src/screens/DebugStateScreen.tsx new file mode 100644 index 00000000..d1986b1b --- /dev/null +++ b/src/screens/DebugStateScreen.tsx @@ -0,0 +1,239 @@ +import React from 'react'; +import { View, Text, ScrollView, TouchableOpacity } 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 { useAppStore } from '../stores/appStore'; + +const PRO_AHA_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; +const PRO_AHA_MAX_SHOWS = 5; + +export const DebugStateScreen: React.FC = () => { + const navigation = useNavigation(); + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + + const textGenerationCount = useAppStore(s => s.textGenerationCount); + const imageGenerationCount = useAppStore(s => s.imageGenerationCount); + const hasRegisteredPro = useAppStore(s => s.hasRegisteredPro); + const proAhaTriggeredBy = useAppStore(s => s.proAhaTriggeredBy); + const proAhaShowCount = useAppStore(s => s.proAhaShowCount); + const lastProAhaShownAt = useAppStore(s => s.lastProAhaShownAt); + const hasEngagedSharePrompt = useAppStore(s => s.hasEngagedSharePrompt); + + const setHasRegisteredPro = useAppStore(s => s.setHasRegisteredPro); + const setProAhaTriggeredBy = useAppStore(s => s.setProAhaTriggeredBy); + const setLastProAhaShownAt = useAppStore(s => s.setLastProAhaShownAt); + const incrementProAhaShowCount = useAppStore(s => s.incrementProAhaShowCount); + + const now = Date.now(); + const cooldownRemaining = lastProAhaShownAt !== null + ? Math.max(0, PRO_AHA_COOLDOWN_MS - (now - lastProAhaShownAt)) + : null; + const cooldownDays = cooldownRemaining !== null + ? (cooldownRemaining / (1000 * 60 * 60 * 24)).toFixed(1) + : null; + const lastShownDate = lastProAhaShownAt !== null + ? new Date(lastProAhaShownAt).toLocaleString() + : 'Never'; + + const handleResetAll = () => { + setHasRegisteredPro(false); + setProAhaTriggeredBy(null); + setLastProAhaShownAt(0); + // Reset show count via store directly + useAppStore.setState({ proAhaShowCount: 0 }); + }; + + const handleSimulateCooldownExpired = () => { + // Set lastProAhaShownAt to 8 days ago so cooldown appears expired + const eightDaysAgo = Date.now() - (8 * 24 * 60 * 60 * 1000); + setLastProAhaShownAt(eightDaysAgo); + setProAhaTriggeredBy(null); + }; + + const handleIncrementShowCount = () => { + incrementProAhaShowCount(); + }; + + return ( + + + navigation.goBack()} style={styles.backButton}> + + + Debug State + + + + + + {/* Generation Counts */} + Generation Counts + + + + + + + {/* PRO Aha State */} + PRO Aha Sheet + + + + = PRO_AHA_MAX_SHOWS} + /> + = PRO_AHA_MAX_SHOWS ? 'Yes - will never show' : 'No'} + colors={colors} + highlight={proAhaShowCount >= PRO_AHA_MAX_SHOWS} + /> + + + {/* Cooldown */} + Cooldown (7 days) + + + 0 ? 'Yes' : 'No'} + colors={colors} + highlight={cooldownRemaining !== null && cooldownRemaining > 0} + /> + 0 ? `${cooldownDays} days` : 'Expired / not started'} + colors={colors} + /> + + + {/* What will happen next */} + Next Generation Will... + + {getNextGenPrediction({ + hasRegisteredPro, + proAhaShowCount, + proAhaTriggeredBy, + lastProAhaShownAt, + textGenerationCount, + imageGenerationCount, + now, + })} + + + {/* Actions */} + Debug Actions + + + Reset all PRO state + + + Simulate cooldown expired (set last shown to 8 days ago) + + + Increment show count (+1) + + useAppStore.setState({ proAhaShowCount: PRO_AHA_MAX_SHOWS })}> + Max out show count (force permanent block) + + + + + + ); +}; + +function getNextGenPrediction(s: { + hasRegisteredPro: boolean; + proAhaShowCount: number; + proAhaTriggeredBy: string | null; + lastProAhaShownAt: number | null; + textGenerationCount: number; + imageGenerationCount: number; + now: number; +}): string { + if (s.hasRegisteredPro) return 'PRO sheet will NOT show (user registered). Share sheet unaffected.'; + if (s.proAhaShowCount >= PRO_AHA_MAX_SHOWS) return 'PRO sheet will NOT show (max 5 shows reached permanently). Share sheet unaffected.'; + const cooldownActive = s.lastProAhaShownAt !== null && s.now - s.lastProAhaShownAt < PRO_AHA_COOLDOWN_MS; + if (cooldownActive) return 'PRO sheet will NOT show (cooldown active). Share sheet may show at gen 10, 20, etc.'; + if (s.proAhaTriggeredBy !== null) return 'PRO sheet will NOT show (already fired this cycle, cooldown not yet expired). Share sheet unaffected.'; + if (s.textGenerationCount < 3) return `PRO sheet will NOT show yet (need ${3 - s.textGenerationCount} more text gens). Share sheet shows at gen 2.`; + return 'PRO sheet WILL show on next text or image generation (threshold met, cooldown clear, cycle open).'; +} + +const Row: React.FC<{ label: string; value: string; colors: ThemeColors; highlight?: boolean }> = ({ label, value, colors, highlight }) => ( + + {label} + {value} + +); + +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, + }, + backButton: { width: 36, padding: SPACING.xs }, + headerTitle: { ...TYPOGRAPHY.h2, color: colors.text }, + content: { padding: SPACING.lg, paddingBottom: SPACING.xxl }, + sectionTitle: { + ...TYPOGRAPHY.meta, + color: colors.textMuted, + marginTop: SPACING.lg, + marginBottom: SPACING.sm, + textTransform: 'uppercase' as const, + letterSpacing: 0.5, + }, + card: { + backgroundColor: colors.surface, + borderRadius: 8, + paddingHorizontal: SPACING.md, + ...shadows.small, + }, + prediction: { + ...TYPOGRAPHY.bodySmall, + color: colors.text, + padding: SPACING.md, + lineHeight: 20, + }, + actionGroup: { gap: SPACING.sm }, + actionButton: { + backgroundColor: colors.surface, + borderRadius: 8, + padding: SPACING.md, + ...shadows.small, + }, + actionText: { ...TYPOGRAPHY.bodySmall, color: colors.textMuted }, +}); diff --git a/src/screens/ProDetailScreen/index.tsx b/src/screens/ProDetailScreen/index.tsx index 039c10e2..55442567 100644 --- a/src/screens/ProDetailScreen/index.tsx +++ b/src/screens/ProDetailScreen/index.tsx @@ -16,6 +16,8 @@ import type { ThemeColors, ThemeShadows } from '../../theme'; import { SPACING, TYPOGRAPHY } from '../../constants'; import { useAppStore } from '../../stores/appStore'; import { MadeWithLove } from '../../components/MadeWithLove'; +import { submitProEmail } from '../../utils/proPrompt'; +import logger from '../../utils/logger'; const FEATURES = [ { icon: 'mic', title: 'Voice AI + Personas', desc: 'Talk to named AI assistants with personality and memory.' }, @@ -33,14 +35,24 @@ export const ProDetailScreen: React.FC = () => { const [email, setEmail] = useState(''); const [submitted, setSubmitted] = useState(false); + const [loading, setLoading] = useState(false); const isValidEmail = email.includes('@') && email.includes('.'); - const handleSubmit = () => { - if (!isValidEmail) return; - // Backend integration point — email collected, store locally for now - setHasRegisteredPro(true); - setSubmitted(true); + const handleSubmit = async () => { + if (!isValidEmail || loading) return; + setLoading(true); + logger.log('[ProDetail] Submitting email:', email); + try { + const result = await submitProEmail(email); + logger.log('[ProDetail] Submit success:', JSON.stringify(result)); + } catch (err) { + logger.error('[ProDetail] Submit failed:', err); + } finally { + setHasRegisteredPro(true); + setSubmitted(true); + setLoading(false); + } }; return ( @@ -105,11 +117,11 @@ export const ProDetailScreen: React.FC = () => { autoCorrect={false} /> - I'm in + {loading ? 'Registering...' : 'I am in 🔥'} )} diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 6baf3409..300dfecb 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -248,15 +248,18 @@ 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 */} @@ -288,6 +291,20 @@ export const SettingsScreen: React.FC = () => { Preview PRO Sheet + { + const s = useAppStore.getState(); + s.setHasRegisteredPro(false); + s.setProAhaTriggeredBy(null); + s.setLastProAhaShownAt(0); + useAppStore.setState({ proAhaShowCount: 0 }); + }}> + + Reset PRO State + + navigation.navigate('DebugState')}> + + View Debug State + diff --git a/src/screens/index.ts b/src/screens/index.ts index 51f6ef2b..59956d32 100644 --- a/src/screens/index.ts +++ b/src/screens/index.ts @@ -21,5 +21,6 @@ export { DeviceInfoScreen } from './DeviceInfoScreen'; export { StorageSettingsScreen } from './StorageSettingsScreen'; export { SecuritySettingsScreen } from './SecuritySettingsScreen'; export { RemoteServersScreen } from './RemoteServersScreen'; -export { DebugLogsScreen } from './DebugLogsScreen'; export { ProDetailScreen } from './ProDetailScreen'; +export { AboutScreen } from './AboutScreen'; +export { DebugStateScreen } from './DebugStateScreen'; diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index f11a40d0..b9a55421 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -103,7 +103,7 @@ interface AppState { hasRegisteredPro: boolean; setHasRegisteredPro: (v: boolean) => void; proAhaTriggeredBy: 'image' | 'text' | null; - setProAhaTriggeredBy: (by: 'image' | 'text') => void; + setProAhaTriggeredBy: (by: 'image' | 'text' | null) => void; proAhaShowCount: number; incrementProAhaShowCount: () => number; lastProAhaShownAt: number | null; diff --git a/src/utils/proPrompt.ts b/src/utils/proPrompt.ts index b70aa07b..dbd8580d 100644 --- a/src/utils/proPrompt.ts +++ b/src/utils/proPrompt.ts @@ -1,6 +1,15 @@ import { useAppStore } from '../stores/appStore'; export const PRO_URL = 'https://offgridmobileai.co'; +export const PRO_WAITLIST_URL = 'https://script.google.com/macros/s/AKfycbzlN88mxRESbXSe0varMVqenIfSrsq5DkNF53Wy8bEWQ84U4W_nlo1evFYQTlz0ojCC/exec'; + +export async function submitProEmail(email: string): Promise { + console.log('[proPrompt] GET to:', PRO_WAITLIST_URL, 'email:', email); + const res = await fetch(`${PRO_WAITLIST_URL}?email=${encodeURIComponent(email)}`); + const text = await res.text(); + console.log('[proPrompt] Response status:', res.status, 'body:', text); + return text; +} const PRO_AHA_THRESHOLD = 3; const PRO_AHA_MAX_SHOWS = 5; @@ -24,15 +33,20 @@ function canShowProAha(): boolean { const s = useAppStore.getState(); if (s.hasRegisteredPro) return false; if (s.proAhaShowCount >= PRO_AHA_MAX_SHOWS) return false; - if (s.lastProAhaShownAt !== null && Date.now() - s.lastProAhaShownAt < PRO_AHA_COOLDOWN_MS) return false; + const cooldownActive = s.lastProAhaShownAt !== null && Date.now() - s.lastProAhaShownAt < PRO_AHA_COOLDOWN_MS; + if (cooldownActive) return false; + // Cooldown has expired — reset the cycle so the sheet can show again + if (s.lastProAhaShownAt !== null && !cooldownActive && s.proAhaTriggeredBy !== null) { + s.setProAhaTriggeredBy(null); + } return true; } // Called by generationService after each completed text response export function checkProPromptForText(delayMs: number): void { const s = useAppStore.getState(); - if (s.proAhaTriggeredBy === 'image') return; - if (s.textGenerationCount !== PRO_AHA_THRESHOLD) return; + if (s.proAhaTriggeredBy !== null) return; // already fired this cycle + if (s.textGenerationCount < PRO_AHA_THRESHOLD) return; if (!canShowProAha()) return; s.setProAhaTriggeredBy('text'); setTimeout(() => emitProPrompt('text'), delayMs); @@ -41,8 +55,8 @@ export function checkProPromptForText(delayMs: number): void { // Called by imageGenerationService after each completed image generation export function checkProPromptForImage(delayMs: number): void { const s = useAppStore.getState(); - if (s.proAhaTriggeredBy === 'text') return; - if (s.imageGenerationCount !== PRO_AHA_THRESHOLD) return; + if (s.proAhaTriggeredBy !== null) return; // already fired this cycle + if (s.imageGenerationCount < PRO_AHA_THRESHOLD) return; if (!canShowProAha()) return; s.setProAhaTriggeredBy('image'); setTimeout(() => emitProPrompt('image'), delayMs); From 0fb88991f263a94b1c53ae76f488be82a2dd1c10 Mon Sep 17 00:00:00 2001 From: Dishit Date: Thu, 14 May 2026 17:35:18 +0530 Subject: [PATCH 02/10] feat(pro): simplify PRO flow to website redirect, fix aha trigger logic --- src/components/ProAhaSheet.tsx | 12 +- src/screens/ChatScreen/index.tsx | 6 +- src/screens/DebugStateScreen.tsx | 183 +++++++++----------------- src/screens/ProDetailScreen/index.tsx | 165 +++++------------------ src/screens/SettingsScreen.tsx | 26 +--- src/stores/appStore.ts | 10 -- src/utils/proPrompt.ts | 46 +++---- 7 files changed, 125 insertions(+), 323 deletions(-) diff --git a/src/components/ProAhaSheet.tsx b/src/components/ProAhaSheet.tsx index da8dce5c..ead43b04 100644 --- a/src/components/ProAhaSheet.tsx +++ b/src/components/ProAhaSheet.tsx @@ -1,11 +1,10 @@ -import React, { useEffect } from 'react'; +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 } from '../constants'; -import { useAppStore } from '../stores/appStore'; interface ProAhaSheetProps { visible: boolean; @@ -15,15 +14,6 @@ interface ProAhaSheetProps { export const ProAhaSheet: React.FC = ({ visible, onClose, onRegister }) => { const styles = useThemedStyles(createStyles); - const incrementProAhaShowCount = useAppStore(s => s.incrementProAhaShowCount); - const setLastProAhaShownAt = useAppStore(s => s.setLastProAhaShownAt); - - useEffect(() => { - if (visible) { - incrementProAhaShowCount(); - setLastProAhaShownAt(Date.now()); - } - }, [visible]); const handleCta = () => { onClose(); diff --git a/src/screens/ChatScreen/index.tsx b/src/screens/ChatScreen/index.tsx index 036ca60e..6d3635c2 100644 --- a/src/screens/ChatScreen/index.tsx +++ b/src/screens/ChatScreen/index.tsx @@ -38,8 +38,12 @@ export const ChatScreen: React.FC = () => { useEffect(() => subscribeSharePrompt(() => setSharePromptVisible(true)), []); const [proAhaVisible, setProAhaVisible] = useState(false); - // Session cap: max 1 PRO sheet per ChatScreen mount 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; diff --git a/src/screens/DebugStateScreen.tsx b/src/screens/DebugStateScreen.tsx index d1986b1b..56d673b2 100644 --- a/src/screens/DebugStateScreen.tsx +++ b/src/screens/DebugStateScreen.tsx @@ -7,9 +7,23 @@ import { useTheme, useThemedStyles } from '../theme'; import type { ThemeColors, ThemeShadows } from '../theme'; import { SPACING, TYPOGRAPHY } from '../constants'; import { useAppStore } from '../stores/appStore'; +import { shouldShowProAha } from '../utils/proPrompt'; -const PRO_AHA_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; -const PRO_AHA_MAX_SHOWS = 5; +const PRO_AHA_THRESHOLD = 3; +const PRO_AHA_REPEAT_START = 15; +const PRO_AHA_REPEAT_INTERVAL = 10; + +function nextFireCount(current: number): number { + if (current < PRO_AHA_THRESHOLD) return PRO_AHA_THRESHOLD; + if (current < PRO_AHA_REPEAT_START) return PRO_AHA_REPEAT_START; + const passed = current - PRO_AHA_REPEAT_START; + return PRO_AHA_REPEAT_START + (Math.floor(passed / PRO_AHA_REPEAT_INTERVAL) + 1) * PRO_AHA_REPEAT_INTERVAL; +} + +function nextShareFireCount(current: number): number { + if (current < 2) return 2; + return Math.ceil((current + 1) / 10) * 10; +} export const DebugStateScreen: React.FC = () => { const navigation = useNavigation(); @@ -20,43 +34,19 @@ export const DebugStateScreen: React.FC = () => { const imageGenerationCount = useAppStore(s => s.imageGenerationCount); const hasRegisteredPro = useAppStore(s => s.hasRegisteredPro); const proAhaTriggeredBy = useAppStore(s => s.proAhaTriggeredBy); - const proAhaShowCount = useAppStore(s => s.proAhaShowCount); - const lastProAhaShownAt = useAppStore(s => s.lastProAhaShownAt); const hasEngagedSharePrompt = useAppStore(s => s.hasEngagedSharePrompt); const setHasRegisteredPro = useAppStore(s => s.setHasRegisteredPro); const setProAhaTriggeredBy = useAppStore(s => s.setProAhaTriggeredBy); - const setLastProAhaShownAt = useAppStore(s => s.setLastProAhaShownAt); - const incrementProAhaShowCount = useAppStore(s => s.incrementProAhaShowCount); - - const now = Date.now(); - const cooldownRemaining = lastProAhaShownAt !== null - ? Math.max(0, PRO_AHA_COOLDOWN_MS - (now - lastProAhaShownAt)) - : null; - const cooldownDays = cooldownRemaining !== null - ? (cooldownRemaining / (1000 * 60 * 60 * 24)).toFixed(1) - : null; - const lastShownDate = lastProAhaShownAt !== null - ? new Date(lastProAhaShownAt).toLocaleString() - : 'Never'; - - const handleResetAll = () => { - setHasRegisteredPro(false); - setProAhaTriggeredBy(null); - setLastProAhaShownAt(0); - // Reset show count via store directly - useAppStore.setState({ proAhaShowCount: 0 }); - }; - const handleSimulateCooldownExpired = () => { - // Set lastProAhaShownAt to 8 days ago so cooldown appears expired - const eightDaysAgo = Date.now() - (8 * 24 * 60 * 60 * 1000); - setLastProAhaShownAt(eightDaysAgo); - setProAhaTriggeredBy(null); - }; - - const handleIncrementShowCount = () => { - incrementProAhaShowCount(); + const getPrediction = (): string => { + if (hasRegisteredPro) return 'PRO sheet will never show - user registered. Share sheet unaffected.'; + if (proAhaTriggeredBy !== null) return `PRO sheet blocked this session (triggered by ${proAhaTriggeredBy}). Reopen the chat to reset.`; + const nextText = textGenerationCount + 1; + const nextImage = imageGenerationCount + 1; + if (shouldShowProAha(nextText)) return `PRO sheet WILL show on next text generation (count ${nextText}).`; + if (shouldShowProAha(nextImage)) return `PRO sheet WILL show on next image generation (count ${nextImage}).`; + return `PRO sheet will not show yet. Next text fire at count ${nextFireCount(textGenerationCount)}.`; }; return ( @@ -71,93 +61,64 @@ export const DebugStateScreen: React.FC = () => { - {/* Generation Counts */} Generation Counts - + - {/* PRO Aha State */} - PRO Aha Sheet + Share Sheet - - - = PRO_AHA_MAX_SHOWS} - /> - = PRO_AHA_MAX_SHOWS ? 'Yes - will never show' : 'No'} - colors={colors} - highlight={proAhaShowCount >= PRO_AHA_MAX_SHOWS} - /> + + - {/* Cooldown */} - Cooldown (7 days) + PRO Sheet - - 0 ? 'Yes' : 'No'} - colors={colors} - highlight={cooldownRemaining !== null && cooldownRemaining > 0} - /> - 0 ? `${cooldownDays} days` : 'Expired / not started'} - colors={colors} - /> + + + + + + + - {/* What will happen next */} Next Generation Will... - {getNextGenPrediction({ - hasRegisteredPro, - proAhaShowCount, - proAhaTriggeredBy, - lastProAhaShownAt, - textGenerationCount, - imageGenerationCount, - now, - })} + {getPrediction()} - {/* Actions */} Debug Actions - - Reset all PRO state + { + setHasRegisteredPro(false); + setProAhaTriggeredBy(null); + }}> + Reset PRO state (keep counts) + + { + setHasRegisteredPro(false); + setProAhaTriggeredBy(null); + useAppStore.setState({ textGenerationCount: 0, imageGenerationCount: 0 }); + }}> + Reset everything (PRO + counts) - - Simulate cooldown expired (set last shown to 8 days ago) + { + useAppStore.setState({ textGenerationCount: PRO_AHA_THRESHOLD - 1 }); + }}> + Set text count to 2 (PRO fires on next text gen) - - Increment show count (+1) + { + useAppStore.setState({ imageGenerationCount: PRO_AHA_THRESHOLD - 1 }); + }}> + Set image count to 2 (PRO fires on next image gen) - useAppStore.setState({ proAhaShowCount: PRO_AHA_MAX_SHOWS })}> - Max out show count (force permanent block) + { + useAppStore.setState({ textGenerationCount: 1 }); + }}> + Set text count to 1 (share fires on next text gen) @@ -166,28 +127,10 @@ export const DebugStateScreen: React.FC = () => { ); }; -function getNextGenPrediction(s: { - hasRegisteredPro: boolean; - proAhaShowCount: number; - proAhaTriggeredBy: string | null; - lastProAhaShownAt: number | null; - textGenerationCount: number; - imageGenerationCount: number; - now: number; -}): string { - if (s.hasRegisteredPro) return 'PRO sheet will NOT show (user registered). Share sheet unaffected.'; - if (s.proAhaShowCount >= PRO_AHA_MAX_SHOWS) return 'PRO sheet will NOT show (max 5 shows reached permanently). Share sheet unaffected.'; - const cooldownActive = s.lastProAhaShownAt !== null && s.now - s.lastProAhaShownAt < PRO_AHA_COOLDOWN_MS; - if (cooldownActive) return 'PRO sheet will NOT show (cooldown active). Share sheet may show at gen 10, 20, etc.'; - if (s.proAhaTriggeredBy !== null) return 'PRO sheet will NOT show (already fired this cycle, cooldown not yet expired). Share sheet unaffected.'; - if (s.textGenerationCount < 3) return `PRO sheet will NOT show yet (need ${3 - s.textGenerationCount} more text gens). Share sheet shows at gen 2.`; - return 'PRO sheet WILL show on next text or image generation (threshold met, cooldown clear, cycle open).'; -} - const Row: React.FC<{ label: string; value: string; colors: ThemeColors; highlight?: boolean }> = ({ label, value, colors, highlight }) => ( - {label} - {value} + {label} + {value} ); diff --git a/src/screens/ProDetailScreen/index.tsx b/src/screens/ProDetailScreen/index.tsx index 55442567..4d6cc7bc 100644 --- a/src/screens/ProDetailScreen/index.tsx +++ b/src/screens/ProDetailScreen/index.tsx @@ -1,23 +1,13 @@ -import React, { useState } from 'react'; -import { - View, - Text, - TextInput, - TouchableOpacity, - ScrollView, - KeyboardAvoidingView, - Platform, -} from 'react-native'; +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 { useAppStore } from '../../stores/appStore'; import { MadeWithLove } from '../../components/MadeWithLove'; -import { submitProEmail } from '../../utils/proPrompt'; -import logger from '../../utils/logger'; +import { PRO_URL } from '../../utils/proPrompt'; const FEATURES = [ { icon: 'mic', title: 'Voice AI + Personas', desc: 'Talk to named AI assistants with personality and memory.' }, @@ -31,104 +21,44 @@ export const ProDetailScreen: React.FC = () => { const navigation = useNavigation(); const { colors } = useTheme(); const styles = useThemedStyles(createStyles); - const setHasRegisteredPro = useAppStore(s => s.setHasRegisteredPro); - - const [email, setEmail] = useState(''); - const [submitted, setSubmitted] = useState(false); - const [loading, setLoading] = useState(false); - - const isValidEmail = email.includes('@') && email.includes('.'); - - const handleSubmit = async () => { - if (!isValidEmail || loading) return; - setLoading(true); - logger.log('[ProDetail] Submitting email:', email); - try { - const result = await submitProEmail(email); - logger.log('[ProDetail] Submit success:', JSON.stringify(result)); - } catch (err) { - logger.error('[ProDetail] Submit failed:', err); - } finally { - setHasRegisteredPro(true); - setSubmitted(true); - setLoading(false); - } - }; return ( - - {/* Header */} - - navigation.goBack()} style={styles.backButton}> - - - + + navigation.goBack()} style={styles.backButton}> + + + - - {/* Title */} - Off Grid PRO - Lifetime access - Coming soon + + Off Grid PRO + Lifetime access - Coming soon - {/* Features */} - - {FEATURES.map(f => ( - - - - - - {f.title} - {f.desc} - + + {FEATURES.map(f => ( + + + + + + {f.title} + {f.desc} - ))} - + + ))} + - {/* Pitch */} - - 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. - + + 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. + - {/* Email input or confirmation */} - {submitted ? ( - - - You're in. We'll be in touch. - - ) : ( - - - - {loading ? 'Registering...' : 'I am in 🔥'} - - - )} + Linking.openURL(PRO_URL)}> + I am in 🔥 + - - - + + ); }; @@ -171,9 +101,7 @@ const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ alignItems: 'center' as const, paddingTop: 2, }, - featureText: { - flex: 1, - }, + featureText: { flex: 1 }, featureTitle: { ...TYPOGRAPHY.body, color: colors.text, @@ -189,40 +117,15 @@ const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ lineHeight: 20, marginBottom: SPACING.xl, }, - inputSection: { - gap: SPACING.sm, - }, - input: { - ...TYPOGRAPHY.body, - color: colors.text, - borderWidth: 1, - borderColor: colors.border, - borderRadius: 8, - paddingHorizontal: SPACING.md, - paddingVertical: SPACING.md, - backgroundColor: colors.surface, - }, ctaButton: { paddingVertical: SPACING.md, backgroundColor: colors.primary, borderRadius: 8, alignItems: 'center' as const, - }, - ctaButtonDisabled: { - opacity: 0.4, + marginBottom: SPACING.xl, }, ctaText: { ...TYPOGRAPHY.body, color: colors.background, }, - successRow: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - gap: SPACING.sm, - paddingVertical: SPACING.md, - }, - successText: { - ...TYPOGRAPHY.body, - color: colors.primary, - }, }); diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 300dfecb..2bde8b65 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -42,7 +42,6 @@ export const SettingsScreen: React.FC = () => { const { colors } = useTheme(); const styles = useThemedStyles(createStyles); const setOnboardingComplete = useAppStore((s) => s.setOnboardingComplete); - const hasRegisteredPro = useAppStore((s) => s.hasRegisteredPro); const [proAhaVisible, setProAhaVisible] = useState(false); const themeMode = useAppStore((s) => s.themeMode); const setThemeMode = useAppStore((s) => s.setThemeMode); @@ -182,30 +181,19 @@ export const SettingsScreen: React.FC = () => { !hasRegisteredPro && navigation.navigate('ProDetail')} - activeOpacity={hasRegisteredPro ? 1 : 0.75} + onPress={() => navigation.navigate('ProDetail')} + activeOpacity={0.75} > - {hasRegisteredPro ? ( - <> - You're in. Welcome. - Off Grid PRO supporter - - ) : ( - <> - Off Grid PRO - Voice. MCPs. Calendar. WhatsApp. - I am in 🔥 - - )} + Off Grid PRO + Voice. MCPs. Calendar. WhatsApp. + I am in 🔥 - {!hasRegisteredPro && ( - - )} + @@ -295,8 +283,6 @@ export const SettingsScreen: React.FC = () => { const s = useAppStore.getState(); s.setHasRegisteredPro(false); s.setProAhaTriggeredBy(null); - s.setLastProAhaShownAt(0); - useAppStore.setState({ proAhaShowCount: 0 }); }}> Reset PRO State diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index b9a55421..c0444b4d 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -104,10 +104,6 @@ interface AppState { setHasRegisteredPro: (v: boolean) => void; proAhaTriggeredBy: 'image' | 'text' | null; setProAhaTriggeredBy: (by: 'image' | 'text' | null) => void; - proAhaShowCount: number; - incrementProAhaShowCount: () => number; - lastProAhaShownAt: number | null; - setLastProAhaShownAt: (ts: number) => void; loadedSettings: Partial | null; setLoadedSettings: (settings: Partial | null) => void; } @@ -291,10 +287,6 @@ export const useAppStore = create()( setHasRegisteredPro: (v) => set({ hasRegisteredPro: v }), proAhaTriggeredBy: null, setProAhaTriggeredBy: (by) => set({ proAhaTriggeredBy: by }), - proAhaShowCount: 0, - incrementProAhaShowCount: () => { const c = get().proAhaShowCount + 1; set({ proAhaShowCount: c }); return c; }, - lastProAhaShownAt: null, - setLastProAhaShownAt: (ts) => set({ lastProAhaShownAt: ts }), loadedSettings: null, setLoadedSettings: (settings) => set({ loadedSettings: settings }), }), @@ -316,8 +308,6 @@ export const useAppStore = create()( hasEngagedSharePrompt: state.hasEngagedSharePrompt, hasRegisteredPro: state.hasRegisteredPro, proAhaTriggeredBy: state.proAhaTriggeredBy, - proAhaShowCount: state.proAhaShowCount, - lastProAhaShownAt: state.lastProAhaShownAt, loadedSettings: state.loadedSettings, }), } diff --git a/src/utils/proPrompt.ts b/src/utils/proPrompt.ts index dbd8580d..aa870a15 100644 --- a/src/utils/proPrompt.ts +++ b/src/utils/proPrompt.ts @@ -1,19 +1,18 @@ import { useAppStore } from '../stores/appStore'; export const PRO_URL = 'https://offgridmobileai.co'; -export const PRO_WAITLIST_URL = 'https://script.google.com/macros/s/AKfycbzlN88mxRESbXSe0varMVqenIfSrsq5DkNF53Wy8bEWQ84U4W_nlo1evFYQTlz0ojCC/exec'; - -export async function submitProEmail(email: string): Promise { - console.log('[proPrompt] GET to:', PRO_WAITLIST_URL, 'email:', email); - const res = await fetch(`${PRO_WAITLIST_URL}?email=${encodeURIComponent(email)}`); - const text = await res.text(); - console.log('[proPrompt] Response status:', res.status, 'body:', text); - return text; -} +// 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_MAX_SHOWS = 5; -const PRO_AHA_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +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; @@ -29,25 +28,12 @@ export function emitProPrompt(variant: ProPromptVariant): void { listeners.forEach(l => l(variant)); } -function canShowProAha(): boolean { - const s = useAppStore.getState(); - if (s.hasRegisteredPro) return false; - if (s.proAhaShowCount >= PRO_AHA_MAX_SHOWS) return false; - const cooldownActive = s.lastProAhaShownAt !== null && Date.now() - s.lastProAhaShownAt < PRO_AHA_COOLDOWN_MS; - if (cooldownActive) return false; - // Cooldown has expired — reset the cycle so the sheet can show again - if (s.lastProAhaShownAt !== null && !cooldownActive && s.proAhaTriggeredBy !== null) { - s.setProAhaTriggeredBy(null); - } - return true; -} - // Called by generationService after each completed text response export function checkProPromptForText(delayMs: number): void { const s = useAppStore.getState(); - if (s.proAhaTriggeredBy !== null) return; // already fired this cycle - if (s.textGenerationCount < PRO_AHA_THRESHOLD) return; - if (!canShowProAha()) return; + if (s.hasRegisteredPro) return; + if (s.proAhaTriggeredBy !== null) return; + if (!shouldShowProAha(s.textGenerationCount)) return; s.setProAhaTriggeredBy('text'); setTimeout(() => emitProPrompt('text'), delayMs); } @@ -55,9 +41,9 @@ export function checkProPromptForText(delayMs: number): void { // Called by imageGenerationService after each completed image generation export function checkProPromptForImage(delayMs: number): void { const s = useAppStore.getState(); - if (s.proAhaTriggeredBy !== null) return; // already fired this cycle - if (s.imageGenerationCount < PRO_AHA_THRESHOLD) return; - if (!canShowProAha()) return; + if (s.hasRegisteredPro) return; + if (s.proAhaTriggeredBy !== null) return; + if (!shouldShowProAha(s.imageGenerationCount)) return; s.setProAhaTriggeredBy('image'); setTimeout(() => emitProPrompt('image'), delayMs); } From 4cba2069cda659a9a92fba7a8fbed2f6486f2e4c Mon Sep 17 00:00:00 2001 From: Dishit Date: Thu, 14 May 2026 17:42:16 +0530 Subject: [PATCH 03/10] remove debug buttons and 10 dollar pro --- src/components/ProAhaSheet.tsx | 4 ---- src/screens/SettingsScreen.tsx | 26 ++------------------------ 2 files changed, 2 insertions(+), 28 deletions(-) diff --git a/src/components/ProAhaSheet.tsx b/src/components/ProAhaSheet.tsx index ead43b04..d0224061 100644 --- a/src/components/ProAhaSheet.tsx +++ b/src/components/ProAhaSheet.tsx @@ -28,10 +28,6 @@ export const ProAhaSheet: React.FC = ({ visible, onClose, onRe Help us build what's next - and get it free for life. - - $10 lifetime access - - {[ 'Voice-native conversation', diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 2bde8b65..01ddac12 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { View, Text, @@ -13,7 +13,7 @@ import { AttachStep } from 'react-native-spotlight-tour'; import { useNavigation, CommonActions, CompositeNavigationProp } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs'; -import { Card, ProAhaSheet } from '../components'; +import { Card } from '../components'; import { AnimatedEntry } from '../components/AnimatedEntry'; import { AnimatedListItem } from '../components/AnimatedListItem'; import { MadeWithLove } from '../components/MadeWithLove'; @@ -42,7 +42,6 @@ export const SettingsScreen: React.FC = () => { const { colors } = useTheme(); const styles = useThemedStyles(createStyles); const setOnboardingComplete = useAppStore((s) => s.setOnboardingComplete); - const [proAhaVisible, setProAhaVisible] = useState(false); const themeMode = useAppStore((s) => s.themeMode); const setThemeMode = useAppStore((s) => s.setThemeMode); const completeChecklistStep = useAppStore((s) => s.completeChecklistStep); @@ -275,31 +274,10 @@ export const SettingsScreen: React.FC = () => { Reset Onboarding Checklist - setProAhaVisible(true)}> - - Preview PRO Sheet - - { - const s = useAppStore.getState(); - s.setHasRegisteredPro(false); - s.setProAhaTriggeredBy(null); - }}> - - Reset PRO State - - navigation.navigate('DebugState')}> - - View Debug State - - setProAhaVisible(false)} - onRegister={() => { setProAhaVisible(false); navigation.navigate('ProDetail'); }} - /> ); }; From 03e5e053d50f450f491d149d2e6733220fc8372f Mon Sep 17 00:00:00 2001 From: Dishit Date: Thu, 14 May 2026 18:07:25 +0530 Subject: [PATCH 04/10] fix lint and tests --- .../onboarding/ChatScreenSpotlight.test.tsx | 1 + .../rntl/screens/SettingsScreen.test.tsx | 6 +- src/navigation/AppNavigator.tsx | 6 - src/navigation/types.ts | 1 - src/screens/DebugStateScreen.tsx | 182 ------------------ src/screens/index.ts | 1 - 6 files changed, 4 insertions(+), 193 deletions(-) delete mode 100644 src/screens/DebugStateScreen.tsx 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/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/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index ddaa184c..fbbc4322 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -39,7 +39,6 @@ import { RemoteServersScreen, ProDetailScreen, AboutScreen, - DebugStateScreen, } from '../screens'; import { RootStackParamList, @@ -245,11 +244,6 @@ export const AppNavigator: React.FC = () => { component={AboutScreen} options={{ headerShown: false }} /> - { - const navigation = useNavigation(); - const { colors } = useTheme(); - const styles = useThemedStyles(createStyles); - - const textGenerationCount = useAppStore(s => s.textGenerationCount); - const imageGenerationCount = useAppStore(s => s.imageGenerationCount); - const hasRegisteredPro = useAppStore(s => s.hasRegisteredPro); - const proAhaTriggeredBy = useAppStore(s => s.proAhaTriggeredBy); - const hasEngagedSharePrompt = useAppStore(s => s.hasEngagedSharePrompt); - - const setHasRegisteredPro = useAppStore(s => s.setHasRegisteredPro); - const setProAhaTriggeredBy = useAppStore(s => s.setProAhaTriggeredBy); - - const getPrediction = (): string => { - if (hasRegisteredPro) return 'PRO sheet will never show - user registered. Share sheet unaffected.'; - if (proAhaTriggeredBy !== null) return `PRO sheet blocked this session (triggered by ${proAhaTriggeredBy}). Reopen the chat to reset.`; - const nextText = textGenerationCount + 1; - const nextImage = imageGenerationCount + 1; - if (shouldShowProAha(nextText)) return `PRO sheet WILL show on next text generation (count ${nextText}).`; - if (shouldShowProAha(nextImage)) return `PRO sheet WILL show on next image generation (count ${nextImage}).`; - return `PRO sheet will not show yet. Next text fire at count ${nextFireCount(textGenerationCount)}.`; - }; - - return ( - - - navigation.goBack()} style={styles.backButton}> - - - Debug State - - - - - - Generation Counts - - - - - - - Share Sheet - - - - - - PRO Sheet - - - - - - - - - - - Next Generation Will... - - {getPrediction()} - - - Debug Actions - - { - setHasRegisteredPro(false); - setProAhaTriggeredBy(null); - }}> - Reset PRO state (keep counts) - - { - setHasRegisteredPro(false); - setProAhaTriggeredBy(null); - useAppStore.setState({ textGenerationCount: 0, imageGenerationCount: 0 }); - }}> - Reset everything (PRO + counts) - - { - useAppStore.setState({ textGenerationCount: PRO_AHA_THRESHOLD - 1 }); - }}> - Set text count to 2 (PRO fires on next text gen) - - { - useAppStore.setState({ imageGenerationCount: PRO_AHA_THRESHOLD - 1 }); - }}> - Set image count to 2 (PRO fires on next image gen) - - { - useAppStore.setState({ textGenerationCount: 1 }); - }}> - Set text count to 1 (share fires on next text gen) - - - - - - ); -}; - -const Row: React.FC<{ label: string; value: string; colors: ThemeColors; highlight?: boolean }> = ({ label, value, colors, highlight }) => ( - - {label} - {value} - -); - -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, - }, - backButton: { width: 36, padding: SPACING.xs }, - headerTitle: { ...TYPOGRAPHY.h2, color: colors.text }, - content: { padding: SPACING.lg, paddingBottom: SPACING.xxl }, - sectionTitle: { - ...TYPOGRAPHY.meta, - color: colors.textMuted, - marginTop: SPACING.lg, - marginBottom: SPACING.sm, - textTransform: 'uppercase' as const, - letterSpacing: 0.5, - }, - card: { - backgroundColor: colors.surface, - borderRadius: 8, - paddingHorizontal: SPACING.md, - ...shadows.small, - }, - prediction: { - ...TYPOGRAPHY.bodySmall, - color: colors.text, - padding: SPACING.md, - lineHeight: 20, - }, - actionGroup: { gap: SPACING.sm }, - actionButton: { - backgroundColor: colors.surface, - borderRadius: 8, - padding: SPACING.md, - ...shadows.small, - }, - actionText: { ...TYPOGRAPHY.bodySmall, color: colors.textMuted }, -}); diff --git a/src/screens/index.ts b/src/screens/index.ts index 59956d32..1ad2bb0d 100644 --- a/src/screens/index.ts +++ b/src/screens/index.ts @@ -23,4 +23,3 @@ export { SecuritySettingsScreen } from './SecuritySettingsScreen'; export { RemoteServersScreen } from './RemoteServersScreen'; export { ProDetailScreen } from './ProDetailScreen'; export { AboutScreen } from './AboutScreen'; -export { DebugStateScreen } from './DebugStateScreen'; From 57b8def179c4761bd62243005cecf58f6e6f4adc Mon Sep 17 00:00:00 2001 From: Dishit Date: Thu, 14 May 2026 18:27:13 +0530 Subject: [PATCH 05/10] test --- __tests__/rntl/screens/ChatScreen.test.tsx | 1 + 1 file changed, 1 insertion(+) 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', () => ({ From db22a63d884cbb69614e30665ccea276e3411095 Mon Sep 17 00:00:00 2001 From: Dishit Date: Thu, 14 May 2026 18:38:55 +0530 Subject: [PATCH 06/10] chore: remove unused babel-plugin-transform-inline-environment-variables --- package-lock.json | 8 -------- package.json | 1 - 2 files changed, 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5e884900..8571ba17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,7 +64,6 @@ "@types/node": "^25.3.5", "@types/react": "^19.2.0", "@types/react-test-renderer": "^19.1.0", - "babel-plugin-transform-inline-environment-variables": "^0.4.4", "eslint": "^8.19.0", "husky": "^9.1.7", "jest": "^29.6.3", @@ -5170,13 +5169,6 @@ "@babel/plugin-syntax-flow": "^7.12.1" } }, - "node_modules/babel-plugin-transform-inline-environment-variables": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-inline-environment-variables/-/babel-plugin-transform-inline-environment-variables-0.4.4.tgz", - "integrity": "sha512-bJILBtn5a11SmtR2j/3mBOjX4K3weC6cq+NNZ7hG22wCAqpc3qtj/iN7dSe9HDiS46lgp1nHsQgeYrea/RUe+g==", - "dev": true, - "license": "MIT" - }, "node_modules/babel-preset-current-node-syntax": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", diff --git a/package.json b/package.json index 8c3fcf38..5c1ffe0a 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,6 @@ "@types/node": "^25.3.5", "@types/react": "^19.2.0", "@types/react-test-renderer": "^19.1.0", - "babel-plugin-transform-inline-environment-variables": "^0.4.4", "eslint": "^8.19.0", "husky": "^9.1.7", "jest": "^29.6.3", From 64a195ec9a80912e7d391bdc4b7a1623f6333c38 Mon Sep 17 00:00:00 2001 From: Dishit Date: Thu, 14 May 2026 18:50:18 +0530 Subject: [PATCH 07/10] fix: decouple pro prompt from share prompt gate, fix duplicate animation index --- src/screens/SettingsScreen.tsx | 6 +++--- src/services/generationService.ts | 4 ++-- src/services/imageGenerationService.ts | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 01ddac12..74376016 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -234,7 +234,7 @@ export const SettingsScreen: React.FC = () => { {/* About */} - + navigation.navigate('About')}> @@ -250,7 +250,7 @@ export const SettingsScreen: React.FC = () => { {/* Privacy */} - + @@ -264,7 +264,7 @@ export const SettingsScreen: React.FC = () => { {/* Reset Onboarding */} - + diff --git a/src/services/generationService.ts b/src/services/generationService.ts index f2f9006a..ef5e9d55 100644 --- a/src/services/generationService.ts +++ b/src/services/generationService.ts @@ -127,8 +127,8 @@ 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); } diff --git a/src/services/imageGenerationService.ts b/src/services/imageGenerationService.ts index 02b55c1c..d8212932 100644 --- a/src/services/imageGenerationService.ts +++ b/src/services/imageGenerationService.ts @@ -100,9 +100,9 @@ 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); } From 8b725e7b955391b24ce3009373b13078c2a6f045 Mon Sep 17 00:00:00 2001 From: Dishit Date: Thu, 14 May 2026 19:08:06 +0530 Subject: [PATCH 08/10] feat(pro): set hasRegisteredPro on CTA tap, add UTM params to PRO URL --- src/screens/ProDetailScreen/index.tsx | 9 ++++++++- src/utils/proPrompt.ts | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/screens/ProDetailScreen/index.tsx b/src/screens/ProDetailScreen/index.tsx index 4d6cc7bc..7840cd9a 100644 --- a/src/screens/ProDetailScreen/index.tsx +++ b/src/screens/ProDetailScreen/index.tsx @@ -8,6 +8,7 @@ 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.' }, @@ -21,6 +22,12 @@ 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 ( @@ -53,7 +60,7 @@ export const ProDetailScreen: React.FC = () => { Register now - we'll send your purchase link when it's live. - Linking.openURL(PRO_URL)}> + I am in 🔥 diff --git a/src/utils/proPrompt.ts b/src/utils/proPrompt.ts index aa870a15..49ca5928 100644 --- a/src/utils/proPrompt.ts +++ b/src/utils/proPrompt.ts @@ -1,6 +1,6 @@ import { useAppStore } from '../stores/appStore'; -export const PRO_URL = 'https://offgridmobileai.co'; +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 From 200b395c5466a551dbc14ec3a1f028609a0b2c8e Mon Sep 17 00:00:00 2001 From: Dishit Date: Thu, 14 May 2026 19:24:10 +0530 Subject: [PATCH 09/10] fix: revert stray imageUseOpenCL default back to true --- src/stores/appStore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index c0444b4d..1894a029 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -130,7 +130,7 @@ const DEFAULT_SETTINGS: AppSettings = { imageThreads: 4, imageWidth: 512, imageHeight: 512, - imageUseOpenCL: false, + imageUseOpenCL: true, enhanceImagePrompts: false, modelLoadingStrategy: 'performance' as ModelLoadingStrategy, enableGpu: Platform.OS === 'ios', From 9a65396b191702f5faff66b949279855249e564d Mon Sep 17 00:00:00 2001 From: Dishit Date: Fri, 15 May 2026 13:40:20 +0530 Subject: [PATCH 10/10] fix: address review comments - move URL and features to constants, fix duplicate import --- src/components/MadeWithLove.tsx | 4 +--- src/components/ProAhaSheet.tsx | 9 ++------- src/constants/index.ts | 10 ++++++++++ 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/components/MadeWithLove.tsx b/src/components/MadeWithLove.tsx index 4ffa2241..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://mobile.wednesday.is/hire-ai-native-mobile-squad?utm_source=off-grid-mobile-app&utm_medium=made-with-love&utm_campaign=in-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 index d0224061..4be217a3 100644 --- a/src/components/ProAhaSheet.tsx +++ b/src/components/ProAhaSheet.tsx @@ -4,7 +4,7 @@ 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 } from '../constants'; +import { SPACING, TYPOGRAPHY, PRO_AHA_FEATURES } from '../constants'; interface ProAhaSheetProps { visible: boolean; @@ -29,12 +29,7 @@ export const ProAhaSheet: React.FC = ({ visible, onClose, onRe - {[ - 'Voice-native conversation', - 'Custom MCP servers', - 'Calendar and WhatsApp integration', - 'More, shipping monthly', - ].map(feature => ( + {PRO_AHA_FEATURES.map(feature => ( {feature} 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',