Files
opencode-mobile/app/demo.tsx
Den 1ea84f8236 chore(launch): reconcile README/store live-status + add demo-funnel analytics (#111)
Two scoped changes for the no-spend growth launch (Growth Launch Kit,
Notion page 3a1ac25eb49f81099cc9f3a4286c8ec4):

1. README.md and distribution/play-listing.md said Google Play was
   "coming soon" / internal-testing-only, while distribution/retention-analysis.md
   and the live play.google.com listing show it's actually public with 1K+
   installs. Fixed the contradiction, added Google Play as a third install
   channel, and added an accurate mention of the new offline demo mode
   ("Try a Demo" — reasoning, grep, diff, permission prompt, ~30s, no server)
   matching what app/demo.tsx + src/lib/demo-script.ts actually render.
   play-listing.md's stale pre-launch checklists are marked historical
   instead of rewritten, so #83's ASO copy/keyword work is untouched.

2. Added the demo funnel's key metric (demo-completion, per the launch
   kit) as four consent-gated PostHog events: demo_started,
   demo_step_advanced, demo_completed, demo_exited_to_connect. Pure
   property-derivation logic lives in src/lib/demo-analytics.ts (no
   RN/PostHog imports, unit-tested with node --test, same pattern as
   analytics-classify.ts) and is wired into app/demo.tsx's lifecycle.
   Updated docs/analytics.md's event table and the privacy policy's event
   list (distribution/privacy-policy.md + its two HTML mirrors) per the
   repo's "new event requires a policy update" convention.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:49:47 -07:00

146 lines
5.7 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react"
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, useColorScheme, Linking } from "react-native"
import { Stack, useRouter } from "expo-router"
import { Ionicons } from "@expo/vector-icons"
import { useTranslation } from "react-i18next"
import { MessageBubble, PermissionPrompt } from "../src/components/chat"
import { buildDemoScript, buildDemoCompletionMessage, buildDemoDenialMessage } from "../src/lib/demo-script"
import { SETUP_GUIDE_URL } from "../src/lib/links"
import { track, AnalyticsEvent } from "../src/lib/analytics"
import {
demoStepAdvancedProps,
demoCompletedOutcome,
demoExitedToConnectProps,
type DemoPermissionReply,
} from "../src/lib/demo-analytics"
// Fully offline, scripted walkthrough for installers with no self-hosted
// opencode server. ISOLATION: every message/part below is hardcoded local
// data built once with useMemo — this screen never calls useSessions,
// useConnections, useEvents, or any sdk.ts client method, so it cannot
// create a real session, touch the network, or corrupt real app state. The
// permission "reply" only flips local component state (below); it never
// calls sessionClient.permission.reply the way app/session/[id].tsx does.
export default function DemoScreen() {
const router = useRouter()
const colorScheme = useColorScheme()
const isDark = colorScheme === "dark"
const { t } = useTranslation()
// Built once per mount from pure, hardcoded data (src/lib/demo-script.ts).
const script = useMemo(() => buildDemoScript(), [])
const [reply, setReply] = useState<DemoPermissionReply | null>(null)
// Fires once per screen mount — this is a fully offline, consent-gated
// event (track() is a no-op without telemetry consent, same as every
// other analytics call site).
useEffect(() => {
track(AnalyticsEvent.DemoStarted)
}, [])
const handleReply = useCallback((r: DemoPermissionReply) => {
setReply(r)
track(AnalyticsEvent.DemoStepAdvanced, demoStepAdvancedProps(r))
track(AnalyticsEvent.DemoCompleted, { outcome: demoCompletedOutcome(r) })
}, [])
const handleConnectPress = useCallback(() => {
track(AnalyticsEvent.DemoExitedToConnect, demoExitedToConnectProps(reply !== null))
router.push("/connection/add")
}, [reply, router])
const completion = useMemo(() => {
if (!reply) return null
return reply === "reject" ? buildDemoDenialMessage() : buildDemoCompletionMessage()
}, [reply])
const [userMessage, assistantMessage] = script.messages
return (
<>
<Stack.Screen options={{ title: t("demo.title"), presentation: "card" }} />
<View style={[s.container, isDark && s.containerDark]} testID="demo-screen">
<View style={[s.banner, isDark && s.bannerDark]} testID="demo-banner">
<Ionicons name="play-circle-outline" size={16} color="#8b5cf6" />
<Text style={s.bannerText}>{t("demo.banner")}</Text>
</View>
<ScrollView contentContainerStyle={s.scrollContent} keyboardShouldPersistTaps="handled">
<MessageBubble message={userMessage} parts={script.parts[userMessage.id] || []} isDark={isDark} />
<MessageBubble message={assistantMessage} parts={script.parts[assistantMessage.id] || []} isDark={isDark} />
{!reply && <PermissionPrompt permission={script.permission} isDark={isDark} onReply={handleReply} />}
{completion && (
<MessageBubble message={completion.message} parts={completion.parts} isDark={isDark} />
)}
<View style={[s.ctaCard, isDark && s.ctaCardDark]} testID="demo-cta-card">
<Text style={[s.ctaTitle, isDark && s.textWhite]}>{t("demo.ctaTitle")}</Text>
<Text style={[s.ctaSubtitle, isDark && s.metaDark]}>{t("demo.ctaSubtitle")}</Text>
<TouchableOpacity
style={s.connectButton}
onPress={handleConnectPress}
testID="demo-connect-button"
>
<Text style={s.connectButtonText}>{t("demo.connectButton")}</Text>
</TouchableOpacity>
<TouchableOpacity
style={s.setupGuideLink}
onPress={() => Linking.openURL(SETUP_GUIDE_URL)}
testID="demo-setup-guide-link"
>
<Text style={s.setupGuideLinkText}>{t("demo.setupGuideLink")}</Text>
</TouchableOpacity>
</View>
</ScrollView>
</View>
</>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: "#ffffff" },
containerDark: { backgroundColor: "#0a0a0a" },
textWhite: { color: "#ffffff" },
metaDark: { color: "#888888" },
banner: {
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 16,
paddingVertical: 10,
backgroundColor: "#f5f3ff",
borderBottomWidth: 1,
borderBottomColor: "#e9d5ff",
},
bannerDark: { backgroundColor: "#1a1030", borderBottomColor: "#2a1a4a" },
bannerText: { fontSize: 13, fontWeight: "600", color: "#6d28d9", flex: 1 },
scrollContent: { padding: 16, paddingBottom: 40 },
ctaCard: {
marginTop: 8,
padding: 20,
borderRadius: 16,
backgroundColor: "#f5f5f5",
alignItems: "center",
},
ctaCardDark: { backgroundColor: "#1a1a1a" },
ctaTitle: { fontSize: 17, fontWeight: "700", color: "#0a0a0a", textAlign: "center" },
ctaSubtitle: { fontSize: 13, color: "#666666", marginTop: 6, textAlign: "center", lineHeight: 18 },
connectButton: {
marginTop: 16,
backgroundColor: "#0a0a0a",
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
width: "100%",
alignItems: "center",
},
connectButtonText: { color: "#ffffff", fontWeight: "600", fontSize: 15 },
setupGuideLink: { marginTop: 14 },
setupGuideLinkText: { fontSize: 14, fontWeight: "600", color: "#6366f1" },
})