App copy
This commit is contained in:
@@ -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
41
services/devReset.ts
Normal 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
86
services/experiments.ts
Normal 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);
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user