Hard paywall
This commit is contained in:
@@ -68,12 +68,17 @@ describe('mockBackendService billing simulation', () => {
|
|||||||
productId: 'topup_small',
|
productId: 'topup_small',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(first.billing.credits.topupBalance).toBe(25);
|
expect(first.billing.credits.topupBalance).toBe(30);
|
||||||
expect(second.billing.credits.topupBalance).toBe(25);
|
expect(second.billing.credits.topupBalance).toBe(30);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('consumes plan credits before topup credits', async () => {
|
it('consumes plan credits before topup credits', async () => {
|
||||||
const userId = 'test-user-credit-order';
|
const userId = 'test-user-credit-order';
|
||||||
|
await mockBackendService.simulatePurchase({
|
||||||
|
userId,
|
||||||
|
idempotencyKey: 'sub-order-1',
|
||||||
|
productId: 'monthly_pro',
|
||||||
|
});
|
||||||
await mockBackendService.simulatePurchase({
|
await mockBackendService.simulatePurchase({
|
||||||
userId,
|
userId,
|
||||||
idempotencyKey: 'topup-order-1',
|
idempotencyKey: 'topup-order-1',
|
||||||
@@ -82,14 +87,25 @@ describe('mockBackendService billing simulation', () => {
|
|||||||
|
|
||||||
let lastScan = await runScan(userId, 'scan-order-0');
|
let lastScan = await runScan(userId, 'scan-order-0');
|
||||||
expect(lastScan.billing.credits.usedThisCycle).toBe(1);
|
expect(lastScan.billing.credits.usedThisCycle).toBe(1);
|
||||||
expect(lastScan.billing.credits.topupBalance).toBe(25);
|
expect(lastScan.billing.credits.topupBalance).toBe(30);
|
||||||
|
|
||||||
for (let i = 1; i <= 15; i += 1) {
|
let scanIndex = 1;
|
||||||
lastScan = await runScan(userId, `scan-order-${i}`);
|
while (
|
||||||
|
lastScan.billing.credits.usedThisCycle < 100
|
||||||
|
&& lastScan.billing.credits.topupBalance === 30
|
||||||
|
&& scanIndex < 150
|
||||||
|
) {
|
||||||
|
lastScan = await runScan(userId, `scan-order-${scanIndex}`);
|
||||||
|
scanIndex += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(lastScan.billing.credits.usedThisCycle).toBe(15);
|
if (lastScan.billing.credits.topupBalance === 30) {
|
||||||
expect(lastScan.billing.credits.topupBalance).toBe(24);
|
lastScan = await runScan(userId, `scan-order-${scanIndex}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(lastScan.billing.credits.usedThisCycle).toBe(100);
|
||||||
|
expect(lastScan.billing.credits.topupBalance).toBeLessThan(30);
|
||||||
|
expect(lastScan.billing.credits.topupBalance).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can deplete all available credits via webhook simulation', async () => {
|
it('can deplete all available credits via webhook simulation', async () => {
|
||||||
@@ -113,32 +129,33 @@ describe('mockBackendService billing simulation', () => {
|
|||||||
|
|
||||||
it('does not double-charge scan when idempotency key is reused', async () => {
|
it('does not double-charge scan when idempotency key is reused', async () => {
|
||||||
const userId = 'test-user-scan-idempotency';
|
const userId = 'test-user-scan-idempotency';
|
||||||
|
await mockBackendService.simulatePurchase({
|
||||||
|
userId,
|
||||||
|
idempotencyKey: 'sub-scan-idempotency',
|
||||||
|
productId: 'monthly_pro',
|
||||||
|
});
|
||||||
const first = await runScan(userId, 'scan-abc');
|
const first = await runScan(userId, 'scan-abc');
|
||||||
const second = await runScan(userId, 'scan-abc');
|
const second = await runScan(userId, 'scan-abc');
|
||||||
|
|
||||||
expect(first.creditsCharged).toBe(1);
|
expect(first.creditsCharged).toBeGreaterThan(0);
|
||||||
expect(second.creditsCharged).toBe(1);
|
expect(second.creditsCharged).toBe(first.creditsCharged);
|
||||||
expect(second.billing.credits.available).toBe(first.billing.credits.available);
|
expect(second.billing.credits.available).toBe(first.billing.credits.available);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('enforces free monthly credit limit', async () => {
|
it('blocks free users from real scans', async () => {
|
||||||
const userId = 'test-user-credit-limit';
|
const userId = 'test-user-credit-limit';
|
||||||
let successfulScans = 0;
|
let successfulScans = 0;
|
||||||
let errorCode: string | null = null;
|
let errorCode: string | null = null;
|
||||||
|
|
||||||
for (let i = 0; i < 30; i += 1) {
|
try {
|
||||||
try {
|
await runScan(userId, 'scan-free-hard-paywall');
|
||||||
await runScan(userId, `scan-${i}`);
|
successfulScans += 1;
|
||||||
successfulScans += 1;
|
} catch (error) {
|
||||||
} catch (error) {
|
errorCode = (error as { code?: string }).code || null;
|
||||||
errorCode = (error as { code?: string }).code || null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(errorCode).toBe('INSUFFICIENT_CREDITS');
|
expect(errorCode).toBe('INSUFFICIENT_CREDITS');
|
||||||
expect(successfulScans).toBeGreaterThanOrEqual(7);
|
expect(successfulScans).toBe(0);
|
||||||
expect(successfulScans).toBeLessThanOrEqual(15);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('syncs pro entitlement from RevenueCat customer info', async () => {
|
it('syncs pro entitlement from RevenueCat customer info', async () => {
|
||||||
@@ -162,6 +179,70 @@ describe('mockBackendService billing simulation', () => {
|
|||||||
expect(response.billing.entitlement.renewsAt).toBe('2026-04-30T00:00:00.000Z');
|
expect(response.billing.entitlement.renewsAt).toBe('2026-04-30T00:00:00.000Z');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('limits RevenueCat trial entitlement to trial credits', async () => {
|
||||||
|
const response = await mockBackendService.syncRevenueCatState({
|
||||||
|
userId: 'test-user-rc-trial',
|
||||||
|
customerInfo: {
|
||||||
|
entitlements: {
|
||||||
|
active: {
|
||||||
|
pro: {
|
||||||
|
productIdentifier: 'monthly_pro',
|
||||||
|
expirationDate: '2026-04-30T00:00:00.000Z',
|
||||||
|
periodType: 'TRIAL',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nonSubscriptions: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.billing.entitlement.plan).toBe('pro');
|
||||||
|
expect(response.billing.credits.monthlyAllowance).toBe(30);
|
||||||
|
expect(response.billing.credits.available).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets trial usage when RevenueCat trial converts to paid pro', async () => {
|
||||||
|
const userId = 'test-user-rc-trial-converts';
|
||||||
|
await mockBackendService.syncRevenueCatState({
|
||||||
|
userId,
|
||||||
|
customerInfo: {
|
||||||
|
entitlements: {
|
||||||
|
active: {
|
||||||
|
pro: {
|
||||||
|
productIdentifier: 'monthly_pro',
|
||||||
|
expirationDate: '2026-04-30T00:00:00.000Z',
|
||||||
|
periodType: 'TRIAL',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nonSubscriptions: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const trialScan = await runScan(userId, 'trial-conversion-scan');
|
||||||
|
expect(trialScan.billing.credits.usedThisCycle).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const paidResponse = await mockBackendService.syncRevenueCatState({
|
||||||
|
userId,
|
||||||
|
customerInfo: {
|
||||||
|
entitlements: {
|
||||||
|
active: {
|
||||||
|
pro: {
|
||||||
|
productIdentifier: 'monthly_pro',
|
||||||
|
expirationDate: '2026-05-30T00:00:00.000Z',
|
||||||
|
periodType: 'NORMAL',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nonSubscriptions: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(paidResponse.billing.credits.monthlyAllowance).toBe(100);
|
||||||
|
expect(paidResponse.billing.credits.usedThisCycle).toBe(0);
|
||||||
|
expect(paidResponse.billing.credits.available).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
it('credits RevenueCat top-up transactions only once', async () => {
|
it('credits RevenueCat top-up transactions only once', async () => {
|
||||||
const userId = 'test-user-rc-topup';
|
const userId = 'test-user-rc-topup';
|
||||||
await mockBackendService.syncRevenueCatState({
|
await mockBackendService.syncRevenueCatState({
|
||||||
@@ -194,7 +275,7 @@ describe('mockBackendService billing simulation', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(second.billing.credits.topupBalance).toBe(25);
|
expect(second.billing.credits.topupBalance).toBe(30);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores malformed pro entitlements coming from top-up customer info', async () => {
|
it('ignores malformed pro entitlements coming from top-up customer info', async () => {
|
||||||
@@ -223,8 +304,8 @@ describe('mockBackendService billing simulation', () => {
|
|||||||
|
|
||||||
expect(response.billing.entitlement.plan).toBe('free');
|
expect(response.billing.entitlement.plan).toBe('free');
|
||||||
expect(response.billing.entitlement.status).toBe('inactive');
|
expect(response.billing.entitlement.status).toBe('inactive');
|
||||||
expect(response.billing.credits.topupBalance).toBe(25);
|
expect(response.billing.credits.topupBalance).toBe(30);
|
||||||
expect(response.billing.credits.available).toBe(40);
|
expect(response.billing.credits.available).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not downgrade an existing pro user during a top-up sync', async () => {
|
it('does not downgrade an existing pro user during a top-up sync', async () => {
|
||||||
@@ -270,7 +351,7 @@ describe('mockBackendService billing simulation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(response.billing.entitlement.plan).toBe('pro');
|
expect(response.billing.entitlement.plan).toBe('pro');
|
||||||
expect(response.billing.credits.available).toBe(275);
|
expect(response.billing.credits.available).toBe(130);
|
||||||
expect(response.billing.credits.topupBalance).toBe(25);
|
expect(response.billing.credits.topupBalance).toBe(30);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
2
app.json
2
app.json
@@ -17,6 +17,7 @@
|
|||||||
],
|
],
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
|
"usesAppleSignIn": true,
|
||||||
"bundleIdentifier": "com.greenlens.app",
|
"bundleIdentifier": "com.greenlens.app",
|
||||||
"buildNumber": "37",
|
"buildNumber": "37",
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"expo-dev-client",
|
"expo-dev-client",
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-camera",
|
"expo-camera",
|
||||||
|
"expo-apple-authentication",
|
||||||
"expo-image-picker",
|
"expo-image-picker",
|
||||||
"expo-secure-store",
|
"expo-secure-store",
|
||||||
"expo-asset",
|
"expo-asset",
|
||||||
|
|||||||
@@ -54,7 +54,17 @@ const ensureInstallConsistency = async (): Promise<boolean> => {
|
|||||||
import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen';
|
import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen';
|
||||||
|
|
||||||
function RootLayoutInner() {
|
function RootLayoutInner() {
|
||||||
const { isDarkMode, colorPalette, signOut, session, isInitializing, isLoadingPlants, syncRevenueCatState } = useApp();
|
const {
|
||||||
|
isDarkMode,
|
||||||
|
colorPalette,
|
||||||
|
signOut,
|
||||||
|
session,
|
||||||
|
billingSummary,
|
||||||
|
isInitializing,
|
||||||
|
isLoadingPlants,
|
||||||
|
isLoadingBilling,
|
||||||
|
syncRevenueCatState,
|
||||||
|
} = useApp();
|
||||||
const colors = useColors(isDarkMode, colorPalette);
|
const colors = useColors(isDarkMode, colorPalette);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [installCheckDone, setInstallCheckDone] = useState(false);
|
const [installCheckDone, setInstallCheckDone] = useState(false);
|
||||||
@@ -145,14 +155,22 @@ function RootLayoutInner() {
|
|||||||
}, [signOut]);
|
}, [signOut]);
|
||||||
|
|
||||||
const isAppReady = installCheckDone && !isInitializing && !isLoadingPlants;
|
const isAppReady = installCheckDone && !isInitializing && !isLoadingPlants;
|
||||||
|
const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro'
|
||||||
|
&& billingSummary?.entitlement?.status === 'active';
|
||||||
|
const isAllowedWithoutSession = pathname.includes('onboarding')
|
||||||
|
|| pathname.includes('auth/')
|
||||||
|
|| pathname.includes('scanner')
|
||||||
|
|| pathname.includes('profile/billing');
|
||||||
|
const isAllowedWithoutEntitlement = pathname.includes('auth/')
|
||||||
|
|| pathname.includes('scanner')
|
||||||
|
|| pathname.includes('profile/billing');
|
||||||
|
|
||||||
let content = null;
|
let content = null;
|
||||||
|
|
||||||
if (isAppReady) {
|
if (isAppReady) {
|
||||||
if (!session) {
|
if (!session) {
|
||||||
// Only redirect if we are not already on an auth-related page or the scanner
|
// Only redirect if we are not already on an auth-related page or the scanner
|
||||||
const isAuthPage = pathname.includes('onboarding') || pathname.includes('auth/') || pathname.includes('scanner') || pathname.includes('profile/billing');
|
if (!isAllowedWithoutSession) {
|
||||||
if (!isAuthPage) {
|
|
||||||
content = <Redirect href="/onboarding" />;
|
content = <Redirect href="/onboarding" />;
|
||||||
} else {
|
} else {
|
||||||
content = (
|
content = (
|
||||||
@@ -180,6 +198,8 @@ function RootLayoutInner() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (!hasActiveEntitlement && !isLoadingBilling && !isAllowedWithoutEntitlement) {
|
||||||
|
content = <Redirect href="/profile/billing" />;
|
||||||
} else {
|
} else {
|
||||||
content = (
|
content = (
|
||||||
<>
|
<>
|
||||||
@@ -249,7 +269,6 @@ export default function RootLayout() {
|
|||||||
<PostHogProvider apiKey={POSTHOG_API_KEY} options={{
|
<PostHogProvider apiKey={POSTHOG_API_KEY} options={{
|
||||||
host: 'https://us.i.posthog.com',
|
host: 'https://us.i.posthog.com',
|
||||||
enableSessionReplay: false,
|
enableSessionReplay: false,
|
||||||
debug: __DEV__,
|
|
||||||
}}>
|
}}>
|
||||||
<AppProvider>
|
<AppProvider>
|
||||||
<CoachMarksProvider>
|
<CoachMarksProvider>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -17,17 +17,35 @@ import { useApp } from '../../context/AppContext';
|
|||||||
import { useColors } from '../../constants/Colors';
|
import { useColors } from '../../constants/Colors';
|
||||||
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
|
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
|
||||||
import { AuthService } from '../../services/authService';
|
import { AuthService } from '../../services/authService';
|
||||||
|
import * as AppleAuthentication from 'expo-apple-authentication';
|
||||||
|
import { usePostHog } from 'posthog-react-native';
|
||||||
|
|
||||||
export default function LoginScreen() {
|
export default function LoginScreen() {
|
||||||
const { isDarkMode, colorPalette, hydrateSession, t } = useApp();
|
const { isDarkMode, colorPalette, hydrateSession, t } = useApp();
|
||||||
const colors = useColors(isDarkMode, colorPalette);
|
const colors = useColors(isDarkMode, colorPalette);
|
||||||
|
const posthog = usePostHog();
|
||||||
|
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [appleAvailable, setAppleAvailable] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
AppleAuthentication.isAvailableAsync()
|
||||||
|
.then((available) => {
|
||||||
|
if (mounted) setAppleAvailable(available);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (mounted) setAppleAvailable(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
if (!email.trim() || !password) {
|
if (!email.trim() || !password) {
|
||||||
setError(t.errFillAllFields);
|
setError(t.errFillAllFields);
|
||||||
@@ -38,7 +56,7 @@ export default function LoginScreen() {
|
|||||||
try {
|
try {
|
||||||
const session = await AuthService.login(email, password);
|
const session = await AuthService.login(email, password);
|
||||||
await hydrateSession(session);
|
await hydrateSession(session);
|
||||||
router.replace('/(tabs)');
|
router.replace('/profile/billing');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.message === 'USER_NOT_FOUND') {
|
if (e.message === 'USER_NOT_FOUND') {
|
||||||
setError(t.errUserNotFound);
|
setError(t.errUserNotFound);
|
||||||
@@ -56,6 +74,51 @@ export default function LoginScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAppleSignIn = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
posthog.capture('apple_login_started', { surface: 'login' });
|
||||||
|
try {
|
||||||
|
const credential = await AppleAuthentication.signInAsync({
|
||||||
|
requestedScopes: [
|
||||||
|
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||||||
|
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!credential.identityToken) {
|
||||||
|
throw new Error('APPLE_AUTH_INVALID');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullName = [
|
||||||
|
credential.fullName?.givenName,
|
||||||
|
credential.fullName?.familyName,
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
const session = await AuthService.signInWithApple({
|
||||||
|
identityToken: credential.identityToken,
|
||||||
|
appleUser: credential.user,
|
||||||
|
email: credential.email,
|
||||||
|
name: fullName || undefined,
|
||||||
|
});
|
||||||
|
await hydrateSession(session);
|
||||||
|
posthog.capture('apple_login_succeeded', { surface: 'login' });
|
||||||
|
router.replace('/profile/billing');
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.code === 'ERR_REQUEST_CANCELED') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
posthog.capture('apple_login_failed', {
|
||||||
|
surface: 'login',
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
setError(e?.message === 'APPLE_BACKEND_UNAVAILABLE'
|
||||||
|
? 'Apple Login ist auf dem Backend noch nicht aktiviert. Bitte Backend neu starten oder deployen.'
|
||||||
|
: t.errAuthError);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={[styles.flex, { backgroundColor: colors.background }]}
|
style={[styles.flex, { backgroundColor: colors.background }]}
|
||||||
@@ -82,6 +145,26 @@ export default function LoginScreen() {
|
|||||||
|
|
||||||
{/* Card */}
|
{/* Card */}
|
||||||
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}>
|
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}>
|
||||||
|
{appleAvailable ? (
|
||||||
|
<AppleAuthentication.AppleAuthenticationButton
|
||||||
|
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
|
||||||
|
buttonStyle={isDarkMode
|
||||||
|
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
|
||||||
|
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
|
||||||
|
cornerRadius={12}
|
||||||
|
style={styles.appleButton}
|
||||||
|
onPress={handleAppleSignIn}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{appleAvailable ? (
|
||||||
|
<View style={styles.dividerRowCompact}>
|
||||||
|
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
|
||||||
|
<Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text>
|
||||||
|
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
<View style={styles.fieldGroup}>
|
<View style={styles.fieldGroup}>
|
||||||
<Text style={[styles.label, { color: colors.textSecondary }]}>E-Mail</Text>
|
<Text style={[styles.label, { color: colors.textSecondary }]}>E-Mail</Text>
|
||||||
@@ -211,6 +294,17 @@ const styles = StyleSheet.create({
|
|||||||
shadowRadius: 12,
|
shadowRadius: 12,
|
||||||
elevation: 4,
|
elevation: 4,
|
||||||
},
|
},
|
||||||
|
appleButton: {
|
||||||
|
width: '100%',
|
||||||
|
height: 50,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
dividerRowCompact: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
marginVertical: 2,
|
||||||
|
},
|
||||||
fieldGroup: {
|
fieldGroup: {
|
||||||
gap: 6,
|
gap: 6,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -18,10 +18,13 @@ import { useColors } from '../../constants/Colors';
|
|||||||
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
|
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
|
||||||
import { AuthService } from '../../services/authService';
|
import { AuthService } from '../../services/authService';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import * as AppleAuthentication from 'expo-apple-authentication';
|
||||||
|
import { usePostHog } from 'posthog-react-native';
|
||||||
|
|
||||||
export default function SignupScreen() {
|
export default function SignupScreen() {
|
||||||
const { isDarkMode, colorPalette, hydrateSession, getPendingPlant, t } = useApp();
|
const { isDarkMode, colorPalette, hydrateSession, getPendingPlant, t } = useApp();
|
||||||
const colors = useColors(isDarkMode, colorPalette);
|
const colors = useColors(isDarkMode, colorPalette);
|
||||||
|
const posthog = usePostHog();
|
||||||
const pendingPlant = getPendingPlant();
|
const pendingPlant = getPendingPlant();
|
||||||
|
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
@@ -30,9 +33,24 @@ export default function SignupScreen() {
|
|||||||
const [passwordConfirm, setPasswordConfirm] = useState('');
|
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [showPasswordConfirm, setShowPasswordConfirm] = useState(false);
|
const [showPasswordConfirm, setShowPasswordConfirm] = useState(false);
|
||||||
|
const [appleAvailable, setAppleAvailable] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
AppleAuthentication.isAvailableAsync()
|
||||||
|
.then((available) => {
|
||||||
|
if (mounted) setAppleAvailable(available);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (mounted) setAppleAvailable(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const validate = (): string | null => {
|
const validate = (): string | null => {
|
||||||
if (!name.trim()) return t.errNameRequired;
|
if (!name.trim()) return t.errNameRequired;
|
||||||
if (!email.trim() || !email.includes('@')) return t.errEmailInvalid;
|
if (!email.trim() || !email.includes('@')) return t.errEmailInvalid;
|
||||||
@@ -54,7 +72,7 @@ export default function SignupScreen() {
|
|||||||
await hydrateSession(session);
|
await hydrateSession(session);
|
||||||
// Flag setzen: Tour beim nächsten App-Öffnen anzeigen
|
// Flag setzen: Tour beim nächsten App-Öffnen anzeigen
|
||||||
await AsyncStorage.setItem('greenlens_show_tour', 'true');
|
await AsyncStorage.setItem('greenlens_show_tour', 'true');
|
||||||
router.replace('/onboarding/source');
|
router.replace('/profile/billing');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.message === 'EMAIL_TAKEN') {
|
if (e.message === 'EMAIL_TAKEN') {
|
||||||
setError(t.errEmailTaken);
|
setError(t.errEmailTaken);
|
||||||
@@ -74,6 +92,52 @@ export default function SignupScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAppleSignIn = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
posthog.capture('apple_login_started', { surface: 'signup' });
|
||||||
|
try {
|
||||||
|
const credential = await AppleAuthentication.signInAsync({
|
||||||
|
requestedScopes: [
|
||||||
|
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||||||
|
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!credential.identityToken) {
|
||||||
|
throw new Error('APPLE_AUTH_INVALID');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullName = [
|
||||||
|
credential.fullName?.givenName,
|
||||||
|
credential.fullName?.familyName,
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
const session = await AuthService.signInWithApple({
|
||||||
|
identityToken: credential.identityToken,
|
||||||
|
appleUser: credential.user,
|
||||||
|
email: credential.email,
|
||||||
|
name: fullName || undefined,
|
||||||
|
});
|
||||||
|
await hydrateSession(session);
|
||||||
|
await AsyncStorage.setItem('greenlens_show_tour', 'true');
|
||||||
|
posthog.capture('apple_login_succeeded', { surface: 'signup' });
|
||||||
|
router.replace('/profile/billing');
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.code === 'ERR_REQUEST_CANCELED') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
posthog.capture('apple_login_failed', {
|
||||||
|
surface: 'signup',
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
setError(e?.message === 'APPLE_BACKEND_UNAVAILABLE'
|
||||||
|
? 'Apple Login ist auf dem Backend noch nicht aktiviert. Bitte Backend neu starten oder deployen.'
|
||||||
|
: t.errAuthError);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={[styles.flex, { backgroundColor: colors.background }]}
|
style={[styles.flex, { backgroundColor: colors.background }]}
|
||||||
@@ -116,6 +180,26 @@ export default function SignupScreen() {
|
|||||||
|
|
||||||
{/* Card */}
|
{/* Card */}
|
||||||
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}>
|
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}>
|
||||||
|
{appleAvailable ? (
|
||||||
|
<AppleAuthentication.AppleAuthenticationButton
|
||||||
|
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
|
||||||
|
buttonStyle={isDarkMode
|
||||||
|
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
|
||||||
|
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
|
||||||
|
cornerRadius={12}
|
||||||
|
style={styles.appleButton}
|
||||||
|
onPress={handleAppleSignIn}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{appleAvailable ? (
|
||||||
|
<View style={styles.dividerRowCompact}>
|
||||||
|
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
|
||||||
|
<Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text>
|
||||||
|
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<View style={styles.fieldGroup}>
|
<View style={styles.fieldGroup}>
|
||||||
<Text style={[styles.label, { color: colors.textSecondary }]}>Name</Text>
|
<Text style={[styles.label, { color: colors.textSecondary }]}>Name</Text>
|
||||||
@@ -327,6 +411,25 @@ const styles = StyleSheet.create({
|
|||||||
shadowRadius: 12,
|
shadowRadius: 12,
|
||||||
elevation: 4,
|
elevation: 4,
|
||||||
},
|
},
|
||||||
|
appleButton: {
|
||||||
|
width: '100%',
|
||||||
|
height: 50,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
dividerRowCompact: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
marginVertical: 2,
|
||||||
|
},
|
||||||
|
dividerLine: {
|
||||||
|
flex: 1,
|
||||||
|
height: 1,
|
||||||
|
},
|
||||||
|
dividerText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
fieldGroup: {
|
fieldGroup: {
|
||||||
gap: 6,
|
gap: 6,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ type TopupProductId = Extract<PurchaseProductId, 'topup_small' | 'topup_medium'
|
|||||||
type SubscriptionPackages = Partial<Record<SubscriptionProductId, PurchasesPackage>>;
|
type SubscriptionPackages = Partial<Record<SubscriptionProductId, PurchasesPackage>>;
|
||||||
type TopupProducts = Partial<Record<TopupProductId, PurchasesStoreProduct>>;
|
type TopupProducts = Partial<Record<TopupProductId, PurchasesStoreProduct>>;
|
||||||
|
|
||||||
|
const TOPUP_CREDITS_BY_PRODUCT: Record<TopupProductId, number> = {
|
||||||
|
topup_small: 30,
|
||||||
|
topup_medium: 100,
|
||||||
|
topup_large: 250,
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTopupProductId = (productId: PurchaseProductId): productId is TopupProductId => (
|
||||||
|
productId === 'topup_small' || productId === 'topup_medium' || productId === 'topup_large'
|
||||||
|
);
|
||||||
|
|
||||||
const isMatchingPackage = (
|
const isMatchingPackage = (
|
||||||
pkg: PurchasesPackage,
|
pkg: PurchasesPackage,
|
||||||
productId: SubscriptionProductId,
|
productId: SubscriptionProductId,
|
||||||
@@ -79,6 +89,21 @@ const getBillingCopy = (language: Language) => {
|
|||||||
manageSubscription: 'Abo verwalten',
|
manageSubscription: 'Abo verwalten',
|
||||||
subscriptionTitle: 'Abos',
|
subscriptionTitle: 'Abos',
|
||||||
subscriptionHint: 'Wähle ein Abo und schalte stärkere KI-Scans sowie mehr Credits frei.',
|
subscriptionHint: 'Wähle ein Abo und schalte stärkere KI-Scans sowie mehr Credits frei.',
|
||||||
|
paywallTitle: 'Vollstaendige Diagnose freischalten',
|
||||||
|
paywallHint: 'Starte Pro fuer echte GPT-5.4 Scans, deinen 7-Tage-Rettungsplan und 100 Credits fuer AI-Scans und Follow-ups.',
|
||||||
|
startTrial: '7 Tage kostenlos testen',
|
||||||
|
monthlyCta: 'Monatlich starten',
|
||||||
|
yearlyCta: 'Jaehrlich starten',
|
||||||
|
yearlyTrialBadge: '7 TAGE GRATIS',
|
||||||
|
monthlyBadge: 'FLEXIBEL',
|
||||||
|
yearlySubline: 'Danach 39,99 EUR/Jahr. Jederzeit kuendbar.',
|
||||||
|
monthlySubline: '4,99 EUR/Monat. Ohne Jahresbindung.',
|
||||||
|
saveLabel: 'Bester Wert',
|
||||||
|
expoGoPurchaseTitle: 'Kauf nur im Dev Build',
|
||||||
|
expoGoPurchaseMessage: 'Expo Go kann keine Apple- oder RevenueCat-Kaufmaske anzeigen. Im Development Build oder TestFlight erscheint hier der echte 7-Tage-Trial. Fuer lokale Tests kannst du Pro simulieren.',
|
||||||
|
expoGoSimulate: 'Pro simulieren',
|
||||||
|
perYear: '/ Jahr',
|
||||||
|
perMonth: '/ Monat',
|
||||||
freePlanName: 'Free',
|
freePlanName: 'Free',
|
||||||
freePlanPrice: '0 EUR / Monat',
|
freePlanPrice: '0 EUR / Monat',
|
||||||
proPlanName: 'Pro',
|
proPlanName: 'Pro',
|
||||||
@@ -88,17 +113,20 @@ const getBillingCopy = (language: Language) => {
|
|||||||
proYearlyPlanPrice: '39,99 € / Jahr',
|
proYearlyPlanPrice: '39,99 € / Jahr',
|
||||||
proYearlyBadgeText: 'SPAREN',
|
proYearlyBadgeText: 'SPAREN',
|
||||||
proBenefits: [
|
proBenefits: [
|
||||||
'250 Credits jeden Monat',
|
'100 Credits für AI-Scans und Follow-ups jeden Monat',
|
||||||
'Pro-Scans mit GPT-5.4',
|
'Pro-Scans mit GPT-5.4',
|
||||||
'Unbegrenzte Historie & Galerie',
|
'Unbegrenzte Historie & Galerie',
|
||||||
'KI-Pflanzendoktor inklusive',
|
'KI-Pflanzendoktor inklusive',
|
||||||
'Priorisierter Support'
|
'Priorisierter Support'
|
||||||
],
|
],
|
||||||
topupTitle: 'Credits Aufladen',
|
topupTitle: 'Credits Aufladen',
|
||||||
topupSmall: '25 Credits – 1,99 €',
|
topupHint: 'Für aktive Pro-Nutzer, wenn die Monatscredits nicht reichen.',
|
||||||
topupMedium: '120 Credits – 6,99 €',
|
topupSmall: '30 Credits – 2,99 €',
|
||||||
topupLarge: '300 Credits – 12,99 €',
|
topupMedium: '100 Credits – 6,99 €',
|
||||||
|
topupLarge: '250 Credits – 12,99 €',
|
||||||
topupBestValue: 'BESTES ANGEBOT',
|
topupBestValue: 'BESTES ANGEBOT',
|
||||||
|
topupRequiresProTitle: 'Pro erforderlich',
|
||||||
|
topupRequiresProMessage: 'Top-ups sind für aktive Pro-Nutzer gedacht. Starte Pro, um zusätzliche Credits zu kaufen.',
|
||||||
cancelTitle: 'Schade, dass du gehst',
|
cancelTitle: 'Schade, dass du gehst',
|
||||||
cancelQuestion: 'Dürfen wir fragen, warum du kündigst?',
|
cancelQuestion: 'Dürfen wir fragen, warum du kündigst?',
|
||||||
reasonTooExpensive: 'Es ist mir zu teuer',
|
reasonTooExpensive: 'Es ist mir zu teuer',
|
||||||
@@ -124,6 +152,21 @@ const getBillingCopy = (language: Language) => {
|
|||||||
manageSubscription: 'Administrar Suscripción',
|
manageSubscription: 'Administrar Suscripción',
|
||||||
subscriptionTitle: 'Suscripciones',
|
subscriptionTitle: 'Suscripciones',
|
||||||
subscriptionHint: 'Elige un plan y desbloquea escaneos con IA más potentes y más créditos.',
|
subscriptionHint: 'Elige un plan y desbloquea escaneos con IA más potentes y más créditos.',
|
||||||
|
paywallTitle: 'Desbloquear diagnostico completo',
|
||||||
|
paywallHint: 'Inicia Pro para escaneos reales con GPT-5.4, tu plan de rescate de 7 dias y 100 creditos para escaneos IA y seguimientos.',
|
||||||
|
startTrial: 'Probar 7 dias gratis',
|
||||||
|
monthlyCta: 'Empezar mensual',
|
||||||
|
yearlyCta: 'Empezar anual',
|
||||||
|
yearlyTrialBadge: '7 DIAS GRATIS',
|
||||||
|
monthlyBadge: 'FLEXIBLE',
|
||||||
|
yearlySubline: 'Despues 39.99 EUR/ano. Cancela cuando quieras.',
|
||||||
|
monthlySubline: '4.99 EUR/mes. Sin compromiso anual.',
|
||||||
|
saveLabel: 'Mejor valor',
|
||||||
|
expoGoPurchaseTitle: 'Compra solo en Dev Build',
|
||||||
|
expoGoPurchaseMessage: 'Expo Go no puede mostrar la compra nativa de Apple o RevenueCat. En Development Build o TestFlight aparecera el trial real de 7 dias. Para pruebas locales puedes simular Pro.',
|
||||||
|
expoGoSimulate: 'Simular Pro',
|
||||||
|
perYear: '/ ano',
|
||||||
|
perMonth: '/ mes',
|
||||||
freePlanName: 'Gratis',
|
freePlanName: 'Gratis',
|
||||||
freePlanPrice: '0 EUR / Mes',
|
freePlanPrice: '0 EUR / Mes',
|
||||||
proPlanName: 'Pro',
|
proPlanName: 'Pro',
|
||||||
@@ -133,17 +176,20 @@ const getBillingCopy = (language: Language) => {
|
|||||||
proYearlyPlanPrice: '39.99 EUR / Año',
|
proYearlyPlanPrice: '39.99 EUR / Año',
|
||||||
proYearlyBadgeText: 'AHORRAR',
|
proYearlyBadgeText: 'AHORRAR',
|
||||||
proBenefits: [
|
proBenefits: [
|
||||||
'250 créditos cada mes',
|
'100 créditos para escaneos IA y seguimientos cada mes',
|
||||||
'Escaneos Pro con GPT-5.4',
|
'Escaneos Pro con GPT-5.4',
|
||||||
'Historial y galería ilimitados',
|
'Historial y galería ilimitados',
|
||||||
'Doctor de plantas de IA incluido',
|
'Doctor de plantas de IA incluido',
|
||||||
'Soporte prioritario'
|
'Soporte prioritario'
|
||||||
],
|
],
|
||||||
topupTitle: 'Recargar Créditos',
|
topupTitle: 'Recargar Créditos',
|
||||||
topupSmall: '25 Créditos – 1,99 €',
|
topupHint: 'Para usuarios Pro activos cuando los créditos mensuales no alcanzan.',
|
||||||
topupMedium: '120 Créditos – 6,99 €',
|
topupSmall: '30 Créditos – 2,99 €',
|
||||||
topupLarge: '300 Créditos – 12,99 €',
|
topupMedium: '100 Créditos – 6,99 €',
|
||||||
|
topupLarge: '250 Créditos – 12,99 €',
|
||||||
topupBestValue: 'MEJOR OFERTA',
|
topupBestValue: 'MEJOR OFERTA',
|
||||||
|
topupRequiresProTitle: 'Pro requerido',
|
||||||
|
topupRequiresProMessage: 'Los top-ups son para usuarios Pro activos. Inicia Pro para comprar créditos adicionales.',
|
||||||
cancelTitle: 'Lamentamos verte ir',
|
cancelTitle: 'Lamentamos verte ir',
|
||||||
cancelQuestion: '¿Podemos saber por qué cancelas?',
|
cancelQuestion: '¿Podemos saber por qué cancelas?',
|
||||||
reasonTooExpensive: 'Es muy caro',
|
reasonTooExpensive: 'Es muy caro',
|
||||||
@@ -169,6 +215,21 @@ const getBillingCopy = (language: Language) => {
|
|||||||
manageSubscription: 'Manage Subscription',
|
manageSubscription: 'Manage Subscription',
|
||||||
subscriptionTitle: 'Subscriptions',
|
subscriptionTitle: 'Subscriptions',
|
||||||
subscriptionHint: 'Choose a plan to unlock stronger AI scans and more credits.',
|
subscriptionHint: 'Choose a plan to unlock stronger AI scans and more credits.',
|
||||||
|
paywallTitle: 'Unlock the full diagnosis',
|
||||||
|
paywallHint: 'Start Pro for real GPT-5.4 scans, your 7-day rescue plan, and 100 credits for AI scans and follow-ups.',
|
||||||
|
startTrial: 'Start 7-day free trial',
|
||||||
|
monthlyCta: 'Start monthly',
|
||||||
|
yearlyCta: 'Start yearly',
|
||||||
|
yearlyTrialBadge: '7 DAYS FREE',
|
||||||
|
monthlyBadge: 'FLEXIBLE',
|
||||||
|
yearlySubline: 'Then EUR 39.99/year. Cancel anytime.',
|
||||||
|
monthlySubline: 'EUR 4.99/month. No annual commitment.',
|
||||||
|
saveLabel: 'Best value',
|
||||||
|
expoGoPurchaseTitle: 'Purchase requires a dev build',
|
||||||
|
expoGoPurchaseMessage: 'Expo Go cannot show the native Apple or RevenueCat purchase sheet. In a Development Build or TestFlight this opens the real 7-day trial. For local testing you can simulate Pro.',
|
||||||
|
expoGoSimulate: 'Simulate Pro',
|
||||||
|
perYear: '/ year',
|
||||||
|
perMonth: '/ month',
|
||||||
freePlanName: 'Free',
|
freePlanName: 'Free',
|
||||||
freePlanPrice: '0 EUR / Month',
|
freePlanPrice: '0 EUR / Month',
|
||||||
proPlanName: 'Pro',
|
proPlanName: 'Pro',
|
||||||
@@ -178,17 +239,20 @@ const getBillingCopy = (language: Language) => {
|
|||||||
proYearlyPlanPrice: '39.99 EUR / Year',
|
proYearlyPlanPrice: '39.99 EUR / Year',
|
||||||
proYearlyBadgeText: 'SAVE',
|
proYearlyBadgeText: 'SAVE',
|
||||||
proBenefits: [
|
proBenefits: [
|
||||||
'250 credits every month',
|
'100 credits for AI scans and follow-ups every month',
|
||||||
'Pro scans with GPT-5.4',
|
'Pro scans with GPT-5.4',
|
||||||
'Unlimited history & gallery',
|
'Unlimited history & gallery',
|
||||||
'AI Plant Doctor included',
|
'AI Plant Doctor included',
|
||||||
'Priority support'
|
'Priority support'
|
||||||
],
|
],
|
||||||
topupTitle: 'Topup Credits',
|
topupTitle: 'Topup Credits',
|
||||||
topupSmall: '25 Credits – €1.99',
|
topupHint: 'For active Pro users when monthly credits are not enough.',
|
||||||
topupMedium: '120 Credits – €6.99',
|
topupSmall: '30 Credits – €2.99',
|
||||||
topupLarge: '300 Credits – €12.99',
|
topupMedium: '100 Credits – €6.99',
|
||||||
|
topupLarge: '250 Credits – €12.99',
|
||||||
topupBestValue: 'BEST VALUE',
|
topupBestValue: 'BEST VALUE',
|
||||||
|
topupRequiresProTitle: 'Pro required',
|
||||||
|
topupRequiresProMessage: 'Top-ups are for active Pro users. Start Pro to buy extra credits.',
|
||||||
cancelTitle: 'Sorry to see you go',
|
cancelTitle: 'Sorry to see you go',
|
||||||
cancelQuestion: 'May we ask why you are cancelling?',
|
cancelQuestion: 'May we ask why you are cancelling?',
|
||||||
reasonTooExpensive: 'It is too expensive',
|
reasonTooExpensive: 'It is too expensive',
|
||||||
@@ -227,6 +291,7 @@ export default function BillingScreen() {
|
|||||||
|
|
||||||
const planId = billingSummary?.entitlement?.plan || 'free';
|
const planId = billingSummary?.entitlement?.plan || 'free';
|
||||||
const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? '--');
|
const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? '--');
|
||||||
|
const showPaywallPlans = !session || planId !== 'pro';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -276,7 +341,13 @@ export default function BillingScreen() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
posthog.capture('paywall_viewed', { plan_id: planId });
|
posthog.capture('paywall_viewed', { plan_id: planId });
|
||||||
}, [posthog, planId]);
|
if (showPaywallPlans) {
|
||||||
|
posthog.capture('hard_paywall_viewed', {
|
||||||
|
plan_id: planId,
|
||||||
|
authenticated: Boolean(session),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [posthog, planId, session?.serverUserId, showPaywallPlans]);
|
||||||
|
|
||||||
const monthlyPackage = subscriptionPackages.monthly_pro;
|
const monthlyPackage = subscriptionPackages.monthly_pro;
|
||||||
const yearlyPackage = subscriptionPackages.yearly_pro;
|
const yearlyPackage = subscriptionPackages.yearly_pro;
|
||||||
@@ -285,22 +356,60 @@ export default function BillingScreen() {
|
|||||||
const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPrice;
|
const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPrice;
|
||||||
|
|
||||||
const topupLabels = useMemo(() => ({
|
const topupLabels = useMemo(() => ({
|
||||||
topup_small: topupProducts.topup_small ? `25 Credits - ${topupProducts.topup_small.priceString}` : copy.topupSmall,
|
topup_small: topupProducts.topup_small ? `${TOPUP_CREDITS_BY_PRODUCT.topup_small} Credits - ${topupProducts.topup_small.priceString}` : copy.topupSmall,
|
||||||
topup_medium: topupProducts.topup_medium ? `120 Credits - ${topupProducts.topup_medium.priceString}` : copy.topupMedium,
|
topup_medium: topupProducts.topup_medium ? `${TOPUP_CREDITS_BY_PRODUCT.topup_medium} Credits - ${topupProducts.topup_medium.priceString}` : copy.topupMedium,
|
||||||
topup_large: topupProducts.topup_large ? `300 Credits - ${topupProducts.topup_large.priceString}` : copy.topupLarge,
|
topup_large: topupProducts.topup_large ? `${TOPUP_CREDITS_BY_PRODUCT.topup_large} Credits - ${topupProducts.topup_large.priceString}` : copy.topupLarge,
|
||||||
}), [copy.topupLarge, copy.topupMedium, copy.topupSmall, topupProducts.topup_large, topupProducts.topup_medium, topupProducts.topup_small]);
|
}), [copy.topupLarge, copy.topupMedium, copy.topupSmall, topupProducts.topup_large, topupProducts.topup_medium, topupProducts.topup_small]);
|
||||||
|
|
||||||
const openAppleSubscriptions = async () => {
|
const openAppleSubscriptions = async () => {
|
||||||
await Linking.openURL('itms-apps://apps.apple.com/account/subscriptions');
|
await Linking.openURL('itms-apps://apps.apple.com/account/subscriptions');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (showPaywallPlans) {
|
||||||
|
router.replace(session ? '/scanner' : '/onboarding');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeExpoGoSimulation = async (productId: PurchaseProductId) => {
|
||||||
|
setIsUpdating(true);
|
||||||
|
try {
|
||||||
|
await simulatePurchase(productId);
|
||||||
|
if (productId === 'monthly_pro' || productId === 'yearly_pro') {
|
||||||
|
posthog.capture('subscription_started', { product_id: productId, simulated: true });
|
||||||
|
posthog.capture('trial_started', { product_id: productId, simulated: true });
|
||||||
|
} else {
|
||||||
|
posthog.capture('topup_purchased', { product_id: productId, simulated: true });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handlePurchase = async (productId: PurchaseProductId) => {
|
const handlePurchase = async (productId: PurchaseProductId) => {
|
||||||
|
if (isTopupProductId(productId) && planId !== 'pro') {
|
||||||
|
Alert.alert(copy.topupRequiresProTitle, copy.topupRequiresProMessage, [
|
||||||
|
{ text: copy.manageSubscription, onPress: () => setSubModalVisible(true) },
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
posthog.capture('purchase_initiated', { product_id: productId });
|
posthog.capture('purchase_initiated', { product_id: productId });
|
||||||
try {
|
try {
|
||||||
if (isExpoGo) {
|
if (isExpoGo) {
|
||||||
// ExpoGo has no native RevenueCat — use simulation for development only
|
// ExpoGo has no native RevenueCat — use simulation for development only
|
||||||
await simulatePurchase(productId);
|
setIsUpdating(false);
|
||||||
|
if (productId === 'monthly_pro' || productId === 'yearly_pro') {
|
||||||
|
Alert.alert(copy.expoGoPurchaseTitle, copy.expoGoPurchaseMessage, [
|
||||||
|
{ text: 'OK', style: 'cancel' },
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await completeExpoGoSimulation(productId);
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
if (productId === 'monthly_pro' || productId === 'yearly_pro') {
|
if (productId === 'monthly_pro' || productId === 'yearly_pro') {
|
||||||
if (planId === 'pro') {
|
if (planId === 'pro') {
|
||||||
@@ -323,7 +432,6 @@ export default function BillingScreen() {
|
|||||||
// Derive plan locally from RevenueCat — backend sync via webhook comes later (Step 3)
|
// Derive plan locally from RevenueCat — backend sync via webhook comes later (Step 3)
|
||||||
const customerInfo = await Purchases.getCustomerInfo();
|
const customerInfo = await Purchases.getCustomerInfo();
|
||||||
await syncRevenueCatState(customerInfo as any, 'subscription_purchase');
|
await syncRevenueCatState(customerInfo as any, 'subscription_purchase');
|
||||||
posthog.capture('subscription_started', { product_id: productId });
|
|
||||||
} else {
|
} else {
|
||||||
const selectedProduct = topupProducts[productId];
|
const selectedProduct = topupProducts[productId];
|
||||||
if (!selectedProduct) {
|
if (!selectedProduct) {
|
||||||
@@ -332,9 +440,14 @@ export default function BillingScreen() {
|
|||||||
await Purchases.purchaseStoreProduct(selectedProduct);
|
await Purchases.purchaseStoreProduct(selectedProduct);
|
||||||
const customerInfo = await Purchases.getCustomerInfo();
|
const customerInfo = await Purchases.getCustomerInfo();
|
||||||
await syncRevenueCatState(customerInfo as any, 'topup_purchase');
|
await syncRevenueCatState(customerInfo as any, 'topup_purchase');
|
||||||
posthog.capture('topup_purchased', { product_id: productId });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (productId === 'monthly_pro' || productId === 'yearly_pro') {
|
||||||
|
posthog.capture('subscription_started', { product_id: productId });
|
||||||
|
posthog.capture('trial_started', { product_id: productId });
|
||||||
|
} else {
|
||||||
|
posthog.capture('topup_purchased', { product_id: productId });
|
||||||
|
}
|
||||||
setSubModalVisible(false);
|
setSubModalVisible(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
@@ -342,6 +455,7 @@ export default function BillingScreen() {
|
|||||||
|
|
||||||
if (userCancelled) {
|
if (userCancelled) {
|
||||||
posthog.capture('purchase_cancelled', { product_id: productId });
|
posthog.capture('purchase_cancelled', { product_id: productId });
|
||||||
|
posthog.capture('paywall_purchase_cancelled', { product_id: productId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,7 +519,7 @@ export default function BillingScreen() {
|
|||||||
<ThemeBackdrop colors={colors} />
|
<ThemeBackdrop colors={colors} />
|
||||||
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
<TouchableOpacity onPress={handleBack} style={styles.backButton}>
|
||||||
<Ionicons name="arrow-back" size={24} color={colors.text} />
|
<Ionicons name="arrow-back" size={24} color={colors.text} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text style={[styles.title, { color: colors.text }]}>{copy.title}</Text>
|
<Text style={[styles.title, { color: colors.text }]}>{copy.title}</Text>
|
||||||
@@ -417,7 +531,7 @@ export default function BillingScreen() {
|
|||||||
<ActivityIndicator size="large" color={colors.primary} style={{ marginTop: 40 }} />
|
<ActivityIndicator size="large" color={colors.primary} style={{ marginTop: 40 }} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{session && (
|
{session && planId === 'pro' && (
|
||||||
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
|
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
|
||||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>{copy.planLabel}</Text>
|
<Text style={[styles.sectionTitle, { color: colors.text }]}>{copy.planLabel}</Text>
|
||||||
<View style={[styles.row, { marginBottom: 16 }]}>
|
<View style={[styles.row, { marginBottom: 16 }]}>
|
||||||
@@ -436,66 +550,73 @@ export default function BillingScreen() {
|
|||||||
<Text style={[styles.creditsValue, { color: colors.text }]}>{credits}</Text>
|
<Text style={[styles.creditsValue, { color: colors.text }]}>{credits}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{!session && (
|
{showPaywallPlans && (
|
||||||
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
|
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
|
||||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Subscription Plans</Text>
|
<Text style={[styles.paywallTitle, { color: colors.text }]}>{copy.paywallTitle}</Text>
|
||||||
<Text style={[styles.modalHint, { color: colors.text + '80', marginBottom: 16 }]}>
|
<Text style={[styles.modalHint, { color: colors.text + '80', marginBottom: 16 }]}>
|
||||||
Choose a plan to unlock AI plant scans and care features.
|
{copy.paywallHint}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Monthly */}
|
<View style={styles.paywallValueRows}>
|
||||||
<View style={[styles.guestPlanCard, { borderColor: colors.primary, backgroundColor: colors.primary + '10' }]}>
|
{copy.proBenefits.slice(0, 3).map((benefit, index) => (
|
||||||
<View style={styles.guestPlanHeader}>
|
<View key={benefit} style={[styles.paywallValueRow, { backgroundColor: colors.surfaceMuted }]}>
|
||||||
<Text style={[styles.guestPlanName, { color: colors.text }]}>GreenLens Pro</Text>
|
<Ionicons
|
||||||
<View style={[styles.proBadge, { backgroundColor: colors.primary }]}>
|
name={index === 0 ? 'scan-outline' : index === 1 ? 'medkit-outline' : 'calendar-outline'}
|
||||||
<Text style={styles.proBadgeText}>MONTHLY</Text>
|
size={17}
|
||||||
|
color={colors.primary}
|
||||||
|
/>
|
||||||
|
<Text style={[styles.paywallValueText, { color: colors.text }]}>{benefit}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
))}
|
||||||
<Text style={[styles.guestPlanPrice, { color: colors.text }]}>{monthlyPrice}</Text>
|
|
||||||
<Text style={[styles.guestPlanRenew, { color: colors.textMuted }]}>{copy.autoRenewMonthly}</Text>
|
|
||||||
<View style={{ gap: 4, marginTop: 8 }}>
|
|
||||||
{copy.proBenefits.map((b, i) => (
|
|
||||||
<View key={i} style={styles.benefitRow}>
|
|
||||||
<Ionicons name="checkmark" size={14} color={colors.primary} />
|
|
||||||
<Text style={[styles.benefitText, { color: colors.textSecondary }]}>{b}</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.guestSubscribeBtn, { backgroundColor: colors.primary }]}
|
|
||||||
onPress={() => handlePurchase('monthly_pro')}
|
|
||||||
disabled={isUpdating || !storeReady}
|
|
||||||
>
|
|
||||||
<Text style={styles.manageBtnText}>Subscribe Monthly</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Yearly */}
|
<TouchableOpacity
|
||||||
<View style={[styles.guestPlanCard, { borderColor: colors.border, marginTop: 12 }]}>
|
style={[styles.paywallPlanCardPrimary, { borderColor: colors.primary, backgroundColor: colors.primary + '12' }]}
|
||||||
<View style={styles.guestPlanHeader}>
|
onPress={() => handlePurchase('yearly_pro')}
|
||||||
<Text style={[styles.guestPlanName, { color: colors.text }]}>GreenLens Pro</Text>
|
disabled={isUpdating || !storeReady}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
>
|
||||||
|
<View style={styles.planTopRow}>
|
||||||
|
<View>
|
||||||
|
<Text style={[styles.guestPlanName, { color: colors.text }]}>GreenLens Pro</Text>
|
||||||
|
<Text style={[styles.planSubline, { color: colors.textMuted }]}>{copy.saveLabel}</Text>
|
||||||
|
</View>
|
||||||
<View style={[styles.proBadge, { backgroundColor: colors.primary }]}>
|
<View style={[styles.proBadge, { backgroundColor: colors.primary }]}>
|
||||||
<Text style={styles.proBadgeText}>YEARLY</Text>
|
<Text style={styles.proBadgeText}>{copy.yearlyTrialBadge}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text style={[styles.guestPlanPrice, { color: colors.text }]}>{yearlyPrice}</Text>
|
<View style={styles.priceRow}>
|
||||||
<Text style={[styles.guestPlanRenew, { color: colors.textMuted }]}>{copy.autoRenewYearly}</Text>
|
<Text style={[styles.guestPlanPrice, { color: colors.text }]}>{yearlyPrice}</Text>
|
||||||
<View style={{ gap: 4, marginTop: 8 }}>
|
<Text style={[styles.planTerm, { color: colors.textMuted }]}>{copy.perYear}</Text>
|
||||||
{copy.proBenefits.map((b, i) => (
|
|
||||||
<View key={i} style={styles.benefitRow}>
|
|
||||||
<Ionicons name="checkmark" size={14} color={colors.primary} />
|
|
||||||
<Text style={[styles.benefitText, { color: colors.textSecondary }]}>{b}</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<Text style={[styles.guestPlanRenew, { color: colors.textMuted }]}>{copy.yearlySubline}</Text>
|
||||||
style={[styles.guestSubscribeBtn, { backgroundColor: colors.primary }]}
|
<View style={[styles.trialCallout, { backgroundColor: colors.primarySoft }]}>
|
||||||
onPress={() => handlePurchase('yearly_pro')}
|
<Ionicons name="sparkles-outline" size={16} color={colors.primary} />
|
||||||
disabled={isUpdating || !storeReady}
|
<Text style={[styles.trialCalloutText, { color: colors.text }]}>{copy.startTrial}</Text>
|
||||||
>
|
</View>
|
||||||
<Text style={styles.manageBtnText}>Subscribe Yearly</Text>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
<TouchableOpacity
|
||||||
|
style={[styles.paywallPlanCardSecondary, { borderColor: colors.border }]}
|
||||||
|
onPress={() => handlePurchase('monthly_pro')}
|
||||||
|
disabled={isUpdating || !storeReady}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
>
|
||||||
|
<View style={styles.planTopRow}>
|
||||||
|
<View>
|
||||||
|
<Text style={[styles.guestPlanName, { color: colors.text }]}>Monatlich</Text>
|
||||||
|
<Text style={[styles.planSubline, { color: colors.textMuted }]}>{copy.monthlySubline}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.secondaryBadge, { borderColor: colors.borderStrong }]}>
|
||||||
|
<Text style={[styles.secondaryBadgeText, { color: colors.textSecondary }]}>{copy.monthlyBadge}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.priceRow}>
|
||||||
|
<Text style={[styles.guestPlanPrice, { color: colors.text }]}>{monthlyPrice}</Text>
|
||||||
|
<Text style={[styles.planTerm, { color: colors.textMuted }]}>{copy.perMonth}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.monthlyCtaText, { color: colors.primary }]}>{copy.monthlyCta}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
<View style={[styles.legalLinksRow, { marginTop: 16 }]}>
|
<View style={[styles.legalLinksRow, { marginTop: 16 }]}>
|
||||||
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}>
|
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}>
|
||||||
@@ -512,8 +633,10 @@ export default function BillingScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{session && planId === 'pro' && !isExpoGo ? (
|
||||||
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
|
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
|
||||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>{copy.topupTitle}</Text>
|
<Text style={[styles.sectionTitle, { color: colors.text }]}>{copy.topupTitle}</Text>
|
||||||
|
<Text style={[styles.modalHint, { color: colors.text + '80', marginBottom: 8 }]}>{copy.topupHint}</Text>
|
||||||
<View style={{ gap: 10, marginTop: 8 }}>
|
<View style={{ gap: 10, marginTop: 8 }}>
|
||||||
{([
|
{([
|
||||||
{ id: 'topup_small' as PurchaseProductId, label: topupLabels.topup_small },
|
{ id: 'topup_small' as PurchaseProductId, label: topupLabels.topup_small },
|
||||||
@@ -564,6 +687,7 @@ export default function BillingScreen() {
|
|||||||
<Text style={[styles.legalLink, { color: colors.textMuted }]}>{copy.restorePurchases}</Text>
|
<Text style={[styles.legalLink, { color: colors.textMuted }]}>{copy.restorePurchases}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@@ -783,6 +907,31 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
|
paywallTitle: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '800',
|
||||||
|
lineHeight: 30,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
paywallValueRows: {
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 14,
|
||||||
|
},
|
||||||
|
paywallValueRow: {
|
||||||
|
minHeight: 42,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 9,
|
||||||
|
},
|
||||||
|
paywallValueText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '700',
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
topupBtn: {
|
topupBtn: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -945,23 +1094,87 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
padding: 16,
|
padding: 16,
|
||||||
},
|
},
|
||||||
|
paywallPlanCardPrimary: {
|
||||||
|
borderWidth: 2,
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 16,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
paywallPlanCardSecondary: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 14,
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
guestPlanHeader: {
|
guestPlanHeader: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
},
|
},
|
||||||
|
planTopRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 10,
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
guestPlanName: {
|
guestPlanName: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
|
planSubline: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
priceRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
guestPlanPrice: {
|
guestPlanPrice: {
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
marginBottom: 2,
|
marginBottom: 2,
|
||||||
},
|
},
|
||||||
|
planTerm: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
guestPlanRenew: {
|
guestPlanRenew: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
lineHeight: 17,
|
||||||
|
},
|
||||||
|
trialCallout: {
|
||||||
|
minHeight: 46,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 14,
|
||||||
|
},
|
||||||
|
trialCalloutText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
secondaryBadge: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
},
|
||||||
|
secondaryBadgeText: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
monthlyCtaText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '800',
|
||||||
|
marginTop: 8,
|
||||||
},
|
},
|
||||||
guestSubscribeBtn: {
|
guestSubscribeBtn: {
|
||||||
marginTop: 14,
|
marginTop: 14,
|
||||||
|
|||||||
277
app/scanner.tsx
277
app/scanner.tsx
@@ -9,6 +9,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
import * as ImageManipulator from 'expo-image-manipulator';
|
import * as ImageManipulator from 'expo-image-manipulator';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import * as AppleAuthentication from 'expo-apple-authentication';
|
||||||
import { usePostHog } from 'posthog-react-native';
|
import { usePostHog } from 'posthog-react-native';
|
||||||
import { useApp } from '../context/AppContext';
|
import { useApp } from '../context/AppContext';
|
||||||
import { useColors } from '../constants/Colors';
|
import { useColors } from '../constants/Colors';
|
||||||
@@ -18,6 +19,7 @@ import { ResultCard } from '../components/ResultCard';
|
|||||||
import { backendApiClient, isInsufficientCreditsError, isNetworkError, isTimeoutError } from '../services/backend/backendApiClient';
|
import { backendApiClient, isInsufficientCreditsError, isNetworkError, isTimeoutError } from '../services/backend/backendApiClient';
|
||||||
import { isBackendApiError } from '../services/backend/contracts';
|
import { isBackendApiError } from '../services/backend/contracts';
|
||||||
import { createIdempotencyKey } from '../utils/idempotency';
|
import { createIdempotencyKey } from '../utils/idempotency';
|
||||||
|
import { AuthService } from '../services/authService';
|
||||||
|
|
||||||
const HEALTH_CHECK_CREDIT_COST = 2;
|
const HEALTH_CHECK_CREDIT_COST = 2;
|
||||||
|
|
||||||
@@ -45,6 +47,11 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
|
|||||||
healthDoneTitle: 'Health Check abgeschlossen',
|
healthDoneTitle: 'Health Check abgeschlossen',
|
||||||
healthDoneMessage: 'Neues Foto wurde geprueft und zur Galerie hinzugefuegt.',
|
healthDoneMessage: 'Neues Foto wurde geprueft und zur Galerie hinzugefuegt.',
|
||||||
signupLabel: 'Registrieren',
|
signupLabel: 'Registrieren',
|
||||||
|
demoTitle: 'Rettungsplan bereit',
|
||||||
|
demoMessage: 'Wir haben mögliche Ursachen erkannt. Schalte die vollständige KI-Diagnose und deinen 7-Tage-Rettungsplan frei.',
|
||||||
|
appleCta: 'Mit Apple fortfahren',
|
||||||
|
emailCta: 'Mit E-Mail fortfahren',
|
||||||
|
unlockCta: 'Vollständige Diagnose freischalten',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +78,11 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
|
|||||||
healthDoneTitle: 'Health-check completado',
|
healthDoneTitle: 'Health-check completado',
|
||||||
healthDoneMessage: 'La foto nueva fue analizada y guardada en la galeria.',
|
healthDoneMessage: 'La foto nueva fue analizada y guardada en la galeria.',
|
||||||
signupLabel: 'Registrarse',
|
signupLabel: 'Registrarse',
|
||||||
|
demoTitle: 'Plan de rescate listo',
|
||||||
|
demoMessage: 'Detectamos posibles causas. Desbloquea el diagnóstico completo con IA y tu plan de rescate de 7 días.',
|
||||||
|
appleCta: 'Continuar con Apple',
|
||||||
|
emailCta: 'Continuar con email',
|
||||||
|
unlockCta: 'Desbloquear diagnóstico completo',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +108,11 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
|
|||||||
healthDoneTitle: 'Health Check Complete',
|
healthDoneTitle: 'Health Check Complete',
|
||||||
healthDoneMessage: 'The new photo was analyzed and added to gallery.',
|
healthDoneMessage: 'The new photo was analyzed and added to gallery.',
|
||||||
signupLabel: 'Sign Up',
|
signupLabel: 'Sign Up',
|
||||||
|
demoTitle: 'Rescue plan ready',
|
||||||
|
demoMessage: 'We found possible causes. Unlock the full AI diagnosis and your 7-day rescue plan.',
|
||||||
|
appleCta: 'Continue with Apple',
|
||||||
|
emailCta: 'Continue with email',
|
||||||
|
unlockCta: 'Unlock full diagnosis',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -114,9 +131,8 @@ export default function ScannerScreen() {
|
|||||||
refreshBillingSummary,
|
refreshBillingSummary,
|
||||||
isLoadingBilling,
|
isLoadingBilling,
|
||||||
session,
|
session,
|
||||||
|
hydrateSession,
|
||||||
setPendingPlant,
|
setPendingPlant,
|
||||||
guestScanCount,
|
|
||||||
incrementGuestScanCount,
|
|
||||||
} = useApp();
|
} = useApp();
|
||||||
const colors = useColors(isDarkMode, colorPalette);
|
const colors = useColors(isDarkMode, colorPalette);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -127,19 +143,37 @@ export default function ScannerScreen() {
|
|||||||
const healthPlant = isHealthMode && healthPlantId
|
const healthPlant = isHealthMode && healthPlantId
|
||||||
? plants.find((item) => item.id === healthPlantId)
|
? plants.find((item) => item.id === healthPlantId)
|
||||||
: null;
|
: null;
|
||||||
const availableCredits = session
|
const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro'
|
||||||
? (billingSummary?.credits.available ?? 0)
|
&& billingSummary?.entitlement?.status === 'active';
|
||||||
: Math.max(0, 5 - guestScanCount);
|
const isDemoMode = !hasActiveEntitlement;
|
||||||
|
const availableCredits = hasActiveEntitlement ? (billingSummary?.credits.available ?? 0) : 0;
|
||||||
|
|
||||||
const [permission, requestPermission] = useCameraPermissions();
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||||
|
const [isAuthLoading, setIsAuthLoading] = useState(false);
|
||||||
|
const [appleAvailable, setAppleAvailable] = useState(false);
|
||||||
const [analysisProgress, setAnalysisProgress] = useState(0);
|
const [analysisProgress, setAnalysisProgress] = useState(0);
|
||||||
const [analysisResult, setAnalysisResult] = useState<IdentificationResult | null>(null);
|
const [analysisResult, setAnalysisResult] = useState<IdentificationResult | null>(null);
|
||||||
|
const [demoResultVisible, setDemoResultVisible] = useState(false);
|
||||||
const cameraRef = useRef<CameraView>(null);
|
const cameraRef = useRef<CameraView>(null);
|
||||||
const scanLineProgress = useRef(new Animated.Value(0)).current;
|
const scanLineProgress = useRef(new Animated.Value(0)).current;
|
||||||
const scanPulse = useRef(new Animated.Value(0)).current;
|
const scanPulse = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
AppleAuthentication.isAvailableAsync()
|
||||||
|
.then((available) => {
|
||||||
|
if (mounted) setAppleAvailable(available);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (mounted) setAppleAvailable(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAnalyzing) {
|
if (!isAnalyzing) {
|
||||||
scanLineProgress.stopAnimation();
|
scanLineProgress.stopAnimation();
|
||||||
@@ -199,12 +233,7 @@ export default function ScannerScreen() {
|
|||||||
const analyzeImage = async (imageUri: string, galleryImageUri?: string) => {
|
const analyzeImage = async (imageUri: string, galleryImageUri?: string) => {
|
||||||
if (isAnalyzing) return;
|
if (isAnalyzing) return;
|
||||||
|
|
||||||
if (availableCredits <= 0) {
|
if (!isDemoMode && availableCredits <= 0) {
|
||||||
if (!session) {
|
|
||||||
// Guest: show paywall directly — no registration required to purchase
|
|
||||||
router.push('/profile/billing');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
billingCopy.noCreditsTitle,
|
billingCopy.noCreditsTitle,
|
||||||
isHealthMode ? billingCopy.healthNoCreditsMessage : billingCopy.noCreditsMessage,
|
isHealthMode ? billingCopy.healthNoCreditsMessage : billingCopy.noCreditsMessage,
|
||||||
@@ -212,7 +241,7 @@ export default function ScannerScreen() {
|
|||||||
{ text: billingCopy.dismiss, style: 'cancel' },
|
{ text: billingCopy.dismiss, style: 'cancel' },
|
||||||
{
|
{
|
||||||
text: billingCopy.managePlan,
|
text: billingCopy.managePlan,
|
||||||
onPress: () => router.replace('/(tabs)/profile'),
|
onPress: () => router.replace('/profile/billing'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -222,6 +251,7 @@ export default function ScannerScreen() {
|
|||||||
setIsAnalyzing(true);
|
setIsAnalyzing(true);
|
||||||
setAnalysisProgress(0);
|
setAnalysisProgress(0);
|
||||||
setAnalysisResult(null);
|
setAnalysisResult(null);
|
||||||
|
setDemoResultVisible(false);
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
@@ -235,6 +265,28 @@ export default function ScannerScreen() {
|
|||||||
}, 150);
|
}, 150);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (isDemoMode) {
|
||||||
|
posthog.capture('demo_scan_started', {
|
||||||
|
authenticated: Boolean(session),
|
||||||
|
scan_type: isHealthMode ? 'health_check' : 'identification',
|
||||||
|
});
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2100));
|
||||||
|
setAnalysisProgress(100);
|
||||||
|
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 350));
|
||||||
|
setDemoResultVisible(true);
|
||||||
|
posthog.capture('demo_scan_completed', {
|
||||||
|
authenticated: Boolean(session),
|
||||||
|
latency_ms: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
posthog.capture('paid_scan_started', {
|
||||||
|
scan_type: isHealthMode ? 'health_check' : 'identification',
|
||||||
|
credits_available: availableCredits,
|
||||||
|
});
|
||||||
|
|
||||||
if (isHealthMode) {
|
if (isHealthMode) {
|
||||||
if (!healthPlant) {
|
if (!healthPlant) {
|
||||||
Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage);
|
Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage);
|
||||||
@@ -261,10 +313,6 @@ export default function ScannerScreen() {
|
|||||||
latency_ms: Date.now() - startTime,
|
latency_ms: Date.now() - startTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
incrementGuestScanCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentGallery = healthPlant.gallery || [];
|
const currentGallery = healthPlant.gallery || [];
|
||||||
const existingChecks = healthPlant.healthChecks || [];
|
const existingChecks = healthPlant.healthChecks || [];
|
||||||
const updatedChecks = [response.healthCheck, ...existingChecks].slice(0, 6);
|
const updatedChecks = [response.healthCheck, ...existingChecks].slice(0, 6);
|
||||||
@@ -285,14 +333,14 @@ export default function ScannerScreen() {
|
|||||||
latency_ms: Date.now() - startTime,
|
latency_ms: Date.now() - startTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
incrementGuestScanCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
setAnalysisResult(result);
|
setAnalysisResult(result);
|
||||||
}
|
}
|
||||||
setAnalysisProgress(100);
|
setAnalysisProgress(100);
|
||||||
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
posthog.capture('paid_scan_completed', {
|
||||||
|
scan_type: isHealthMode ? 'health_check' : 'identification',
|
||||||
|
latency_ms: Date.now() - startTime,
|
||||||
|
});
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
setIsAnalyzing(false);
|
setIsAnalyzing(false);
|
||||||
if (isHealthMode && healthPlant) {
|
if (isHealthMode && healthPlant) {
|
||||||
@@ -318,7 +366,7 @@ export default function ScannerScreen() {
|
|||||||
{ text: billingCopy.dismiss, style: 'cancel' },
|
{ text: billingCopy.dismiss, style: 'cancel' },
|
||||||
{
|
{
|
||||||
text: billingCopy.managePlan,
|
text: billingCopy.managePlan,
|
||||||
onPress: () => router.replace('/(tabs)/profile'),
|
onPress: () => router.replace('/profile/billing'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -362,7 +410,10 @@ export default function ScannerScreen() {
|
|||||||
setIsAnalyzing(false);
|
setIsAnalyzing(false);
|
||||||
} finally {
|
} finally {
|
||||||
clearInterval(progressInterval);
|
clearInterval(progressInterval);
|
||||||
await refreshBillingSummary();
|
setIsAnalyzing(false);
|
||||||
|
if (!isDemoMode) {
|
||||||
|
await refreshBillingSummary();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -372,6 +423,7 @@ export default function ScannerScreen() {
|
|||||||
const photo = await cameraRef.current.takePictureAsync({ base64: false, quality: 0.9 });
|
const photo = await cameraRef.current.takePictureAsync({ base64: false, quality: 0.9 });
|
||||||
if (photo) {
|
if (photo) {
|
||||||
const analysisUri = await resizeForAnalysis(photo.uri);
|
const analysisUri = await resizeForAnalysis(photo.uri);
|
||||||
|
setDemoResultVisible(false);
|
||||||
setSelectedImage(analysisUri);
|
setSelectedImage(analysisUri);
|
||||||
analyzeImage(analysisUri, photo.uri);
|
analyzeImage(analysisUri, photo.uri);
|
||||||
}
|
}
|
||||||
@@ -388,6 +440,7 @@ export default function ScannerScreen() {
|
|||||||
if (!result.canceled && result.assets[0]) {
|
if (!result.canceled && result.assets[0]) {
|
||||||
const asset = result.assets[0];
|
const asset = result.assets[0];
|
||||||
const analysisUri = await resizeForAnalysis(asset.uri);
|
const analysisUri = await resizeForAnalysis(asset.uri);
|
||||||
|
setDemoResultVisible(false);
|
||||||
setSelectedImage(asset.uri);
|
setSelectedImage(asset.uri);
|
||||||
analyzeImage(analysisUri, asset.uri);
|
analyzeImage(analysisUri, asset.uri);
|
||||||
}
|
}
|
||||||
@@ -412,6 +465,68 @@ export default function ScannerScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const routeToHardPaywall = () => {
|
||||||
|
posthog.capture('auth_prompt_shown', {
|
||||||
|
authenticated: Boolean(session),
|
||||||
|
surface: 'demo_scan_result',
|
||||||
|
});
|
||||||
|
if (session) {
|
||||||
|
router.replace('/profile/billing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.replace('/auth/signup');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDemoAppleSignIn = async () => {
|
||||||
|
if (!appleAvailable) {
|
||||||
|
routeToHardPaywall();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAuthLoading(true);
|
||||||
|
posthog.capture('apple_login_started', { surface: 'scanner_demo' });
|
||||||
|
try {
|
||||||
|
const credential = await AppleAuthentication.signInAsync({
|
||||||
|
requestedScopes: [
|
||||||
|
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||||||
|
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (!credential.identityToken) {
|
||||||
|
throw new Error('APPLE_AUTH_INVALID');
|
||||||
|
}
|
||||||
|
const fullName = [
|
||||||
|
credential.fullName?.givenName,
|
||||||
|
credential.fullName?.familyName,
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
const nextSession = await AuthService.signInWithApple({
|
||||||
|
identityToken: credential.identityToken,
|
||||||
|
appleUser: credential.user,
|
||||||
|
email: credential.email,
|
||||||
|
name: fullName || undefined,
|
||||||
|
});
|
||||||
|
await hydrateSession(nextSession);
|
||||||
|
posthog.capture('apple_login_succeeded', { surface: 'scanner_demo' });
|
||||||
|
router.replace('/profile/billing');
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'ERR_REQUEST_CANCELED') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
posthog.capture('apple_login_failed', {
|
||||||
|
surface: 'scanner_demo',
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
Alert.alert(
|
||||||
|
billingCopy.genericErrorTitle,
|
||||||
|
error instanceof Error && error.message === 'APPLE_BACKEND_UNAVAILABLE'
|
||||||
|
? 'Apple Login ist auf dem Backend noch nicht aktiviert. Bitte Backend neu starten oder deployen.'
|
||||||
|
: billingCopy.genericErrorMessage,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsAuthLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
router.back();
|
router.back();
|
||||||
};
|
};
|
||||||
@@ -472,9 +587,9 @@ export default function ScannerScreen() {
|
|||||||
{isHealthMode ? billingCopy.healthTitle : t.scanner}
|
{isHealthMode ? billingCopy.healthTitle : t.scanner}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={[styles.creditBadge, { backgroundColor: colors.heroButton, borderColor: colors.heroButtonBorder }]}>
|
<View style={[styles.creditBadge, { backgroundColor: colors.heroButton, borderColor: colors.heroButtonBorder }]}>
|
||||||
<Ionicons name="wallet-outline" size={12} color={colors.text} />
|
<Ionicons name={isDemoMode ? 'sparkles-outline' : 'wallet-outline'} size={12} color={colors.text} />
|
||||||
<Text style={[styles.creditBadgeText, { color: colors.text }]}>
|
<Text style={[styles.creditBadgeText, { color: colors.text }]}>
|
||||||
{billingCopy.creditsLabel}: {availableCredits}
|
{isDemoMode ? 'Demo' : `${billingCopy.creditsLabel}: ${availableCredits}`}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -562,6 +677,61 @@ export default function ScannerScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{demoResultVisible && !isAnalyzing ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.demoSheet,
|
||||||
|
{
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
borderColor: colors.border,
|
||||||
|
bottom: analysisBottomOffset,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={[styles.demoIconWrap, { backgroundColor: colors.primarySoft }]}>
|
||||||
|
<Ionicons name="sparkles" size={22} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.demoTitle, { color: colors.text }]}>{billingCopy.demoTitle}</Text>
|
||||||
|
<Text style={[styles.demoMessage, { color: colors.textSecondary }]}>{billingCopy.demoMessage}</Text>
|
||||||
|
|
||||||
|
{!session && appleAvailable ? (
|
||||||
|
<AppleAuthentication.AppleAuthenticationButton
|
||||||
|
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
|
||||||
|
buttonStyle={isDarkMode
|
||||||
|
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
|
||||||
|
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
|
||||||
|
cornerRadius={12}
|
||||||
|
style={styles.demoAppleButton}
|
||||||
|
onPress={handleDemoAppleSignIn}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.demoPrimaryBtn, { backgroundColor: colors.primary }]}
|
||||||
|
onPress={session ? routeToHardPaywall : handleDemoAppleSignIn}
|
||||||
|
disabled={isAuthLoading}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
<Text style={[styles.demoPrimaryText, { color: colors.onPrimary }]}>
|
||||||
|
{isAuthLoading ? '...' : session ? billingCopy.unlockCta : billingCopy.appleCta}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!session ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.demoSecondaryBtn, { borderColor: colors.borderStrong }]}
|
||||||
|
onPress={() => {
|
||||||
|
posthog.capture('auth_prompt_shown', { surface: 'demo_scan_result', method: 'email' });
|
||||||
|
router.replace('/auth/signup');
|
||||||
|
}}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
<Text style={[styles.demoSecondaryText, { color: colors.text }]}>{billingCopy.emailCta}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Bottom Controls */}
|
{/* Bottom Controls */}
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -701,6 +871,65 @@ const styles = StyleSheet.create({
|
|||||||
shadowRadius: 14,
|
shadowRadius: 14,
|
||||||
elevation: 14,
|
elevation: 14,
|
||||||
},
|
},
|
||||||
|
demoSheet: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
borderRadius: 22,
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 18,
|
||||||
|
zIndex: 25,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.24,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 12,
|
||||||
|
},
|
||||||
|
demoIconWrap: {
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
borderRadius: 21,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
demoTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '800',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
demoMessage: {
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
marginBottom: 14,
|
||||||
|
},
|
||||||
|
demoAppleButton: {
|
||||||
|
width: '100%',
|
||||||
|
height: 50,
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
demoPrimaryBtn: {
|
||||||
|
height: 50,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
demoPrimaryText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
demoSecondaryBtn: {
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
demoSecondaryText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
analysisHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 },
|
analysisHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 },
|
||||||
analysisBadge: {
|
analysisBadge: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"@google/genai": "^1.38.0",
|
"@google/genai": "^1.38.0",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"expo": "^54.0.33",
|
"expo": "^54.0.33",
|
||||||
|
"expo-apple-authentication": "~8.0.8",
|
||||||
"expo-application": "~7.0.8",
|
"expo-application": "~7.0.8",
|
||||||
"expo-asset": "~12.0.12",
|
"expo-asset": "~12.0.12",
|
||||||
"expo-av": "^16.0.8",
|
"expo-av": "^16.0.8",
|
||||||
@@ -6195,6 +6196,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-apple-authentication": {
|
||||||
|
"version": "8.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-apple-authentication/-/expo-apple-authentication-8.0.8.tgz",
|
||||||
|
"integrity": "sha512-TwCHWXYR1kS0zaeV7QZKLWYluxsvqL31LFJubzK30njZqeWoWO89HZ8nZVaeXbFV1LrArKsze4BmMb+94wS0AQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-application": {
|
"node_modules/expo-application": {
|
||||||
"version": "7.0.8",
|
"version": "7.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"@google/genai": "^1.38.0",
|
"@google/genai": "^1.38.0",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"expo": "^54.0.33",
|
"expo": "^54.0.33",
|
||||||
|
"expo-apple-authentication": "~8.0.8",
|
||||||
"expo-application": "~7.0.8",
|
"expo-application": "~7.0.8",
|
||||||
"expo-asset": "~12.0.12",
|
"expo-asset": "~12.0.12",
|
||||||
"expo-av": "^16.0.8",
|
"expo-av": "^16.0.8",
|
||||||
|
|||||||
@@ -26,7 +26,14 @@ loadEnvFiles([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const { closeDatabase, getDefaultDbPath, openDatabase, get } = require('./lib/postgres');
|
const { closeDatabase, getDefaultDbPath, openDatabase, get } = require('./lib/postgres');
|
||||||
const { ensureAuthSchema, signUp: authSignUp, login: authLogin, issueToken, verifyJwt } = require('./lib/auth');
|
const {
|
||||||
|
ensureAuthSchema,
|
||||||
|
signUp: authSignUp,
|
||||||
|
login: authLogin,
|
||||||
|
signInWithApple: authSignInWithApple,
|
||||||
|
issueToken,
|
||||||
|
verifyJwt,
|
||||||
|
} = require('./lib/auth');
|
||||||
const {
|
const {
|
||||||
PlantImportValidationError,
|
PlantImportValidationError,
|
||||||
ensurePlantSchema,
|
ensurePlantSchema,
|
||||||
@@ -174,6 +181,20 @@ const resolveIdempotencyKey = (request) => {
|
|||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createHardPaywallError = (requiredCredits) => {
|
||||||
|
const error = new Error('Active Pro or trial entitlement required.');
|
||||||
|
error.code = 'INSUFFICIENT_CREDITS';
|
||||||
|
error.status = 402;
|
||||||
|
error.metadata = { required: requiredCredits, available: 0 };
|
||||||
|
return error;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureActiveProEntitlement = (accountSnapshot, requiredCredits) => {
|
||||||
|
if (!accountSnapshot || accountSnapshot.plan !== 'pro') {
|
||||||
|
throw createHardPaywallError(requiredCredits);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const toPlantResult = (entry, confidence) => {
|
const toPlantResult = (entry, confidence) => {
|
||||||
return {
|
return {
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
@@ -253,6 +274,20 @@ const toApiErrorPayload = (error) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
error
|
||||||
|
&& typeof error === 'object'
|
||||||
|
&& Number.isInteger(error.status)
|
||||||
|
&& error.status >= 400
|
||||||
|
&& error.status < 500
|
||||||
|
&& typeof error.code === 'string'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
status: error.status,
|
||||||
|
body: { code: error.code, message: error.message || 'Request failed.' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (error && typeof error === 'object' && error.code === 'PROVIDER_ERROR') {
|
if (error && typeof error === 'object' && error.code === 'PROVIDER_ERROR') {
|
||||||
return {
|
return {
|
||||||
status: 502,
|
status: 502,
|
||||||
@@ -489,6 +524,7 @@ app.get('/', (_request, response) => {
|
|||||||
'POST /api/plants/rebuild',
|
'POST /api/plants/rebuild',
|
||||||
'POST /auth/signup',
|
'POST /auth/signup',
|
||||||
'POST /auth/login',
|
'POST /auth/login',
|
||||||
|
'POST /auth/apple',
|
||||||
'GET /v1/billing/summary',
|
'GET /v1/billing/summary',
|
||||||
'POST /v1/billing/sync-revenuecat',
|
'POST /v1/billing/sync-revenuecat',
|
||||||
'POST /v1/scan',
|
'POST /v1/scan',
|
||||||
@@ -642,14 +678,17 @@ app.post('/v1/scan', async (request, response) => {
|
|||||||
let modelUsed = null;
|
let modelUsed = null;
|
||||||
let modelFallbackCount = 0;
|
let modelFallbackCount = 0;
|
||||||
|
|
||||||
const [creditResult, accountSnapshot, catalogEntries] = await Promise.all([
|
const [accountSnapshot, catalogEntries] = await Promise.all([
|
||||||
isGuest(userId)
|
|
||||||
? Promise.resolve(0)
|
|
||||||
: consumeCreditsWithIdempotency(db, userId, chargeKey('scan-primary', userId, idempotencyKey), SCAN_PRIMARY_COST),
|
|
||||||
getAccountSnapshot(db, userId),
|
getAccountSnapshot(db, userId),
|
||||||
getCachedCatalogEntries(db),
|
getCachedCatalogEntries(db),
|
||||||
]);
|
]);
|
||||||
creditsCharged += creditResult;
|
ensureActiveProEntitlement(accountSnapshot, SCAN_PRIMARY_COST);
|
||||||
|
creditsCharged += await consumeCreditsWithIdempotency(
|
||||||
|
db,
|
||||||
|
userId,
|
||||||
|
chargeKey('scan-primary', userId, idempotencyKey),
|
||||||
|
SCAN_PRIMARY_COST,
|
||||||
|
);
|
||||||
|
|
||||||
const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free';
|
const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free';
|
||||||
let result = pickCatalogFallback(catalogEntries, imageUri, false, { silent: true });
|
let result = pickCatalogFallback(catalogEntries, imageUri, false, { silent: true });
|
||||||
@@ -797,6 +836,9 @@ app.post('/v1/search/semantic', async (request, response) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accountSnapshot = await getAccountSnapshot(db, userId);
|
||||||
|
ensureActiveProEntitlement(accountSnapshot, SEMANTIC_SEARCH_COST);
|
||||||
|
|
||||||
const creditsCharged = await consumeCreditsWithIdempotency(
|
const creditsCharged = await consumeCreditsWithIdempotency(
|
||||||
db,
|
db,
|
||||||
userId,
|
userId,
|
||||||
@@ -834,6 +876,9 @@ app.post('/v1/health-check', async (request, response) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accountSnapshot = await getAccountSnapshot(db, userId);
|
||||||
|
ensureActiveProEntitlement(accountSnapshot, HEALTH_CHECK_COST);
|
||||||
|
|
||||||
if (!isOpenAiConfigured()) {
|
if (!isOpenAiConfigured()) {
|
||||||
const error = new Error('OpenAI health check is unavailable. Please configure OPENAI_API_KEY.');
|
const error = new Error('OpenAI health check is unavailable. Please configure OPENAI_API_KEY.');
|
||||||
error.code = 'PROVIDER_ERROR';
|
error.code = 'PROVIDER_ERROR';
|
||||||
@@ -1013,6 +1058,21 @@ app.post('/auth/login', async (request, response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/auth/apple', async (request, response) => {
|
||||||
|
try {
|
||||||
|
const { identityToken, appleUser, email, name } = request.body || {};
|
||||||
|
if (!identityToken) {
|
||||||
|
return response.status(400).json({ code: 'BAD_REQUEST', message: 'identityToken is required.' });
|
||||||
|
}
|
||||||
|
const user = await authSignInWithApple(db, identityToken, { appleUser, email, name });
|
||||||
|
const token = issueToken(user.id, user.email, user.name);
|
||||||
|
response.status(200).json({ userId: user.id, email: user.email, name: user.name, token });
|
||||||
|
} catch (error) {
|
||||||
|
const status = error.status || 500;
|
||||||
|
response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Startup ───────────────────────────────────────────────────────────────
|
// ─── Startup ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ const { get, run } = require('./postgres');
|
|||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'greenlens-dev-secret-change-in-prod';
|
const JWT_SECRET = process.env.JWT_SECRET || 'greenlens-dev-secret-change-in-prod';
|
||||||
const TOKEN_EXPIRY_SECONDS = 365 * 24 * 3600; // 1 year
|
const TOKEN_EXPIRY_SECONDS = 365 * 24 * 3600; // 1 year
|
||||||
|
const APPLE_JWKS_URL = 'https://appleid.apple.com/auth/keys';
|
||||||
|
const APPLE_ISSUER = 'https://appleid.apple.com';
|
||||||
|
const APPLE_AUDIENCE = (
|
||||||
|
process.env.APPLE_CLIENT_ID
|
||||||
|
|| process.env.APPLE_BUNDLE_ID
|
||||||
|
|| process.env.EXPO_PUBLIC_APPLE_CLIENT_ID
|
||||||
|
|| process.env.IOS_BUNDLE_ID
|
||||||
|
|| 'com.greenlens.app'
|
||||||
|
).trim();
|
||||||
|
let appleJwksCache = { keys: [], expiresAt: 0 };
|
||||||
|
|
||||||
// ─── Minimal JWT (HS256, no external deps) ─────────────────────────────────
|
// ─── Minimal JWT (HS256, no external deps) ─────────────────────────────────
|
||||||
|
|
||||||
@@ -50,6 +60,98 @@ const issueToken = (userId, email, name) =>
|
|||||||
const hashPassword = (password) =>
|
const hashPassword = (password) =>
|
||||||
crypto.createHmac('sha256', JWT_SECRET).update(password).digest('hex');
|
crypto.createHmac('sha256', JWT_SECRET).update(password).digest('hex');
|
||||||
|
|
||||||
|
const parseJwtPart = (value) => JSON.parse(b64urlDecode(value));
|
||||||
|
|
||||||
|
const getAppleJwks = async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (appleJwksCache.keys.length > 0 && appleJwksCache.expiresAt > now) {
|
||||||
|
return appleJwksCache.keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(APPLE_JWKS_URL);
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = new Error('Could not load Apple public keys.');
|
||||||
|
error.code = 'APPLE_AUTH_UNAVAILABLE';
|
||||||
|
error.status = 503;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
appleJwksCache = {
|
||||||
|
keys: Array.isArray(payload.keys) ? payload.keys : [],
|
||||||
|
expiresAt: now + 6 * 60 * 60 * 1000,
|
||||||
|
};
|
||||||
|
return appleJwksCache.keys;
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyAppleIdentityToken = async (identityToken) => {
|
||||||
|
if (!identityToken || typeof identityToken !== 'string') {
|
||||||
|
const error = new Error('Apple identityToken is required.');
|
||||||
|
error.code = 'BAD_REQUEST';
|
||||||
|
error.status = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = identityToken.split('.');
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
const error = new Error('Apple identityToken is malformed.');
|
||||||
|
error.code = 'APPLE_AUTH_INVALID';
|
||||||
|
error.status = 401;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [encodedHeader, encodedPayload, encodedSignature] = parts;
|
||||||
|
let header;
|
||||||
|
let claims;
|
||||||
|
try {
|
||||||
|
header = parseJwtPart(encodedHeader);
|
||||||
|
claims = parseJwtPart(encodedPayload);
|
||||||
|
} catch {
|
||||||
|
const error = new Error('Apple identityToken is malformed.');
|
||||||
|
error.code = 'APPLE_AUTH_INVALID';
|
||||||
|
error.status = 401;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (header.alg !== 'RS256' || !header.kid) {
|
||||||
|
const error = new Error('Apple identityToken has an unsupported signature.');
|
||||||
|
error.code = 'APPLE_AUTH_INVALID';
|
||||||
|
error.status = 401;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = await getAppleJwks();
|
||||||
|
const jwk = keys.find((key) => key.kid === header.kid);
|
||||||
|
if (!jwk) {
|
||||||
|
const error = new Error('Apple public key not found.');
|
||||||
|
error.code = 'APPLE_AUTH_INVALID';
|
||||||
|
error.status = 401;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifier = crypto.createVerify('RSA-SHA256');
|
||||||
|
verifier.update(`${encodedHeader}.${encodedPayload}`);
|
||||||
|
verifier.end();
|
||||||
|
const publicKey = crypto.createPublicKey({ key: jwk, format: 'jwk' });
|
||||||
|
const validSignature = verifier.verify(publicKey, Buffer.from(encodedSignature, 'base64url'));
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
const expectedAudiences = new Set([APPLE_AUDIENCE, 'com.greenlens.app'].filter(Boolean));
|
||||||
|
|
||||||
|
if (
|
||||||
|
!validSignature
|
||||||
|
|| claims.iss !== APPLE_ISSUER
|
||||||
|
|| !expectedAudiences.has(claims.aud)
|
||||||
|
|| !claims.sub
|
||||||
|
|| (claims.exp && nowSeconds > Number(claims.exp))
|
||||||
|
) {
|
||||||
|
const error = new Error('Apple identityToken could not be verified.');
|
||||||
|
error.code = 'APPLE_AUTH_INVALID';
|
||||||
|
error.status = 401;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims;
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Schema ────────────────────────────────────────────────────────────────
|
// ─── Schema ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const ensureAuthSchema = async (db) => {
|
const ensureAuthSchema = async (db) => {
|
||||||
@@ -59,10 +161,22 @@ const ensureAuthSchema = async (db) => {
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
email TEXT NOT NULL UNIQUE,
|
email TEXT NOT NULL UNIQUE,
|
||||||
name TEXT NOT NULL DEFAULT '',
|
name TEXT NOT NULL DEFAULT '',
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT,
|
||||||
|
auth_provider TEXT NOT NULL DEFAULT 'email',
|
||||||
|
apple_subject TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
)`,
|
)`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await run(db, "ALTER TABLE auth_users ADD COLUMN IF NOT EXISTS auth_provider TEXT NOT NULL DEFAULT 'email'");
|
||||||
|
await run(db, 'ALTER TABLE auth_users ADD COLUMN IF NOT EXISTS apple_subject TEXT');
|
||||||
|
await run(db, 'ALTER TABLE auth_users ALTER COLUMN password_hash DROP NOT NULL');
|
||||||
|
await run(
|
||||||
|
db,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_users_apple_subject
|
||||||
|
ON auth_users (apple_subject)
|
||||||
|
WHERE apple_subject IS NOT NULL`,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Operations ───────────────────────────────────────────────────────────
|
// ─── Operations ───────────────────────────────────────────────────────────
|
||||||
@@ -99,6 +213,12 @@ const login = async (db, email, password) => {
|
|||||||
err.status = 401;
|
err.status = 401;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
if (!user.password_hash) {
|
||||||
|
const err = new Error('This account uses Apple Sign-In.');
|
||||||
|
err.code = 'USE_APPLE_LOGIN';
|
||||||
|
err.status = 401;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
if (user.password_hash !== hashPassword(password)) {
|
if (user.password_hash !== hashPassword(password)) {
|
||||||
const err = new Error('Wrong password.');
|
const err = new Error('Wrong password.');
|
||||||
err.code = 'WRONG_PASSWORD';
|
err.code = 'WRONG_PASSWORD';
|
||||||
@@ -108,4 +228,52 @@ const login = async (db, email, password) => {
|
|||||||
return { id: user.id, email: user.email, name: user.name };
|
return { id: user.id, email: user.email, name: user.name };
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { ensureAuthSchema, signUp, login, issueToken, verifyJwt };
|
const signInWithApple = async (db, identityToken, profile = {}) => {
|
||||||
|
const claims = await verifyAppleIdentityToken(identityToken);
|
||||||
|
const appleSubject = String(claims.sub);
|
||||||
|
const emailFromToken = typeof claims.email === 'string' ? claims.email.trim().toLowerCase() : '';
|
||||||
|
const emailFromProfile = typeof profile.email === 'string' ? profile.email.trim().toLowerCase() : '';
|
||||||
|
const normalizedEmail = emailFromToken || emailFromProfile;
|
||||||
|
const profileName = typeof profile.name === 'string' ? profile.name.trim() : '';
|
||||||
|
|
||||||
|
const existingByApple = await get(
|
||||||
|
db,
|
||||||
|
'SELECT id, email, name FROM auth_users WHERE apple_subject = $1',
|
||||||
|
[appleSubject],
|
||||||
|
);
|
||||||
|
if (existingByApple) return existingByApple;
|
||||||
|
|
||||||
|
if (!normalizedEmail) {
|
||||||
|
const err = new Error('Apple did not return an email for this account.');
|
||||||
|
err.code = 'APPLE_EMAIL_MISSING';
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingByEmail = await get(
|
||||||
|
db,
|
||||||
|
'SELECT id, email, name FROM auth_users WHERE LOWER(email) = LOWER($1)',
|
||||||
|
[normalizedEmail],
|
||||||
|
);
|
||||||
|
if (existingByEmail) {
|
||||||
|
const nextName = existingByEmail.name || profileName || normalizedEmail.split('@')[0] || 'GreenLens User';
|
||||||
|
await run(
|
||||||
|
db,
|
||||||
|
'UPDATE auth_users SET apple_subject = $1, auth_provider = $2, name = $3 WHERE id = $4',
|
||||||
|
[appleSubject, 'apple', nextName, existingByEmail.id],
|
||||||
|
);
|
||||||
|
return { ...existingByEmail, name: nextName };
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = `usr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
const name = profileName || normalizedEmail.split('@')[0] || 'GreenLens User';
|
||||||
|
await run(
|
||||||
|
db,
|
||||||
|
`INSERT INTO auth_users (id, email, name, password_hash, auth_provider, apple_subject)
|
||||||
|
VALUES ($1, $2, $3, NULL, $4, $5)`,
|
||||||
|
[id, normalizedEmail, name, 'apple', appleSubject],
|
||||||
|
);
|
||||||
|
return { id, email: normalizedEmail, name };
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { ensureAuthSchema, signUp, login, signInWithApple, issueToken, verifyJwt, verifyAppleIdentityToken };
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
const { get, run } = require('./postgres');
|
const { get, run } = require('./postgres');
|
||||||
|
|
||||||
const FREE_MONTHLY_CREDITS = 15;
|
const FREE_MONTHLY_CREDITS = 0;
|
||||||
const PRO_MONTHLY_CREDITS = 250;
|
const TRIAL_MONTHLY_CREDITS = 30;
|
||||||
const TOPUP_DEFAULT_CREDITS = 60;
|
const PRO_MONTHLY_CREDITS = 100;
|
||||||
|
const TOPUP_DEFAULT_CREDITS = 100;
|
||||||
const REVENUECAT_PRO_ENTITLEMENT_ID = (process.env.REVENUECAT_PRO_ENTITLEMENT_ID || 'pro').trim() || 'pro';
|
const REVENUECAT_PRO_ENTITLEMENT_ID = (process.env.REVENUECAT_PRO_ENTITLEMENT_ID || 'pro').trim() || 'pro';
|
||||||
const SUPPORTED_SUBSCRIPTION_PRODUCTS = new Set(['monthly_pro', 'yearly_pro']);
|
const SUPPORTED_SUBSCRIPTION_PRODUCTS = new Set(['monthly_pro', 'yearly_pro']);
|
||||||
|
|
||||||
const TOPUP_CREDITS_BY_PRODUCT = {
|
const TOPUP_CREDITS_BY_PRODUCT = {
|
||||||
monthly_pro: 0,
|
monthly_pro: 0,
|
||||||
yearly_pro: 0,
|
yearly_pro: 0,
|
||||||
topup_small: 25,
|
topup_small: 30,
|
||||||
topup_medium: 120,
|
topup_medium: 100,
|
||||||
topup_large: 300,
|
topup_large: 250,
|
||||||
};
|
};
|
||||||
|
|
||||||
const AVAILABLE_PRODUCTS = ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large'];
|
const AVAILABLE_PRODUCTS = ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large'];
|
||||||
@@ -64,6 +65,22 @@ const getMonthlyAllowanceForPlan = (plan) => {
|
|||||||
return plan === 'pro' ? PRO_MONTHLY_CREDITS : FREE_MONTHLY_CREDITS;
|
return plan === 'pro' ? PRO_MONTHLY_CREDITS : FREE_MONTHLY_CREDITS;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getRevenueCatPeriodType = (source) => {
|
||||||
|
return String(source?.periodType || source?.period_type || '').trim().toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRevenueCatTrial = (source) => {
|
||||||
|
return getRevenueCatPeriodType(source) === 'trial';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAllowedMonthlyAllowance = (account) => {
|
||||||
|
if (account.plan === 'pro') {
|
||||||
|
return account.monthlyAllowance === PRO_MONTHLY_CREDITS
|
||||||
|
|| account.monthlyAllowance === TRIAL_MONTHLY_CREDITS;
|
||||||
|
}
|
||||||
|
return account.monthlyAllowance === FREE_MONTHLY_CREDITS;
|
||||||
|
};
|
||||||
|
|
||||||
const createInsufficientCreditsError = (required, available) => {
|
const createInsufficientCreditsError = (required, available) => {
|
||||||
const error = new Error(`Insufficient credits. Required ${required}, available ${available}.`);
|
const error = new Error(`Insufficient credits. Required ${required}, available ${available}.`);
|
||||||
error.code = 'INSUFFICIENT_CREDITS';
|
error.code = 'INSUFFICIENT_CREDITS';
|
||||||
@@ -130,7 +147,7 @@ const buildDefaultAccount = (userId, now) => {
|
|||||||
const alignAccountToCurrentCycle = (account, now) => {
|
const alignAccountToCurrentCycle = (account, now) => {
|
||||||
const next = { ...account };
|
const next = { ...account };
|
||||||
const expectedMonthlyAllowance = getMonthlyAllowanceForPlan(next.plan);
|
const expectedMonthlyAllowance = getMonthlyAllowanceForPlan(next.plan);
|
||||||
if (next.monthlyAllowance !== expectedMonthlyAllowance) {
|
if (!isAllowedMonthlyAllowance(next)) {
|
||||||
next.monthlyAllowance = expectedMonthlyAllowance;
|
next.monthlyAllowance = expectedMonthlyAllowance;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +255,7 @@ const getOrCreateAccount = async (db, userId) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getAvailableCredits = (account) => {
|
const getAvailableCredits = (account) => {
|
||||||
|
if (account.plan !== 'pro') return 0;
|
||||||
const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle);
|
const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle);
|
||||||
return monthlyRemaining + Math.max(0, account.topupBalance);
|
return monthlyRemaining + Math.max(0, account.topupBalance);
|
||||||
};
|
};
|
||||||
@@ -326,14 +344,22 @@ const getValidProEntitlement = (customerInfo) => {
|
|||||||
|
|
||||||
const applyRevenueCatEntitlementState = (account, options) => {
|
const applyRevenueCatEntitlementState = (account, options) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const previousPlan = account.plan;
|
||||||
|
const previousMonthlyAllowance = account.monthlyAllowance;
|
||||||
const nextPlan = options.active ? 'pro' : 'free';
|
const nextPlan = options.active ? 'pro' : 'free';
|
||||||
const planChanged = account.plan !== nextPlan;
|
const nextMonthlyAllowance = options.active && options.isTrial
|
||||||
|
? TRIAL_MONTHLY_CREDITS
|
||||||
|
: getMonthlyAllowanceForPlan(nextPlan);
|
||||||
|
const planChanged = previousPlan !== nextPlan;
|
||||||
|
const trialConvertedToPaid = previousPlan === 'pro'
|
||||||
|
&& previousMonthlyAllowance === TRIAL_MONTHLY_CREDITS
|
||||||
|
&& nextMonthlyAllowance === PRO_MONTHLY_CREDITS;
|
||||||
account.plan = nextPlan;
|
account.plan = nextPlan;
|
||||||
account.provider = 'revenuecat';
|
account.provider = 'revenuecat';
|
||||||
account.monthlyAllowance = getMonthlyAllowanceForPlan(account.plan);
|
account.monthlyAllowance = nextMonthlyAllowance;
|
||||||
account.renewsAt = options.active ? options.renewsAt || account.renewsAt || addDays(now, 30).toISOString() : null;
|
account.renewsAt = options.active ? options.renewsAt || account.renewsAt || addDays(now, 30).toISOString() : null;
|
||||||
|
|
||||||
if (planChanged) {
|
if (planChanged || trialConvertedToPaid) {
|
||||||
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
|
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
|
||||||
account.cycleStartedAt = cycleStartedAt.toISOString();
|
account.cycleStartedAt = cycleStartedAt.toISOString();
|
||||||
account.cycleEndsAt = cycleEndsAt.toISOString();
|
account.cycleEndsAt = cycleEndsAt.toISOString();
|
||||||
@@ -401,6 +427,7 @@ const syncRevenueCatCustomerInfo = async (db, userId, customerInfo, options = {}
|
|||||||
if (source !== 'topup_purchase') {
|
if (source !== 'topup_purchase') {
|
||||||
applyRevenueCatEntitlementState(account, {
|
applyRevenueCatEntitlementState(account, {
|
||||||
active: Boolean(proEntitlement),
|
active: Boolean(proEntitlement),
|
||||||
|
isTrial: isRevenueCatTrial(proEntitlement),
|
||||||
renewsAt: asIsoDate(proEntitlement?.expirationDate || proEntitlement?.expiresDate),
|
renewsAt: asIsoDate(proEntitlement?.expirationDate || proEntitlement?.expiresDate),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -490,6 +517,7 @@ const syncRevenueCatWebhookEvent = async (db, eventPayload) => {
|
|||||||
if (affectsProEntitlement && shouldGrantRevenueCatSubscription(eventType)) {
|
if (affectsProEntitlement && shouldGrantRevenueCatSubscription(eventType)) {
|
||||||
applyRevenueCatEntitlementState(account, {
|
applyRevenueCatEntitlementState(account, {
|
||||||
active: true,
|
active: true,
|
||||||
|
isTrial: isRevenueCatTrial(eventPayload),
|
||||||
renewsAt: asIsoDate(eventPayload?.expiration_at_ms || eventPayload?.expiration_at),
|
renewsAt: asIsoDate(eventPayload?.expiration_at_ms || eventPayload?.expiration_at),
|
||||||
});
|
});
|
||||||
} else if (affectsProEntitlement && shouldRevokeRevenueCatSubscription(eventType)) {
|
} else if (affectsProEntitlement && shouldRevokeRevenueCatSubscription(eventType)) {
|
||||||
@@ -519,6 +547,9 @@ const syncRevenueCatWebhookEvent = async (db, eventPayload) => {
|
|||||||
|
|
||||||
const consumeCredits = (account, cost) => {
|
const consumeCredits = (account, cost) => {
|
||||||
if (cost <= 0) return 0;
|
if (cost <= 0) return 0;
|
||||||
|
if (account.plan !== 'pro') {
|
||||||
|
throw createInsufficientCreditsError(cost, 0);
|
||||||
|
}
|
||||||
|
|
||||||
const available = getAvailableCredits(account);
|
const available = getAvailableCredits(account);
|
||||||
if (available < cost) {
|
if (available < cost) {
|
||||||
@@ -567,12 +598,12 @@ const consumeCreditsWithIdempotency = async (db, userId, key, cost) => {
|
|||||||
const getBillingSummary = async (db, userId) => {
|
const getBillingSummary = async (db, userId) => {
|
||||||
if (userId === 'guest') {
|
if (userId === 'guest') {
|
||||||
return {
|
return {
|
||||||
entitlement: { plan: 'free', provider: 'mock', status: 'active', renewsAt: null },
|
entitlement: { plan: 'free', provider: 'mock', status: 'inactive', renewsAt: null },
|
||||||
credits: {
|
credits: {
|
||||||
monthlyAllowance: 5,
|
monthlyAllowance: 0,
|
||||||
usedThisCycle: 0,
|
usedThisCycle: 0,
|
||||||
topupBalance: 0,
|
topupBalance: 0,
|
||||||
available: 5,
|
available: 0,
|
||||||
cycleStartedAt: nowIso(),
|
cycleStartedAt: nowIso(),
|
||||||
cycleEndsAt: nowIso(),
|
cycleEndsAt: nowIso(),
|
||||||
},
|
},
|
||||||
@@ -595,7 +626,7 @@ const getAccountSnapshot = async (db, userId) => {
|
|||||||
provider: 'mock',
|
provider: 'mock',
|
||||||
cycleStartedAt: nowIso(),
|
cycleStartedAt: nowIso(),
|
||||||
cycleEndsAt: nowIso(),
|
cycleEndsAt: nowIso(),
|
||||||
monthlyAllowance: 5,
|
monthlyAllowance: 0,
|
||||||
usedThisCycle: 0,
|
usedThisCycle: 0,
|
||||||
topupBalance: 0,
|
topupBalance: 0,
|
||||||
renewsAt: null,
|
renewsAt: null,
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ const authPost = async (path: string, body: object): Promise<{ userId: string; e
|
|||||||
}
|
}
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (response.status === 404 && path === '/auth/apple') {
|
||||||
|
throw new Error('APPLE_BACKEND_UNAVAILABLE');
|
||||||
|
}
|
||||||
const code = (data as any).code || 'AUTH_ERROR';
|
const code = (data as any).code || 'AUTH_ERROR';
|
||||||
const msg = (data as any).message || '';
|
const msg = (data as any).message || '';
|
||||||
console.warn(`[Auth] ${path} failed:`, response.status, code, msg);
|
console.warn(`[Auth] ${path} failed:`, response.status, code, msg);
|
||||||
@@ -91,6 +94,18 @@ export const AuthService = {
|
|||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async signInWithApple(params: {
|
||||||
|
identityToken: string;
|
||||||
|
appleUser?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
}): Promise<AuthSession> {
|
||||||
|
const data = await authPost('/auth/apple', params);
|
||||||
|
const session = buildSession(data);
|
||||||
|
await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify(session));
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
|
||||||
async logout(): Promise<void> {
|
async logout(): Promise<void> {
|
||||||
await clearStoredSession();
|
await clearStoredSession();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export interface RevenueCatEntitlementInfo {
|
|||||||
productIdentifier?: string;
|
productIdentifier?: string;
|
||||||
expirationDate?: string | null;
|
expirationDate?: string | null;
|
||||||
expiresDate?: string | null;
|
expiresDate?: string | null;
|
||||||
|
periodType?: string | null;
|
||||||
|
period_type?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RevenueCatNonSubscriptionTransaction {
|
export interface RevenueCatNonSubscriptionTransaction {
|
||||||
|
|||||||
@@ -29,9 +29,10 @@ import { IdentificationResult, PlantHealthCheck } from '../../types';
|
|||||||
const MOCK_ACCOUNT_STORE_KEY = 'greenlens_mock_backend_accounts_v1';
|
const MOCK_ACCOUNT_STORE_KEY = 'greenlens_mock_backend_accounts_v1';
|
||||||
const MOCK_IDEMPOTENCY_STORE_KEY = 'greenlens_mock_backend_idempotency_v1';
|
const MOCK_IDEMPOTENCY_STORE_KEY = 'greenlens_mock_backend_idempotency_v1';
|
||||||
|
|
||||||
const FREE_MONTHLY_CREDITS = 15;
|
const FREE_MONTHLY_CREDITS = 0;
|
||||||
const GUEST_TRIAL_CREDITS = 5;
|
const GUEST_TRIAL_CREDITS = 0;
|
||||||
const PRO_MONTHLY_CREDITS = 250;
|
const TRIAL_MONTHLY_CREDITS = 30;
|
||||||
|
const PRO_MONTHLY_CREDITS = 100;
|
||||||
|
|
||||||
const SCAN_PRIMARY_COST = 1;
|
const SCAN_PRIMARY_COST = 1;
|
||||||
const SCAN_REVIEW_COST = 1;
|
const SCAN_REVIEW_COST = 1;
|
||||||
@@ -42,14 +43,14 @@ const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8;
|
|||||||
const FREE_SIMULATED_DELAY_MS = 1100;
|
const FREE_SIMULATED_DELAY_MS = 1100;
|
||||||
const PRO_SIMULATED_DELAY_MS = 280;
|
const PRO_SIMULATED_DELAY_MS = 280;
|
||||||
|
|
||||||
const TOPUP_DEFAULT_CREDITS = 60;
|
const TOPUP_DEFAULT_CREDITS = 100;
|
||||||
|
|
||||||
const TOPUP_CREDITS_BY_PRODUCT: Record<PurchaseProductId, number> = {
|
const TOPUP_CREDITS_BY_PRODUCT: Record<PurchaseProductId, number> = {
|
||||||
monthly_pro: 0,
|
monthly_pro: 0,
|
||||||
yearly_pro: 0,
|
yearly_pro: 0,
|
||||||
topup_small: 25,
|
topup_small: 30,
|
||||||
topup_medium: 120,
|
topup_medium: 100,
|
||||||
topup_large: 300,
|
topup_large: 250,
|
||||||
};
|
};
|
||||||
|
|
||||||
const REVENUECAT_PRO_ENTITLEMENT_ID = (process.env.EXPO_PUBLIC_REVENUECAT_PRO_ENTITLEMENT_ID || 'pro').trim() || 'pro';
|
const REVENUECAT_PRO_ENTITLEMENT_ID = (process.env.EXPO_PUBLIC_REVENUECAT_PRO_ENTITLEMENT_ID || 'pro').trim() || 'pro';
|
||||||
@@ -105,6 +106,23 @@ const getMonthlyAllowanceForPlan = (plan: PlanId, userId?: string): number => {
|
|||||||
return plan === 'pro' ? PRO_MONTHLY_CREDITS : FREE_MONTHLY_CREDITS;
|
return plan === 'pro' ? PRO_MONTHLY_CREDITS : FREE_MONTHLY_CREDITS;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getRevenueCatPeriodType = (source?: RevenueCatEntitlementInfo | null): string => {
|
||||||
|
return String(source?.periodType || source?.period_type || '').trim().toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRevenueCatTrial = (source?: RevenueCatEntitlementInfo | null): boolean => {
|
||||||
|
return getRevenueCatPeriodType(source) === 'trial';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAllowedMonthlyAllowance = (account: MockAccountRecord): boolean => {
|
||||||
|
if (account.userId === 'guest') return account.monthlyAllowance === GUEST_TRIAL_CREDITS;
|
||||||
|
if (account.plan === 'pro') {
|
||||||
|
return account.monthlyAllowance === PRO_MONTHLY_CREDITS
|
||||||
|
|| account.monthlyAllowance === TRIAL_MONTHLY_CREDITS;
|
||||||
|
}
|
||||||
|
return account.monthlyAllowance === FREE_MONTHLY_CREDITS;
|
||||||
|
};
|
||||||
|
|
||||||
const getSimulatedDelay = (plan: PlanId): number => {
|
const getSimulatedDelay = (plan: PlanId): number => {
|
||||||
return plan === 'pro' ? PRO_SIMULATED_DELAY_MS : FREE_SIMULATED_DELAY_MS;
|
return plan === 'pro' ? PRO_SIMULATED_DELAY_MS : FREE_SIMULATED_DELAY_MS;
|
||||||
};
|
};
|
||||||
@@ -187,7 +205,7 @@ const buildDefaultAccount = (userId: string, now: Date): MockAccountRecord => {
|
|||||||
const alignAccountToCurrentCycle = (account: MockAccountRecord, now: Date): MockAccountRecord => {
|
const alignAccountToCurrentCycle = (account: MockAccountRecord, now: Date): MockAccountRecord => {
|
||||||
const next = { ...account };
|
const next = { ...account };
|
||||||
const expectedMonthlyAllowance = getMonthlyAllowanceForPlan(next.plan, next.userId);
|
const expectedMonthlyAllowance = getMonthlyAllowanceForPlan(next.plan, next.userId);
|
||||||
if (next.monthlyAllowance !== expectedMonthlyAllowance) {
|
if (!isAllowedMonthlyAllowance(next)) {
|
||||||
next.monthlyAllowance = expectedMonthlyAllowance;
|
next.monthlyAllowance = expectedMonthlyAllowance;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +234,7 @@ const getOrCreateAccount = (stores: { accounts: AccountStore }, userId: string):
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getAvailableCredits = (account: MockAccountRecord): number => {
|
const getAvailableCredits = (account: MockAccountRecord): number => {
|
||||||
|
if (account.plan !== 'pro') return 0;
|
||||||
const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle);
|
const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle);
|
||||||
return monthlyRemaining + Math.max(0, account.topupBalance);
|
return monthlyRemaining + Math.max(0, account.topupBalance);
|
||||||
};
|
};
|
||||||
@@ -298,6 +317,14 @@ const writeIdempotentResponse = <T,>(store: IdempotencyStore, key: string, value
|
|||||||
|
|
||||||
const consumeCredits = (account: MockAccountRecord, cost: number): number => {
|
const consumeCredits = (account: MockAccountRecord, cost: number): number => {
|
||||||
if (cost <= 0) return 0;
|
if (cost <= 0) return 0;
|
||||||
|
if (account.plan !== 'pro') {
|
||||||
|
throw new BackendApiError(
|
||||||
|
'INSUFFICIENT_CREDITS',
|
||||||
|
`Insufficient credits. Required ${cost}, available 0.`,
|
||||||
|
402,
|
||||||
|
{ required: cost, available: 0 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const available = getAvailableCredits(account);
|
const available = getAvailableCredits(account);
|
||||||
if (available < cost) {
|
if (available < cost) {
|
||||||
@@ -326,6 +353,16 @@ const consumeCredits = (account: MockAccountRecord, cost: number): number => {
|
|||||||
return cost;
|
return cost;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ensureActiveProEntitlement = (account: MockAccountRecord, requiredCredits: number): void => {
|
||||||
|
if (account.plan === 'pro') return;
|
||||||
|
throw new BackendApiError(
|
||||||
|
'INSUFFICIENT_CREDITS',
|
||||||
|
`Insufficient credits. Required ${requiredCredits}, available 0.`,
|
||||||
|
402,
|
||||||
|
{ required: requiredCredits, available: 0 },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const consumeCreditsWithIdempotency = (
|
const consumeCreditsWithIdempotency = (
|
||||||
account: MockAccountRecord,
|
account: MockAccountRecord,
|
||||||
idempotencyStore: IdempotencyStore,
|
idempotencyStore: IdempotencyStore,
|
||||||
@@ -705,10 +742,29 @@ export const mockBackendService = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (source !== 'topup_purchase') {
|
if (source !== 'topup_purchase') {
|
||||||
account.plan = proEntitlement ? 'pro' : 'free';
|
const now = new Date();
|
||||||
|
const previousPlan = account.plan;
|
||||||
|
const previousMonthlyAllowance = account.monthlyAllowance;
|
||||||
|
const nextPlan = proEntitlement ? 'pro' : 'free';
|
||||||
|
const nextMonthlyAllowance = proEntitlement && isRevenueCatTrial(proEntitlement)
|
||||||
|
? TRIAL_MONTHLY_CREDITS
|
||||||
|
: getMonthlyAllowanceForPlan(nextPlan, account.userId);
|
||||||
|
const planChanged = previousPlan !== nextPlan;
|
||||||
|
const trialConvertedToPaid = previousPlan === 'pro'
|
||||||
|
&& previousMonthlyAllowance === TRIAL_MONTHLY_CREDITS
|
||||||
|
&& nextMonthlyAllowance === PRO_MONTHLY_CREDITS;
|
||||||
|
|
||||||
|
account.plan = nextPlan;
|
||||||
account.provider = 'revenuecat';
|
account.provider = 'revenuecat';
|
||||||
account.monthlyAllowance = getMonthlyAllowanceForPlan(account.plan, account.userId);
|
account.monthlyAllowance = nextMonthlyAllowance;
|
||||||
account.renewsAt = proEntitlement?.expirationDate || proEntitlement?.expiresDate || null;
|
account.renewsAt = proEntitlement?.expirationDate || proEntitlement?.expiresDate || null;
|
||||||
|
|
||||||
|
if (planChanged || trialConvertedToPaid) {
|
||||||
|
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
|
||||||
|
account.cycleStartedAt = cycleStartedAt.toISOString();
|
||||||
|
account.cycleEndsAt = cycleEndsAt.toISOString();
|
||||||
|
account.usedThisCycle = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const transaction of normalizeRevenueCatTransactions(request.customerInfo)) {
|
for (const transaction of normalizeRevenueCatTransactions(request.customerInfo)) {
|
||||||
@@ -920,6 +976,8 @@ export const mockBackendService = {
|
|||||||
throw new BackendApiError('BAD_REQUEST', 'Health check requires an image URI.', 400);
|
throw new BackendApiError('BAD_REQUEST', 'Health check requires an image URI.', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureActiveProEntitlement(account, HEALTH_CHECK_COST);
|
||||||
|
|
||||||
if (!openAiScanService.isConfigured()) {
|
if (!openAiScanService.isConfigured()) {
|
||||||
throw new BackendApiError(
|
throw new BackendApiError(
|
||||||
'PROVIDER_ERROR',
|
'PROVIDER_ERROR',
|
||||||
|
|||||||
Reference in New Issue
Block a user