This commit is contained in:
2026-07-27 16:42:04 +02:00
parent 45a7704ea6
commit 018b751723
23 changed files with 2327 additions and 635 deletions

View File

@@ -5,6 +5,8 @@ type AnalyticsProperties = Record<string, unknown>;
type SafeAnalytics = {
capture: (event: string, properties?: AnalyticsProperties) => void;
identify: (userId: string, properties?: AnalyticsProperties) => void;
/** Setzt Person-Properties ohne die User-ID zu aendern (z. B. Experiment-Varianten). */
identifyProperties: (properties: AnalyticsProperties) => void;
screen: (name: string, properties?: AnalyticsProperties) => void;
reset: () => void;
};
@@ -46,6 +48,15 @@ const safeAnalytics: SafeAnalytics = {
console.warn('[Analytics] identify failed', error);
}
},
identifyProperties: (properties) => {
try {
// PostHog RN: register() haengt die Properties an alle folgenden Events an,
// damit laesst sich der komplette Funnel nach Variante segmentieren.
client?.register(properties as Record<string, string>);
} catch (error) {
console.warn('[Analytics] identifyProperties failed', error);
}
},
screen: (name, properties) => {
try {
client?.screen(name, properties as Record<string, string>);

41
services/devReset.ts Normal file
View File

@@ -0,0 +1,41 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as SecureStore from 'expo-secure-store';
import { AuthService } from './authService';
import { AppMetaDb } from './database';
/**
* Setzt die App auf den Zustand "frisch installiert" zurueck - fuer Tests.
*
* Warum das noetig ist: Ein Expo-Reload oder ein neuer QR-Scan loescht keinen
* persistenten State. Die Session liegt in SecureStore (auf iOS = Keychain,
* ueberlebt sogar das Loeschen der App), der Onboarding-Flag und der
* Install-Marker liegen in SQLite. Man landet deshalb immer wieder direkt im
* Dashboard, egal wie oft man neu laedt.
*
* Diese Funktion raeumt genau die vier Stellen ab, die das Onboarding-Gate in
* app/_layout.tsx auswerten:
* 1. Session -> SecureStore
* 2. onboarding_completed -> SQLite (AppMetaDb)
* 3. install_marker_v2 -> SQLite + SecureStore
* 4. Experiment-Zuweisung -> AsyncStorage
*/
export const resetToFreshInstall = async (): Promise<void> => {
// 1. Session (Access-/Refresh-Token)
await AuthService.logout().catch(() => undefined);
// 2. Onboarding-Flag: ohne den bleibt hasCompletedOnboarding true und das
// Gate laesst den Nutzer am Welcome-Screen vorbei.
AppMetaDb.set('onboarding_completed', '0');
// 3. Install-Marker in BEIDEN Speichern. Der Keychain-Eintrag ist der Grund,
// warum eine echte Neuinstallation frueher nicht als solche erkannt wurde.
// Key muss mit SECURE_INSTALL_MARKER in app/_layout.tsx uebereinstimmen.
AppMetaDb.set('install_marker_v2', '0');
await SecureStore.deleteItemAsync('greenlens_install_v1').catch(() => undefined);
await SecureStore.deleteItemAsync('greenlens_first_run_complete').catch(() => undefined);
// 4. A/B-Zuweisung, damit beim naechsten Start neu gewuerfelt wird.
await AsyncStorage.removeItem('greenlens_experiment_welcome_headline_v1').catch(() => undefined);
await AsyncStorage.removeItem('greenlens_show_tour').catch(() => undefined);
await AsyncStorage.removeItem('greenlens_preauth_onboarding_v1').catch(() => undefined);
};

86
services/experiments.ts Normal file
View File

@@ -0,0 +1,86 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Analytics } from './analytics';
/**
* Leichtgewichtiges A/B-Testing ohne PostHog-Provider.
*
* Warum lokal und nicht PostHog Feature Flags: der PostHog-Client laeuft hier
* bewusst ohne Provider (siehe services/analytics.ts). Flags waeren damit erst
* nach einem Netzwerk-Roundtrip verfuegbar — der Welcome-Screen rendert aber
* sofort. Eine lokal gewuerfelte, persistierte Zuweisung ist deterministisch,
* funktioniert offline und wird als Event + Person-Property an PostHog gemeldet,
* sodass sich jeder Funnel-Schritt bis zum Kauf nach Variante segmentieren laesst.
*/
const STORAGE_PREFIX = 'greenlens_experiment_';
export const EXPERIMENTS = {
/** Welcome-Headline: Symptom-Einstieg vs. Abgrenzung zu Gratis-Identifikations-Apps. */
welcomeHeadline: {
key: 'welcome_headline_v1',
variants: ['symptom', 'contrast'] as const,
},
} as const;
export type WelcomeHeadlineVariant = (typeof EXPERIMENTS.welcomeHeadline.variants)[number];
const memoryCache = new Map<string, string>();
const pick = <T extends readonly string[]>(variants: T): T[number] =>
variants[Math.floor(Math.random() * variants.length)];
/**
* Liefert die Variante fuer einen Nutzer. Beim ersten Aufruf wird gewuerfelt,
* persistiert und `experiment_assigned` an PostHog geschickt. Alle weiteren
* Aufrufe geben dieselbe Variante zurueck.
*/
export const getVariant = async <T extends readonly string[]>(
experimentKey: string,
variants: T,
): Promise<T[number]> => {
const cached = memoryCache.get(experimentKey);
if (cached && (variants as readonly string[]).includes(cached)) {
return cached as T[number];
}
try {
const stored = await AsyncStorage.getItem(STORAGE_PREFIX + experimentKey);
if (stored && (variants as readonly string[]).includes(stored)) {
memoryCache.set(experimentKey, stored);
return stored as T[number];
}
} catch {
// Storage nicht verfuegbar — wir wuerfeln neu, statt den Screen zu blockieren.
}
const variant = pick(variants);
memoryCache.set(experimentKey, variant);
try {
await AsyncStorage.setItem(STORAGE_PREFIX + experimentKey, variant);
} catch {
// Nicht persistierbar: die Variante gilt dann nur fuer diese Session.
}
// Event fuer die Zuweisung selbst ...
Analytics.capture('experiment_assigned', { experiment: experimentKey, variant });
// ... plus Person-Property, damit sich JEDER spaetere Funnel-Schritt
// (onboarding_started, paywall_opened, purchase_completed) in PostHog nach
// Variante aufteilen laesst, ohne die Property ueberall mitschicken zu muessen.
Analytics.identifyProperties({ [`experiment_${experimentKey}`]: variant });
return variant;
};
/** Nur fuer QA/Debug: erzwingt eine Variante. */
export const forceVariant = async (experimentKey: string, variant: string): Promise<void> => {
memoryCache.set(experimentKey, variant);
try {
await AsyncStorage.setItem(STORAGE_PREFIX + experimentKey, variant);
} catch {
// ignorieren
}
};
export const getWelcomeHeadlineVariant = (): Promise<WelcomeHeadlineVariant> =>
getVariant(EXPERIMENTS.welcomeHeadline.key, EXPERIMENTS.welcomeHeadline.variants);

View File

@@ -8,6 +8,10 @@ export type PreAuthAnswers = {
primaryGoal?: string;
experienceLevel?: string;
lightLevel?: string;
/** 'acute' | 'slow' | 'prevention' — steuert Dringlichkeit und Tonfall des Plans. */
situation?: string;
/** 'yellow' | 'brown_tips' | 'drooping' | 'spots' | 'pests' | 'other' */
symptom?: string;
};
export const PreAuthOnboardingService = {