From 439f5a44c97c539e974d6bc08ae98df2bac46019 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Fri, 3 Apr 2026 22:26:58 +0200 Subject: [PATCH 01/10] feat: implement billing account management and cycle synchronization logic with accompanying tests --- .../billingTimestampNormalization.test.js | 45 ++++++++++++++++ server/lib/billing.js | 51 ++++++++++--------- 2 files changed, 73 insertions(+), 23 deletions(-) create mode 100644 __tests__/server/billingTimestampNormalization.test.js diff --git a/__tests__/server/billingTimestampNormalization.test.js b/__tests__/server/billingTimestampNormalization.test.js new file mode 100644 index 0000000..28d0d69 --- /dev/null +++ b/__tests__/server/billingTimestampNormalization.test.js @@ -0,0 +1,45 @@ +jest.mock('../../server/lib/postgres', () => ({ + get: jest.fn(), + run: jest.fn(), +})); + +const { get, run } = require('../../server/lib/postgres'); +const { syncRevenueCatCustomerInfo } = require('../../server/lib/billing'); + +describe('server billing timestamp normalization', () => { + beforeEach(() => { + jest.clearAllMocks(); + run.mockResolvedValue({ lastId: null, changes: 1, rows: [] }); + }); + + it('upserts ISO timestamps when postgres returns Date objects', async () => { + get.mockResolvedValueOnce({ + userId: 'usr_mnjcdwpo_ax9lf68b', + plan: 'free', + provider: 'revenuecat', + cycleStartedAt: new Date('2026-04-01T00:00:00.000Z'), + cycleEndsAt: new Date('2026-05-01T00:00:00.000Z'), + monthlyAllowance: 15, + usedThisCycle: 0, + topupBalance: 0, + renewsAt: null, + updatedAt: new Date('2026-04-02T12:00:00.000Z'), + }); + + await syncRevenueCatCustomerInfo( + {}, + 'usr_mnjcdwpo_ax9lf68b', + { entitlements: { active: {} }, nonSubscriptions: {} }, + { source: 'topup_purchase' }, + ); + + const upsertCall = run.mock.calls.find(([, sql]) => typeof sql === 'string' && sql.includes('INSERT INTO billing_accounts')); + expect(upsertCall).toBeTruthy(); + + const params = upsertCall[2]; + expect(params[3]).toBe('2026-04-01T00:00:00.000Z'); + expect(params[4]).toBe('2026-05-01T00:00:00.000Z'); + expect(params[3]).not.toContain('Coordinated Universal Time'); + expect(params[4]).not.toContain('Coordinated Universal Time'); + }); +}); diff --git a/server/lib/billing.js b/server/lib/billing.js index 11012e4..16be92e 100644 --- a/server/lib/billing.js +++ b/server/lib/billing.js @@ -18,6 +18,28 @@ const AVAILABLE_PRODUCTS = ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_m const nowIso = () => new Date().toISOString(); +const asIsoDate = (value) => { + if (value == null || value === '') return null; + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value.toISOString(); + } + if (typeof value === 'number' && Number.isFinite(value)) { + return new Date(value).toISOString(); + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return null; + if (/^\d+$/.test(trimmed)) { + return new Date(Number(trimmed)).toISOString(); + } + const parsed = new Date(trimmed); + if (!Number.isNaN(parsed.getTime())) { + return parsed.toISOString(); + } + } + return null; +}; + const startOfUtcMonth = (date) => { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0)); }; @@ -73,17 +95,19 @@ const runInTransaction = async (db, worker) => { const normalizeAccountRow = (row) => { if (!row) return null; + const now = new Date(); + const { cycleStartedAt: defaultCycleStartedAt, cycleEndsAt: defaultCycleEndsAt } = getCycleBounds(now); return { userId: String(row.userId), plan: row.plan === 'pro' ? 'pro' : 'free', provider: typeof row.provider === 'string' && row.provider ? row.provider : 'revenuecat', - cycleStartedAt: String(row.cycleStartedAt), - cycleEndsAt: String(row.cycleEndsAt), + cycleStartedAt: asIsoDate(row.cycleStartedAt) || defaultCycleStartedAt.toISOString(), + cycleEndsAt: asIsoDate(row.cycleEndsAt) || defaultCycleEndsAt.toISOString(), monthlyAllowance: Number(row.monthlyAllowance) || FREE_MONTHLY_CREDITS, usedThisCycle: Number(row.usedThisCycle) || 0, topupBalance: Number(row.topupBalance) || 0, - renewsAt: row.renewsAt ? String(row.renewsAt) : null, - updatedAt: row.updatedAt ? String(row.updatedAt) : nowIso(), + renewsAt: asIsoDate(row.renewsAt), + updatedAt: asIsoDate(row.updatedAt) || now.toISOString(), }; }; @@ -238,25 +262,6 @@ const buildBillingSummary = (account) => { }; }; -const asIsoDate = (value) => { - if (value == null || value === '') return null; - if (typeof value === 'number' && Number.isFinite(value)) { - return new Date(value).toISOString(); - } - if (typeof value === 'string') { - const trimmed = value.trim(); - if (!trimmed) return null; - if (/^\d+$/.test(trimmed)) { - return new Date(Number(trimmed)).toISOString(); - } - const parsed = new Date(trimmed); - if (!Number.isNaN(parsed.getTime())) { - return parsed.toISOString(); - } - } - return null; -}; - const isSupportedTopupProduct = (productId) => { return typeof productId === 'string' && productId.startsWith('topup_') From 363f5f60d1157cb39dab8abd607728e85a610a63 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Sat, 4 Apr 2026 12:15:16 +0200 Subject: [PATCH 02/10] feat: implement billing system with credit tracking and RevenueCat integration --- app/profile/billing.tsx | 1731 ++++++++++++++++++++------------------- server/index.js | 190 ++++- server/lib/billing.js | 13 + 3 files changed, 1051 insertions(+), 883 deletions(-) diff --git a/app/profile/billing.tsx b/app/profile/billing.tsx index 6f8bdb0..39b3d08 100644 --- a/app/profile/billing.tsx +++ b/app/profile/billing.tsx @@ -1,9 +1,9 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { Ionicons } from '@expo/vector-icons'; -import { useRouter } from 'expo-router'; -import Constants from 'expo-constants'; +import React, { useEffect, useMemo, useState } from 'react'; +import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { useRouter } from 'expo-router'; +import Constants from 'expo-constants'; import Purchases, { PACKAGE_TYPE, PRODUCT_CATEGORY, @@ -11,9 +11,9 @@ import Purchases, { PurchasesPackage, PurchasesStoreProduct, } from 'react-native-purchases'; -import { useApp } from '../../context/AppContext'; -import { useColors } from '../../constants/Colors'; -import { ThemeBackdrop } from '../../components/ThemeBackdrop'; +import { useApp } from '../../context/AppContext'; +import { useColors } from '../../constants/Colors'; +import { ThemeBackdrop } from '../../components/ThemeBackdrop'; import { Language } from '../../types'; import { PurchaseProductId } from '../../services/backend/contracts'; @@ -66,183 +66,183 @@ const summarizeOfferingPackages = (offering: PurchasesOffering | null) => { })), }; }; - -const getBillingCopy = (language: Language) => { - if (language === 'de') { - return { - title: 'Abo und Credits', - planLabel: 'Aktueller Plan', - planFree: 'Free', - planPro: 'Pro', - creditsAvailableLabel: 'Verfügbare Credits', - manageSubscription: 'Abo verwalten', - subscriptionTitle: 'Abos', - subscriptionHint: 'Wähle ein Abo und schalte stärkere KI-Scans sowie mehr Credits frei.', - freePlanName: 'Free', - freePlanPrice: '0 EUR / Monat', - proPlanName: 'Pro', - proPlanPrice: '4.99 EUR / Monat', - proBadgeText: 'EMPFOHLEN', - proYearlyPlanName: 'Pro', - proYearlyPlanPrice: '39.99 EUR / Jahr', - proYearlyBadgeText: 'SPAREN', - proBenefits: [ - '250 Credits jeden Monat', - 'Pro-Scans mit GPT-5.4', - 'Unbegrenzte Historie & Galerie', - 'KI-Pflanzendoktor inklusive', - 'Priorisierter Support' - ], - topupTitle: 'Credits Aufladen', - topupSmall: '25 Credits – 1,99 €', - topupMedium: '120 Credits – 6,99 €', - topupLarge: '300 Credits – 12,99 €', - topupBestValue: 'BESTES ANGEBOT', - cancelTitle: 'Schade, dass du gehst', - cancelQuestion: 'Dürfen wir fragen, warum du kündigst?', - reasonTooExpensive: 'Es ist mir zu teuer', - reasonNotUsing: 'Ich nutze die App zu selten', - reasonOther: 'Ein anderer Grund', - offerTitle: 'Ein Geschenk für dich!', - offerText: 'Bleib dabei und erhalte den nächsten Monat für nur 2,49 € (50% Rabatt).', - offerAccept: 'Rabatt sichern', - offerDecline: 'Nein, Kündigung fortsetzen', - confirmCancelBtn: 'Jetzt kündigen', - restorePurchases: 'Käufe wiederherstellen', - autoRenewMonthly: 'Verlängert sich monatlich automatisch. Jederzeit über iOS-Einstellungen kündbar.', - autoRenewYearly: 'Verlängert sich jährlich automatisch. Jederzeit über iOS-Einstellungen kündbar.', - manageInSettings: 'In iOS-Einstellungen verwalten', - }; - } else if (language === 'es') { - return { - title: 'Suscripción y Créditos', - planLabel: 'Plan Actual', - planFree: 'Gratis', - planPro: 'Pro', - creditsAvailableLabel: 'Créditos Disponibles', - manageSubscription: 'Administrar Suscripción', - subscriptionTitle: 'Suscripciones', - subscriptionHint: 'Elige un plan y desbloquea escaneos con IA más potentes y más créditos.', - freePlanName: 'Gratis', - freePlanPrice: '0 EUR / Mes', - proPlanName: 'Pro', - proPlanPrice: '4.99 EUR / Mes', - proBadgeText: 'RECOMENDADO', - proYearlyPlanName: 'Pro', - proYearlyPlanPrice: '39.99 EUR / Año', - proYearlyBadgeText: 'AHORRAR', - proBenefits: [ - '250 créditos cada mes', - 'Escaneos Pro con GPT-5.4', - 'Historial y galería ilimitados', - 'Doctor de plantas de IA incluido', - 'Soporte prioritario' - ], - topupTitle: 'Recargar Créditos', - topupSmall: '25 Créditos – 1,99 €', - topupMedium: '120 Créditos – 6,99 €', - topupLarge: '300 Créditos – 12,99 €', - topupBestValue: 'MEJOR OFERTA', - cancelTitle: 'Lamentamos verte ir', - cancelQuestion: '¿Podemos saber por qué cancelas?', - reasonTooExpensive: 'Es muy caro', - reasonNotUsing: 'No lo uso suficiente', - reasonOther: 'Otra razón', - offerTitle: '¡Un regalo para ti!', - offerText: 'Quédate y obtén el próximo mes por solo 2,49 € (50% de descuento).', - offerAccept: 'Aceptar descuento', - offerDecline: 'No, continuar cancelando', - confirmCancelBtn: 'Cancelar ahora', - restorePurchases: 'Restaurar Compras', - autoRenewMonthly: 'Se renueva mensualmente de forma automática. Cancela cuando quieras en Ajustes de iOS.', - autoRenewYearly: 'Se renueva anualmente de forma automática. Cancela cuando quieras en Ajustes de iOS.', - manageInSettings: 'Administrar en Ajustes de iOS', - }; - } - return { - title: 'Billing & Credits', - planLabel: 'Current Plan', - planFree: 'Free', - planPro: 'Pro', - creditsAvailableLabel: 'Available Credits', - manageSubscription: 'Manage Subscription', - subscriptionTitle: 'Subscriptions', - subscriptionHint: 'Choose a plan to unlock stronger AI scans and more credits.', - freePlanName: 'Free', - freePlanPrice: '0 EUR / Month', - proPlanName: 'Pro', - proPlanPrice: '4.99 EUR / Month', - proBadgeText: 'RECOMMENDED', - proYearlyPlanName: 'Pro', - proYearlyPlanPrice: '39.99 EUR / Year', - proYearlyBadgeText: 'SAVE', - proBenefits: [ - '250 credits every month', - 'Pro scans with GPT-5.4', - 'Unlimited history & gallery', - 'AI Plant Doctor included', - 'Priority support' - ], - topupTitle: 'Topup Credits', - topupSmall: '25 Credits – €1.99', - topupMedium: '120 Credits – €6.99', - topupLarge: '300 Credits – €12.99', - topupBestValue: 'BEST VALUE', - cancelTitle: 'Sorry to see you go', - cancelQuestion: 'May we ask why you are cancelling?', - reasonTooExpensive: 'It is too expensive', - reasonNotUsing: 'I don\'t use it enough', - reasonOther: 'Other reason', - offerTitle: 'A gift for you!', - offerText: 'Stay with us and get your next month for just €2.49 (50% off).', - offerAccept: 'Claim discount', - offerDecline: 'No, continue cancelling', - confirmCancelBtn: 'Cancel now', - restorePurchases: 'Restore Purchases', - autoRenewMonthly: 'Auto-renews monthly. Cancel anytime in iOS Settings.', - autoRenewYearly: 'Auto-renews annually. Cancel anytime in iOS Settings.', - manageInSettings: 'Manage in iOS Settings', - }; -}; - - - -export default function BillingScreen() { - const router = useRouter(); + +const getBillingCopy = (language: Language) => { + if (language === 'de') { + return { + title: 'Abo und Credits', + planLabel: 'Aktueller Plan', + planFree: 'Free', + planPro: 'Pro', + creditsAvailableLabel: 'Verfügbare Credits', + manageSubscription: 'Abo verwalten', + subscriptionTitle: 'Abos', + subscriptionHint: 'Wähle ein Abo und schalte stärkere KI-Scans sowie mehr Credits frei.', + freePlanName: 'Free', + freePlanPrice: '0 EUR / Monat', + proPlanName: 'Pro', + proPlanPrice: '4.99 EUR / Monat', + proBadgeText: 'EMPFOHLEN', + proYearlyPlanName: 'Pro', + proYearlyPlanPrice: '39.99 EUR / Jahr', + proYearlyBadgeText: 'SPAREN', + proBenefits: [ + '250 Credits jeden Monat', + 'Pro-Scans mit GPT-5.4', + 'Unbegrenzte Historie & Galerie', + 'KI-Pflanzendoktor inklusive', + 'Priorisierter Support' + ], + topupTitle: 'Credits Aufladen', + topupSmall: '25 Credits – 1,99 €', + topupMedium: '120 Credits – 6,99 €', + topupLarge: '300 Credits – 12,99 €', + topupBestValue: 'BESTES ANGEBOT', + cancelTitle: 'Schade, dass du gehst', + cancelQuestion: 'Dürfen wir fragen, warum du kündigst?', + reasonTooExpensive: 'Es ist mir zu teuer', + reasonNotUsing: 'Ich nutze die App zu selten', + reasonOther: 'Ein anderer Grund', + offerTitle: 'Ein Geschenk für dich!', + offerText: 'Bleib dabei und erhalte den nächsten Monat für nur 2,49 € (50% Rabatt).', + offerAccept: 'Rabatt sichern', + offerDecline: 'Nein, Kündigung fortsetzen', + confirmCancelBtn: 'Jetzt kündigen', + restorePurchases: 'Käufe wiederherstellen', + autoRenewMonthly: 'Verlängert sich monatlich automatisch. Jederzeit über iOS-Einstellungen kündbar.', + autoRenewYearly: 'Verlängert sich jährlich automatisch. Jederzeit über iOS-Einstellungen kündbar.', + manageInSettings: 'In iOS-Einstellungen verwalten', + }; + } else if (language === 'es') { + return { + title: 'Suscripción y Créditos', + planLabel: 'Plan Actual', + planFree: 'Gratis', + planPro: 'Pro', + creditsAvailableLabel: 'Créditos Disponibles', + manageSubscription: 'Administrar Suscripción', + subscriptionTitle: 'Suscripciones', + subscriptionHint: 'Elige un plan y desbloquea escaneos con IA más potentes y más créditos.', + freePlanName: 'Gratis', + freePlanPrice: '0 EUR / Mes', + proPlanName: 'Pro', + proPlanPrice: '4.99 EUR / Mes', + proBadgeText: 'RECOMENDADO', + proYearlyPlanName: 'Pro', + proYearlyPlanPrice: '39.99 EUR / Año', + proYearlyBadgeText: 'AHORRAR', + proBenefits: [ + '250 créditos cada mes', + 'Escaneos Pro con GPT-5.4', + 'Historial y galería ilimitados', + 'Doctor de plantas de IA incluido', + 'Soporte prioritario' + ], + topupTitle: 'Recargar Créditos', + topupSmall: '25 Créditos – 1,99 €', + topupMedium: '120 Créditos – 6,99 €', + topupLarge: '300 Créditos – 12,99 €', + topupBestValue: 'MEJOR OFERTA', + cancelTitle: 'Lamentamos verte ir', + cancelQuestion: '¿Podemos saber por qué cancelas?', + reasonTooExpensive: 'Es muy caro', + reasonNotUsing: 'No lo uso suficiente', + reasonOther: 'Otra razón', + offerTitle: '¡Un regalo para ti!', + offerText: 'Quédate y obtén el próximo mes por solo 2,49 € (50% de descuento).', + offerAccept: 'Aceptar descuento', + offerDecline: 'No, continuar cancelando', + confirmCancelBtn: 'Cancelar ahora', + restorePurchases: 'Restaurar Compras', + autoRenewMonthly: 'Se renueva mensualmente de forma automática. Cancela cuando quieras en Ajustes de iOS.', + autoRenewYearly: 'Se renueva anualmente de forma automática. Cancela cuando quieras en Ajustes de iOS.', + manageInSettings: 'Administrar en Ajustes de iOS', + }; + } + return { + title: 'Billing & Credits', + planLabel: 'Current Plan', + planFree: 'Free', + planPro: 'Pro', + creditsAvailableLabel: 'Available Credits', + manageSubscription: 'Manage Subscription', + subscriptionTitle: 'Subscriptions', + subscriptionHint: 'Choose a plan to unlock stronger AI scans and more credits.', + freePlanName: 'Free', + freePlanPrice: '0 EUR / Month', + proPlanName: 'Pro', + proPlanPrice: '4.99 EUR / Month', + proBadgeText: 'RECOMMENDED', + proYearlyPlanName: 'Pro', + proYearlyPlanPrice: '39.99 EUR / Year', + proYearlyBadgeText: 'SAVE', + proBenefits: [ + '250 credits every month', + 'Pro scans with GPT-5.4', + 'Unlimited history & gallery', + 'AI Plant Doctor included', + 'Priority support' + ], + topupTitle: 'Topup Credits', + topupSmall: '25 Credits – €1.99', + topupMedium: '120 Credits – €6.99', + topupLarge: '300 Credits – €12.99', + topupBestValue: 'BEST VALUE', + cancelTitle: 'Sorry to see you go', + cancelQuestion: 'May we ask why you are cancelling?', + reasonTooExpensive: 'It is too expensive', + reasonNotUsing: 'I don\'t use it enough', + reasonOther: 'Other reason', + offerTitle: 'A gift for you!', + offerText: 'Stay with us and get your next month for just €2.49 (50% off).', + offerAccept: 'Claim discount', + offerDecline: 'No, continue cancelling', + confirmCancelBtn: 'Cancel now', + restorePurchases: 'Restore Purchases', + autoRenewMonthly: 'Auto-renews monthly. Cancel anytime in iOS Settings.', + autoRenewYearly: 'Auto-renews annually. Cancel anytime in iOS Settings.', + manageInSettings: 'Manage in iOS Settings', + }; +}; + + + +export default function BillingScreen() { + const router = useRouter(); const { isDarkMode, language, billingSummary, isLoadingBilling, simulatePurchase, simulateWebhookEvent, syncRevenueCatState, colorPalette, session } = useApp(); - const colors = useColors(isDarkMode, colorPalette); - const copy = getBillingCopy(language); - const isExpoGo = Constants.appOwnership === 'expo'; - - const [subModalVisible, setSubModalVisible] = useState(false); - const [isUpdating, setIsUpdating] = useState(false); - const [storeReady, setStoreReady] = useState(isExpoGo); + const colors = useColors(isDarkMode, colorPalette); + const copy = getBillingCopy(language); + const isExpoGo = Constants.appOwnership === 'expo'; + + const [subModalVisible, setSubModalVisible] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [storeReady, setStoreReady] = useState(isExpoGo); const [subscriptionPackages, setSubscriptionPackages] = useState({}); const [topupProducts, setTopupProducts] = useState({}); - - // Cancel Flow State - const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none'); - - const planId = billingSummary?.entitlement?.plan || 'free'; - const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? '--'); - - useEffect(() => { - let cancelled = false; - - const loadStoreProducts = async () => { - if (isExpoGo) { - setStoreReady(true); - return; - } - - try { - const [offerings, topups] = await Promise.all([ - Purchases.getOfferings(), - Purchases.getProducts(['topup_small', 'topup_medium', 'topup_large'], PRODUCT_CATEGORY.NON_SUBSCRIPTION), - ]); - - if (cancelled) return; - + + // Cancel Flow State + const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none'); + + const planId = billingSummary?.entitlement?.plan || 'free'; + const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? '--'); + + useEffect(() => { + let cancelled = false; + + const loadStoreProducts = async () => { + if (isExpoGo) { + setStoreReady(true); + return; + } + + try { + const [offerings, topups] = await Promise.all([ + Purchases.getOfferings(), + Purchases.getProducts(['topup_small', 'topup_medium', 'topup_large'], PRODUCT_CATEGORY.NON_SUBSCRIPTION), + ]); + + if (cancelled) return; + const currentOffering = offerings.current; const resolvedPackages = resolveSubscriptionPackages(currentOffering); if (!resolvedPackages.monthly_pro || !resolvedPackages.yearly_pro) { @@ -250,34 +250,34 @@ export default function BillingScreen() { } setSubscriptionPackages(resolvedPackages); - - setTopupProducts({ - topup_small: topups.find((product) => product.identifier === 'topup_small'), - topup_medium: topups.find((product) => product.identifier === 'topup_medium'), - topup_large: topups.find((product) => product.identifier === 'topup_large'), - }); - } catch (error) { - console.warn('Failed to load RevenueCat products', error); - } finally { - if (!cancelled) { - setStoreReady(true); - } - } - }; - - loadStoreProducts(); - - return () => { - cancelled = true; - }; - }, [isExpoGo]); - - const monthlyPackage = subscriptionPackages.monthly_pro; - const yearlyPackage = subscriptionPackages.yearly_pro; - - const monthlyPrice = monthlyPackage?.product.priceString ?? copy.proPlanPrice; - const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPrice; - + + setTopupProducts({ + topup_small: topups.find((product) => product.identifier === 'topup_small'), + topup_medium: topups.find((product) => product.identifier === 'topup_medium'), + topup_large: topups.find((product) => product.identifier === 'topup_large'), + }); + } catch (error) { + console.warn('Failed to load RevenueCat products', error); + } finally { + if (!cancelled) { + setStoreReady(true); + } + } + }; + + loadStoreProducts(); + + return () => { + cancelled = true; + }; + }, [isExpoGo]); + + const monthlyPackage = subscriptionPackages.monthly_pro; + const yearlyPackage = subscriptionPackages.yearly_pro; + + const monthlyPrice = monthlyPackage?.product.priceString ?? copy.proPlanPrice; + const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPrice; + const topupLabels = useMemo(() => ({ topup_small: topupProducts.topup_small ? `25 Credits - ${topupProducts.topup_small.priceString}` : copy.topupSmall, topup_medium: topupProducts.topup_medium ? `120 Credits - ${topupProducts.topup_medium.priceString}` : copy.topupMedium, @@ -287,15 +287,15 @@ export default function BillingScreen() { const openAppleSubscriptions = async () => { await Linking.openURL('itms-apps://apps.apple.com/account/subscriptions'); }; - - const handlePurchase = async (productId: PurchaseProductId) => { - setIsUpdating(true); - try { - if (isExpoGo) { - // ExpoGo has no native RevenueCat — use simulation for development only - await simulatePurchase(productId); - } else { - if (productId === 'monthly_pro' || productId === 'yearly_pro') { + + const handlePurchase = async (productId: PurchaseProductId) => { + setIsUpdating(true); + try { + if (isExpoGo) { + // ExpoGo has no native RevenueCat — use simulation for development only + await simulatePurchase(productId); + } else { + if (productId === 'monthly_pro' || productId === 'yearly_pro') { if (planId === 'pro') { await openAppleSubscriptions(); setSubModalVisible(false); @@ -310,666 +310,675 @@ export default function BillingScreen() { productId, offering: summarizeOfferingPackages(latestOffering), }); - throw new Error('Abo-Paket konnte nicht geladen werden. Bitte RevenueCat Offering prüfen.'); - } - await Purchases.purchasePackage(selectedPackage); - // Derive plan locally from RevenueCat — backend sync via webhook comes later (Step 3) - const customerInfo = await Purchases.getCustomerInfo(); + throw new Error('Abo-Paket konnte nicht geladen werden. Bitte RevenueCat Offering prüfen.'); + } + await Purchases.purchasePackage(selectedPackage); + // Derive plan locally from RevenueCat — backend sync via webhook comes later (Step 3) + const customerInfo = await Purchases.getCustomerInfo(); await syncRevenueCatState(customerInfo as any, 'subscription_purchase'); } else { - const selectedProduct = topupProducts[productId]; - if (!selectedProduct) { - throw new Error('Top-up Produkt konnte nicht geladen werden. Bitte Store-Produkt IDs prüfen.'); - } - await Purchases.purchaseStoreProduct(selectedProduct); + const selectedProduct = topupProducts[productId]; + if (!selectedProduct) { + throw new Error('Top-up Produkt konnte nicht geladen werden. Bitte Store-Produkt IDs prüfen.'); + } + await Purchases.purchaseStoreProduct(selectedProduct); const customerInfo = await Purchases.getCustomerInfo(); await syncRevenueCatState(customerInfo as any, 'topup_purchase'); } - } - setSubModalVisible(false); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - const userCancelled = typeof e === 'object' && e !== null && 'userCancelled' in e && Boolean((e as { userCancelled?: boolean }).userCancelled); - - if (userCancelled) { - return; - } - - console.error('Payment failed', e); - Alert.alert('Unerwarteter Fehler', msg); - } finally { - setIsUpdating(false); - } - }; - - const handleRestore = async () => { - setIsUpdating(true); - try { - if (!isExpoGo) { + } + setSubModalVisible(false); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const userCancelled = typeof e === 'object' && e !== null && 'userCancelled' in e && Boolean((e as { userCancelled?: boolean }).userCancelled); + + if (userCancelled) { + return; + } + + // RevenueCat error code 7 = PRODUCT_ALREADY_PURCHASED — the Apple ID already + // owns this subscription on a different GreenLens account. Silently dismiss; + // the current account stays free. The user can restore via "Käufe wiederherstellen". + const rcErrorCode = typeof e === 'object' && e !== null ? (e as Record).code : undefined; + if (rcErrorCode === 7) { + setSubModalVisible(false); + return; + } + + console.error('Payment failed', e); + Alert.alert('Unerwarteter Fehler', msg); + } finally { + setIsUpdating(false); + } + }; + + const handleRestore = async () => { + setIsUpdating(true); + try { + if (!isExpoGo) { const customerInfo = await Purchases.restorePurchases(); await syncRevenueCatState(customerInfo as any, 'restore'); - } - Alert.alert(copy.restorePurchases, '✓'); - } catch (e) { - Alert.alert('Error', e instanceof Error ? e.message : String(e)); - } finally { - setIsUpdating(false); - } - }; - - const handleDowngrade = async () => { + } + Alert.alert(copy.restorePurchases, '✓'); + } catch (e) { + Alert.alert('Error', e instanceof Error ? e.message : String(e)); + } finally { + setIsUpdating(false); + } + }; + + const handleDowngrade = async () => { if (planId === 'free') return; if (!isExpoGo) { await openAppleSubscriptions(); return; } - // Expo Go / dev only: simulate cancel flow - setCancelStep('survey'); - }; - - const finalizeCancel = async () => { - setIsUpdating(true); - try { - await simulateWebhookEvent('entitlement_revoked'); - setCancelStep('none'); - setSubModalVisible(false); - } catch (e) { - console.error('Downgrade failed', e); - } finally { - setIsUpdating(false); - } - }; - - return ( - - - - - router.back()} style={styles.backButton}> - - - {copy.title} - - - - - {isLoadingBilling && session ? ( - - ) : ( - <> - {session && ( - - {copy.planLabel} - - - {planId === 'pro' ? copy.planPro : copy.planFree} - - setSubModalVisible(true)} - > - {copy.manageSubscription} - - - - {copy.creditsAvailableLabel} - {credits} - - )} - {!session && ( - - Subscription Plans - - Choose a plan to unlock AI plant scans and care features. - - - {/* Monthly */} - - - GreenLens Pro - - MONTHLY - - - {monthlyPrice} - {copy.autoRenewMonthly} - - {copy.proBenefits.map((b, i) => ( - - - {b} - - ))} - - handlePurchase('monthly_pro')} - disabled={isUpdating || !storeReady} - > - Subscribe Monthly - - - - {/* Yearly */} - - - GreenLens Pro - - YEARLY - - - {yearlyPrice} - {copy.autoRenewYearly} - - {copy.proBenefits.map((b, i) => ( - - - {b} - - ))} - - handlePurchase('yearly_pro')} - disabled={isUpdating || !storeReady} - > - Subscribe Yearly - - - - + // Expo Go / dev only: simulate cancel flow + setCancelStep('survey'); + }; + + const finalizeCancel = async () => { + setIsUpdating(true); + try { + await simulateWebhookEvent('entitlement_revoked'); + setCancelStep('none'); + setSubModalVisible(false); + } catch (e) { + console.error('Downgrade failed', e); + } finally { + setIsUpdating(false); + } + }; + + return ( + + + + + router.back()} style={styles.backButton}> + + + {copy.title} + + + + + {isLoadingBilling && session ? ( + + ) : ( + <> + {session && ( + + {copy.planLabel} + + + {planId === 'pro' ? copy.planPro : copy.planFree} + + setSubModalVisible(true)} + > + {copy.manageSubscription} + + + + {copy.creditsAvailableLabel} + {credits} + + )} + {!session && ( + + Subscription Plans + + Choose a plan to unlock AI plant scans and care features. + + + {/* Monthly */} + + + GreenLens Pro + + MONTHLY + + + {monthlyPrice} + {copy.autoRenewMonthly} + + {copy.proBenefits.map((b, i) => ( + + + {b} + + ))} + + handlePurchase('monthly_pro')} + disabled={isUpdating || !storeReady} + > + Subscribe Monthly + + + + {/* Yearly */} + + + GreenLens Pro + + YEARLY + + + {yearlyPrice} + {copy.autoRenewYearly} + + {copy.proBenefits.map((b, i) => ( + + + {b} + + ))} + + handlePurchase('yearly_pro')} + disabled={isUpdating || !storeReady} + > + Subscribe Yearly + + + + Linking.openURL('https://greenlenspro.com/privacy')}> - Privacy Policy - - · + Privacy Policy + + · Linking.openURL('https://greenlenspro.com/terms')}> - Terms of Use - - - - {copy.restorePurchases} - - - )} - - - {copy.topupTitle} - - {([ - { id: 'topup_small' as PurchaseProductId, label: topupLabels.topup_small }, - { id: 'topup_medium' as PurchaseProductId, label: topupLabels.topup_medium, badge: copy.topupBestValue }, - { id: 'topup_large' as PurchaseProductId, label: topupLabels.topup_large }, - ] as { id: PurchaseProductId; label: string; badge?: string }[]).map((pack) => ( - handlePurchase(pack.id)} - disabled={isUpdating || !storeReady} - > - - - - {isUpdating ? '...' : pack.label} - - - {pack.badge && ( - - {pack.badge} - - )} - - ))} - - + Terms of Use + + + + {copy.restorePurchases} + + + )} + + + {copy.topupTitle} + + {([ + { id: 'topup_small' as PurchaseProductId, label: topupLabels.topup_small }, + { id: 'topup_medium' as PurchaseProductId, label: topupLabels.topup_medium, badge: copy.topupBestValue }, + { id: 'topup_large' as PurchaseProductId, label: topupLabels.topup_large }, + ] as { id: PurchaseProductId; label: string; badge?: string }[]).map((pack) => ( + handlePurchase(pack.id)} + disabled={isUpdating || !storeReady} + > + + + + {isUpdating ? '...' : pack.label} + + + {pack.badge && ( + + {pack.badge} + + )} + + ))} + + Linking.openURL('https://greenlenspro.com/privacy')}> - Privacy Policy - - · + Privacy Policy + + · Linking.openURL('https://greenlenspro.com/terms')}> - Terms of Use - - - - {copy.restorePurchases} - - - - )} - - - - setSubModalVisible(false)}> - - - - - {cancelStep === 'survey' ? copy.cancelTitle : cancelStep === 'offer' ? copy.offerTitle : copy.subscriptionTitle} - - { - setSubModalVisible(false); - setCancelStep('none'); - }}> - - - - - {cancelStep === 'none' ? ( - <> - {copy.subscriptionHint} - - - - {copy.freePlanName} - {copy.freePlanPrice} - - {planId === 'free' && } - - - Terms of Use + + + + {copy.restorePurchases} + + + + )} + + + + setSubModalVisible(false)}> + + + + + {cancelStep === 'survey' ? copy.cancelTitle : cancelStep === 'offer' ? copy.offerTitle : copy.subscriptionTitle} + + { + setSubModalVisible(false); + setCancelStep('none'); + }}> + + + + + {cancelStep === 'none' ? ( + <> + {copy.subscriptionHint} + + + + {copy.freePlanName} + {copy.freePlanPrice} + + {planId === 'free' && } + + + handlePurchase('monthly_pro')} disabled={isUpdating || !storeReady} > - - - {copy.proPlanName} - - {copy.proBadgeText} - - - {monthlyPrice} - {copy.autoRenewMonthly} - - - {copy.proBenefits.map((b, i) => ( - - - {b} - - ))} - - - {planId === 'pro' && } - - - handlePurchase('yearly_pro')} - disabled={isUpdating || !storeReady} - > - - - {copy.proYearlyPlanName} - - {copy.proYearlyBadgeText} - - - {yearlyPrice} - {copy.autoRenewYearly} - - - {copy.proBenefits.map((b, i) => ( - - - {b} - - ))} - - - {planId === 'pro' && } - - - + + + {copy.proPlanName} + + {copy.proBadgeText} + + + {monthlyPrice} + {copy.autoRenewMonthly} + + + {copy.proBenefits.map((b, i) => ( + + + {b} + + ))} + + + {planId === 'pro' && } + + + handlePurchase('yearly_pro')} + disabled={isUpdating || !storeReady} + > + + + {copy.proYearlyPlanName} + + {copy.proYearlyBadgeText} + + + {yearlyPrice} + {copy.autoRenewYearly} + + + {copy.proBenefits.map((b, i) => ( + + + {b} + + ))} + + + {planId === 'pro' && } + + + Linking.openURL('https://greenlenspro.com/privacy')}> - Privacy Policy - - · + Privacy Policy + + · Linking.openURL('https://greenlenspro.com/terms')}> - Terms of Use - - - - {copy.restorePurchases} - - - ) : cancelStep === 'survey' ? ( - - {copy.cancelQuestion} - - {[ - { id: 'expensive', label: copy.reasonTooExpensive, icon: 'cash-outline' }, - { id: 'not_using', label: copy.reasonNotUsing, icon: 'calendar-outline' }, - { id: 'other', label: copy.reasonOther, icon: 'ellipsis-horizontal-outline' }, - ].map((reason) => ( - { - setCancelStep('offer'); - }} - > - - - - {reason.label} - - - ))} - - - ) : ( - - - - - - {copy.offerText} - - { - // Handle applying discount here (future implementation) - Alert.alert('Erfolg', 'Rabatt angewendet! (Mock)'); - setCancelStep('none'); - setSubModalVisible(false); - }} - > - {copy.offerAccept} - - - - - {copy.offerDecline} - - - )} - {(isUpdating || (!storeReady && cancelStep === 'none')) && } - - - - - ); -} - -const styles = StyleSheet.create({ - safeArea: { flex: 1 }, - header: { flexDirection: 'row', alignItems: 'center', padding: 16 }, - backButton: { width: 40, height: 40, justifyContent: 'center' }, - title: { flex: 1, fontSize: 20, fontWeight: '700', textAlign: 'center' }, - scrollContent: { padding: 16, gap: 16 }, - card: { - padding: 16, - borderRadius: 16, - borderWidth: StyleSheet.hairlineWidth, - }, - sectionTitle: { - fontSize: 14, - fontWeight: '600', - textTransform: 'uppercase', - letterSpacing: 0.5, - marginBottom: 8, - }, - row: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - value: { - fontSize: 18, - fontWeight: '600', - }, - manageBtn: { - paddingHorizontal: 16, - paddingVertical: 8, - borderRadius: 20, - }, - manageBtnText: { - color: '#fff', - fontSize: 14, - fontWeight: '600', - }, - creditsValue: { - fontSize: 32, - fontWeight: '700', - }, - topupBtn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 12, - borderRadius: 12, - borderWidth: 2, - gap: 8, - }, - topupText: { - fontSize: 16, - fontWeight: '600', - }, - modalOverlay: { - flex: 1, - backgroundColor: '#00000080', - justifyContent: 'flex-end', - }, - modalContent: { - borderTopLeftRadius: 24, - borderTopRightRadius: 24, - padding: 24, - borderTopWidth: StyleSheet.hairlineWidth, - paddingBottom: 40, - }, - modalHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 8, - }, - modalTitle: { - fontSize: 20, - fontWeight: '700', - }, - modalHint: { - fontSize: 14, - marginBottom: 24, - }, - plansContainer: { - gap: 12, - }, - planOption: { - padding: 16, - borderRadius: 12, - borderWidth: 2, - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - planName: { - fontSize: 18, - fontWeight: '600', - }, - planPrice: { - fontSize: 14, - }, - planHeaderRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - marginBottom: 2, - }, - proBadge: { - paddingHorizontal: 8, - paddingVertical: 2, - borderRadius: 6, - }, - proBadgeText: { - color: '#fff', - fontSize: 10, - fontWeight: '800', - }, - proBenefits: { - marginTop: 12, - gap: 6, - }, - benefitRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - }, - benefitText: { - fontSize: 12, - fontWeight: '500', - }, - cancelFlowContainer: { - marginTop: 8, - }, - cancelHint: { - fontSize: 15, - marginBottom: 16, - }, - reasonList: { - gap: 12, - }, - reasonOption: { - flexDirection: 'row', - alignItems: 'center', - padding: 16, - borderWidth: 1, - borderRadius: 12, - }, - reasonIcon: { - width: 36, - height: 36, - borderRadius: 18, - justifyContent: 'center', - alignItems: 'center', - marginRight: 12, - }, - reasonText: { - flex: 1, - fontSize: 16, - fontWeight: '500', - }, - offerCard: { - borderRadius: 16, - padding: 24, - alignItems: 'center', - marginBottom: 16, - }, - offerIconWrap: { - width: 56, - height: 56, - borderRadius: 28, - justifyContent: 'center', - alignItems: 'center', - marginBottom: 16, - }, - offerText: { - fontSize: 16, - textAlign: 'center', - lineHeight: 24, - marginBottom: 24, - fontWeight: '500', - }, - offerAcceptBtn: { - paddingHorizontal: 24, - paddingVertical: 14, - borderRadius: 24, - width: '100%', - alignItems: 'center', - }, - offerAcceptBtnText: { - color: '#fff', - fontSize: 16, - fontWeight: '700', - }, - offerDeclineBtn: { - paddingVertical: 12, - alignItems: 'center', - }, - offerDeclineBtnText: { - fontSize: 15, - fontWeight: '500', - }, - guestPlanCard: { - borderWidth: 2, - borderRadius: 12, - padding: 16, - }, - guestPlanHeader: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - marginBottom: 4, - }, - guestPlanName: { - fontSize: 18, - fontWeight: '700', - }, - guestPlanPrice: { - fontSize: 22, - fontWeight: '700', - marginBottom: 2, - }, - guestPlanRenew: { - fontSize: 12, - }, - guestSubscribeBtn: { - marginTop: 14, - paddingVertical: 12, - borderRadius: 10, - alignItems: 'center', - }, - legalLinksRow: { - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - marginTop: 16, - }, - legalLink: { - fontSize: 12, - fontWeight: '500', - textDecorationLine: 'underline', - }, - legalSep: { - fontSize: 12, - }, - restoreBtn: { - alignItems: 'center', - paddingVertical: 8, - }, - autoRenewText: { - fontSize: 11, - marginTop: 2, - marginBottom: 4, - }, -}); + Terms of Use + + + + {copy.restorePurchases} + + + ) : cancelStep === 'survey' ? ( + + {copy.cancelQuestion} + + {[ + { id: 'expensive', label: copy.reasonTooExpensive, icon: 'cash-outline' }, + { id: 'not_using', label: copy.reasonNotUsing, icon: 'calendar-outline' }, + { id: 'other', label: copy.reasonOther, icon: 'ellipsis-horizontal-outline' }, + ].map((reason) => ( + { + setCancelStep('offer'); + }} + > + + + + {reason.label} + + + ))} + + + ) : ( + + + + + + {copy.offerText} + + { + // Handle applying discount here (future implementation) + Alert.alert('Erfolg', 'Rabatt angewendet! (Mock)'); + setCancelStep('none'); + setSubModalVisible(false); + }} + > + {copy.offerAccept} + + + + + {copy.offerDecline} + + + )} + {(isUpdating || (!storeReady && cancelStep === 'none')) && } + + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { flex: 1 }, + header: { flexDirection: 'row', alignItems: 'center', padding: 16 }, + backButton: { width: 40, height: 40, justifyContent: 'center' }, + title: { flex: 1, fontSize: 20, fontWeight: '700', textAlign: 'center' }, + scrollContent: { padding: 16, gap: 16 }, + card: { + padding: 16, + borderRadius: 16, + borderWidth: StyleSheet.hairlineWidth, + }, + sectionTitle: { + fontSize: 14, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 0.5, + marginBottom: 8, + }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + value: { + fontSize: 18, + fontWeight: '600', + }, + manageBtn: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + }, + manageBtnText: { + color: '#fff', + fontSize: 14, + fontWeight: '600', + }, + creditsValue: { + fontSize: 32, + fontWeight: '700', + }, + topupBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 12, + borderRadius: 12, + borderWidth: 2, + gap: 8, + }, + topupText: { + fontSize: 16, + fontWeight: '600', + }, + modalOverlay: { + flex: 1, + backgroundColor: '#00000080', + justifyContent: 'flex-end', + }, + modalContent: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + padding: 24, + borderTopWidth: StyleSheet.hairlineWidth, + paddingBottom: 40, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + modalTitle: { + fontSize: 20, + fontWeight: '700', + }, + modalHint: { + fontSize: 14, + marginBottom: 24, + }, + plansContainer: { + gap: 12, + }, + planOption: { + padding: 16, + borderRadius: 12, + borderWidth: 2, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + planName: { + fontSize: 18, + fontWeight: '600', + }, + planPrice: { + fontSize: 14, + }, + planHeaderRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 2, + }, + proBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 6, + }, + proBadgeText: { + color: '#fff', + fontSize: 10, + fontWeight: '800', + }, + proBenefits: { + marginTop: 12, + gap: 6, + }, + benefitRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + benefitText: { + fontSize: 12, + fontWeight: '500', + }, + cancelFlowContainer: { + marginTop: 8, + }, + cancelHint: { + fontSize: 15, + marginBottom: 16, + }, + reasonList: { + gap: 12, + }, + reasonOption: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderWidth: 1, + borderRadius: 12, + }, + reasonIcon: { + width: 36, + height: 36, + borderRadius: 18, + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + reasonText: { + flex: 1, + fontSize: 16, + fontWeight: '500', + }, + offerCard: { + borderRadius: 16, + padding: 24, + alignItems: 'center', + marginBottom: 16, + }, + offerIconWrap: { + width: 56, + height: 56, + borderRadius: 28, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 16, + }, + offerText: { + fontSize: 16, + textAlign: 'center', + lineHeight: 24, + marginBottom: 24, + fontWeight: '500', + }, + offerAcceptBtn: { + paddingHorizontal: 24, + paddingVertical: 14, + borderRadius: 24, + width: '100%', + alignItems: 'center', + }, + offerAcceptBtnText: { + color: '#fff', + fontSize: 16, + fontWeight: '700', + }, + offerDeclineBtn: { + paddingVertical: 12, + alignItems: 'center', + }, + offerDeclineBtnText: { + fontSize: 15, + fontWeight: '500', + }, + guestPlanCard: { + borderWidth: 2, + borderRadius: 12, + padding: 16, + }, + guestPlanHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 4, + }, + guestPlanName: { + fontSize: 18, + fontWeight: '700', + }, + guestPlanPrice: { + fontSize: 22, + fontWeight: '700', + marginBottom: 2, + }, + guestPlanRenew: { + fontSize: 12, + }, + guestSubscribeBtn: { + marginTop: 14, + paddingVertical: 12, + borderRadius: 10, + alignItems: 'center', + }, + legalLinksRow: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + marginTop: 16, + }, + legalLink: { + fontSize: 12, + fontWeight: '500', + textDecorationLine: 'underline', + }, + legalSep: { + fontSize: 12, + }, + restoreBtn: { + alignItems: 'center', + paddingVertical: 8, + }, + autoRenewText: { + fontSize: 11, + marginTop: 2, + marginBottom: 4, + }, +}); diff --git a/server/index.js b/server/index.js index f08b2e1..70c09ca 100644 --- a/server/index.js +++ b/server/index.js @@ -69,7 +69,7 @@ const SEMANTIC_SEARCH_COST = 2; const HEALTH_CHECK_COST = 2; const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8; -const DEFAULT_BOOTSTRAP_PLANTS = [ +const DEFAULT_BOOTSTRAP_PLANTS = [ { id: '1', name: 'Monstera Deliciosa', @@ -97,10 +97,18 @@ const DEFAULT_BOOTSTRAP_PLANTS = [ temp: '15-30C', light: 'Low to full light', }, - }, -]; - -let db; + }, +]; + +const FULL_BOOTSTRAP_CATALOG_CANDIDATES = [ + path.join(__dirname, 'data', 'plants_dump_utf8.json'), + path.join(__dirname, '..', 'plants_dump_utf8.json'), +]; +const FULL_BOOTSTRAP_MANIFEST_CANDIDATES = [ + path.join(__dirname, 'public', 'plants', 'manifest.json'), +]; + +let db; const parseBoolean = (value, fallbackValue) => { if (typeof value !== 'string') return fallbackValue; @@ -270,23 +278,161 @@ const ensureRequestAuth = (request) => { const isGuest = (userId) => userId === 'guest'; -const ensureNonEmptyString = (value, fieldName) => { - if (typeof value === 'string' && value.trim()) return value.trim(); - const error = new Error(`${fieldName} is required.`); - error.code = 'BAD_REQUEST'; - throw error; -}; - -const seedBootstrapCatalogIfNeeded = async () => { - const existing = await getPlants(db, { limit: 1 }); - if (existing.length > 0) return; - - await rebuildPlantsCatalog(db, DEFAULT_BOOTSTRAP_PLANTS, { - source: 'bootstrap', - preserveExistingIds: false, - enforceUniqueImages: false, - }); -}; +const ensureNonEmptyString = (value, fieldName) => { + if (typeof value === 'string' && value.trim()) return value.trim(); + const error = new Error(`${fieldName} is required.`); + error.code = 'BAD_REQUEST'; + throw error; +}; + +const readJsonFromCandidates = (filePaths) => { + for (const filePath of filePaths) { + if (!fs.existsSync(filePath)) continue; + + try { + const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''); + return { + parsed: JSON.parse(raw), + sourcePath: filePath, + }; + } catch (error) { + console.warn('Failed to parse bootstrap JSON file.', { + filePath, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return null; +}; + +const buildEntriesFromManifest = (manifest) => { + const items = Array.isArray(manifest?.items) ? manifest.items : []; + return items + .filter((item) => item && typeof item.name === 'string' && typeof item.botanicalName === 'string') + .map((item) => ({ + id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : `${item.botanicalName}`.toLowerCase().replace(/[^a-z0-9]+/g, '-'), + name: item.name.trim(), + botanicalName: item.botanicalName.trim(), + imageUri: typeof item.localImageUri === 'string' && item.localImageUri.trim() + ? item.localImageUri.trim() + : (typeof item.sourceUri === 'string' ? item.sourceUri.trim() : ''), + imageStatus: item.status === 'missing' ? 'missing' : 'ok', + description: '', + categories: [], + confidence: 1, + careInfo: { + waterIntervalDays: 7, + light: 'Unknown', + temp: 'Unknown', + }, + })) + .filter((entry) => entry.imageUri); +}; + +const mergeBootstrapEntries = (primaryEntries, secondaryEntries) => { + const mergedByBotanical = new Map(); + + primaryEntries.forEach((entry) => { + const botanicalKey = typeof entry?.botanicalName === 'string' + ? entry.botanicalName.trim().toLowerCase() + : ''; + if (!botanicalKey || mergedByBotanical.has(botanicalKey)) return; + mergedByBotanical.set(botanicalKey, { ...entry }); + }); + + secondaryEntries.forEach((entry) => { + const botanicalKey = typeof entry?.botanicalName === 'string' + ? entry.botanicalName.trim().toLowerCase() + : ''; + if (!botanicalKey) return; + + const existing = mergedByBotanical.get(botanicalKey); + if (!existing) { + mergedByBotanical.set(botanicalKey, { ...entry }); + return; + } + + const shouldPreferLocalImage = typeof entry.imageUri === 'string' && entry.imageUri.startsWith('/plants/'); + mergedByBotanical.set(botanicalKey, { + ...existing, + imageUri: shouldPreferLocalImage ? entry.imageUri : existing.imageUri, + imageStatus: shouldPreferLocalImage ? entry.imageStatus || existing.imageStatus : existing.imageStatus, + id: existing.id || entry.id, + name: existing.name || entry.name, + botanicalName: existing.botanicalName || entry.botanicalName, + }); + }); + + return Array.from(mergedByBotanical.values()); +}; + +const loadFullBootstrapCatalog = () => { + const catalogDump = readJsonFromCandidates(FULL_BOOTSTRAP_CATALOG_CANDIDATES); + const manifestDump = readJsonFromCandidates(FULL_BOOTSTRAP_MANIFEST_CANDIDATES); + + const catalogEntries = Array.isArray(catalogDump?.parsed) ? catalogDump.parsed : []; + const manifestEntries = manifestDump ? buildEntriesFromManifest(manifestDump.parsed) : []; + const mergedEntries = mergeBootstrapEntries(catalogEntries, manifestEntries); + + if (mergedEntries.length === 0) return null; + + return { + entries: mergedEntries, + sourcePath: [catalogDump?.sourcePath, manifestDump?.sourcePath].filter(Boolean).join(', '), + }; +}; + +const isMinimalBootstrapCatalog = (entries) => { + if (!Array.isArray(entries) || entries.length !== DEFAULT_BOOTSTRAP_PLANTS.length) { + return false; + } + + const botanicalNames = new Set( + entries + .map((entry) => (typeof entry?.botanicalName === 'string' ? entry.botanicalName.trim().toLowerCase() : '')) + .filter(Boolean), + ); + + return DEFAULT_BOOTSTRAP_PLANTS.every((entry) => botanicalNames.has(entry.botanicalName.trim().toLowerCase())); +}; + +const seedBootstrapCatalogIfNeeded = async () => { + const fullCatalog = loadFullBootstrapCatalog(); + const diagnostics = await getPlantDiagnostics(db); + + if (diagnostics.totalCount > 0) { + if (fullCatalog && diagnostics.totalCount === DEFAULT_BOOTSTRAP_PLANTS.length) { + const existingEntries = await getPlants(db, { limit: DEFAULT_BOOTSTRAP_PLANTS.length + 1 }); + if (isMinimalBootstrapCatalog(existingEntries) && fullCatalog.entries.length > existingEntries.length) { + await rebuildPlantsCatalog(db, fullCatalog.entries, { + source: 'bootstrap_upgrade_from_minimal_catalog', + preserveExistingIds: false, + enforceUniqueImages: false, + }); + console.log(`Upgraded minimal bootstrap catalog to full catalog (${fullCatalog.entries.length} entries).`); + } + } + return; + } + + if (fullCatalog) { + await rebuildPlantsCatalog(db, fullCatalog.entries, { + source: 'bootstrap_full_catalog', + preserveExistingIds: false, + enforceUniqueImages: false, + }); + console.log(`Bootstrapped full plant catalog from ${fullCatalog.sourcePath} (${fullCatalog.entries.length} entries).`); + return; + } + + await rebuildPlantsCatalog(db, DEFAULT_BOOTSTRAP_PLANTS, { + source: 'bootstrap_minimal_catalog', + preserveExistingIds: false, + enforceUniqueImages: false, + }); + console.warn('Full bootstrap catalog was not found. Seeded minimal fallback catalog with 2 entries.'); +}; app.use(cors()); app.use('/plants', express.static(plantsPublicDir)); diff --git a/server/lib/billing.js b/server/lib/billing.js index 16be92e..7dcce5f 100644 --- a/server/lib/billing.js +++ b/server/lib/billing.js @@ -403,6 +403,19 @@ const syncRevenueCatCustomerInfo = async (db, userId, customerInfo, options = {} ); } + // Fallback: also check active entitlements for topup products. + // This handles cases where a topup product is misconfigured in RevenueCat + // to grant an entitlement instead of being treated as a consumable. + const rawActiveEntitlements = Object.values(customerInfo?.entitlements?.active || {}); + for (const entitlement of rawActiveEntitlements) { + const productId = entitlement?.productIdentifier; + if (isSupportedTopupProduct(productId)) { + const purchaseDate = entitlement?.latestPurchaseDate || entitlement?.originalPurchaseDate; + const txId = purchaseDate ? `entitlement:${productId}:${purchaseDate}` : null; + await grantRevenueCatTopupIfNeeded(tx, account, txId, productId); + } + } + account.updatedAt = nowIso(); await upsertAccount(tx, account); return { From 1b40f1eb1b54459bf78b42151e884d330437bd81 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Sat, 4 Apr 2026 21:33:51 +0200 Subject: [PATCH 03/10] feat: implement billing and subscription management screen with RevenueCat integration --- app/profile/billing.tsx | 4 ++-- server/lib/billing.js | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/app/profile/billing.tsx b/app/profile/billing.tsx index 39b3d08..43faed7 100644 --- a/app/profile/billing.tsx +++ b/app/profile/billing.tsx @@ -81,10 +81,10 @@ const getBillingCopy = (language: Language) => { freePlanName: 'Free', freePlanPrice: '0 EUR / Monat', proPlanName: 'Pro', - proPlanPrice: '4.99 EUR / Monat', + proPlanPrice: '4,99 € / Monat', proBadgeText: 'EMPFOHLEN', proYearlyPlanName: 'Pro', - proYearlyPlanPrice: '39.99 EUR / Jahr', + proYearlyPlanPrice: '39,99 € / Jahr', proYearlyBadgeText: 'SPAREN', proBenefits: [ '250 Credits jeden Monat', diff --git a/server/lib/billing.js b/server/lib/billing.js index 7dcce5f..1396608 100644 --- a/server/lib/billing.js +++ b/server/lib/billing.js @@ -308,6 +308,18 @@ const getValidProEntitlement = (customerInfo) => { return proEntitlement; } + // Fallback: entitlement is active but backed by a non-subscription product (e.g. a topup + // that was previously misconfigured to grant the pro entitlement). If the user also has a + // supported subscription product in their purchase history, honour the entitlement anyway. + const purchased = Array.isArray(customerInfo?.allPurchasedProductIdentifiers) + ? customerInfo.allPurchasedProductIdentifiers + : []; + const hasSubscription = purchased.some((id) => SUPPORTED_SUBSCRIPTION_PRODUCTS.has(id)); + if (hasSubscription) { + console.warn('[Billing] Pro entitlement backed by unsupported product but subscription found — honouring entitlement', summarizeRevenueCatCustomerInfo(customerInfo)); + return proEntitlement; + } + console.warn('[Billing] Ignoring unsupported RevenueCat pro entitlement', summarizeRevenueCatCustomerInfo(customerInfo)); return null; }; From 8d90d9718248ecb17bd77378717dbbad9448a7f1 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Wed, 8 Apr 2026 00:11:24 +0200 Subject: [PATCH 04/10] feat: initialize project with docker-compose infrastructure and server application logic --- greenlns-landing/docker-compose.yml | 3 +- server/index.js | 643 ++++++++++++++-------------- server/lib/openai.js | 21 +- 3 files changed, 343 insertions(+), 324 deletions(-) diff --git a/greenlns-landing/docker-compose.yml b/greenlns-landing/docker-compose.yml index 8e54702..b1ee536 100644 --- a/greenlns-landing/docker-compose.yml +++ b/greenlns-landing/docker-compose.yml @@ -41,7 +41,8 @@ services: MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-https://greenlenspro.com/storage} OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_SCAN_MODEL: ${OPENAI_SCAN_MODEL:-gpt-5-mini} - OPENAI_HEALTH_MODEL: ${OPENAI_HEALTH_MODEL:-gpt-5-mini} + OPENAI_SCAN_MODEL_PRO: ${OPENAI_SCAN_MODEL_PRO:-gpt-5.4} + OPENAI_HEALTH_MODEL: ${OPENAI_HEALTH_MODEL:-gpt-4o-mini} REVENUECAT_WEBHOOK_SECRET: ${REVENUECAT_WEBHOOK_SECRET:-} REVENUECAT_PRO_ENTITLEMENT_ID: ${REVENUECAT_PRO_ENTITLEMENT_ID:-pro} JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} diff --git a/server/index.js b/server/index.js index 70c09ca..044d385 100644 --- a/server/index.js +++ b/server/index.js @@ -1,8 +1,8 @@ -const fs = require('fs'); -const path = require('path'); -const dotenv = require('dotenv'); -const express = require('express'); -const cors = require('cors'); +const fs = require('fs'); +const path = require('path'); +const dotenv = require('dotenv'); +const express = require('express'); +const cors = require('cors'); const loadEnvFiles = (filePaths) => { const mergedFileEnv = {}; @@ -25,7 +25,7 @@ loadEnvFiles([ path.join(__dirname, '.env.local'), ]); -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 { PlantImportValidationError, @@ -34,34 +34,34 @@ const { getPlants, rebuildPlantsCatalog, } = require('./lib/plants'); -const { - chargeKey, - consumeCreditsWithIdempotency, - endpointKey, - ensureBillingSchema, +const { + chargeKey, + consumeCreditsWithIdempotency, + endpointKey, + ensureBillingSchema, getAccountSnapshot, getBillingSummary, getEndpointResponse, - isInsufficientCreditsError, - simulatePurchase, - simulateWebhook, - syncRevenueCatCustomerInfo, - syncRevenueCatWebhookEvent, - storeEndpointResponse, -} = require('./lib/billing'); -const { - analyzePlantHealth, - getHealthModel, - getScanModel, - identifyPlant, + isInsufficientCreditsError, + simulatePurchase, + simulateWebhook, + syncRevenueCatCustomerInfo, + syncRevenueCatWebhookEvent, + storeEndpointResponse, +} = require('./lib/billing'); +const { + analyzePlantHealth, + getHealthModel, + getScanModel, + identifyPlant, isConfigured: isOpenAiConfigured, -} = require('./lib/openai'); -const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding'); -const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage'); +} = require('./lib/openai'); +const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding'); +const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage'); -const app = express(); -const port = Number(process.env.PORT || 3000); -const plantsPublicDir = path.join(__dirname, 'public', 'plants'); +const app = express(); +const port = Number(process.env.PORT || 3000); +const plantsPublicDir = path.join(__dirname, 'public', 'plants'); const SCAN_PRIMARY_COST = 1; const SCAN_REVIEW_COST = 1; @@ -69,7 +69,7 @@ const SEMANTIC_SEARCH_COST = 2; const HEALTH_CHECK_COST = 2; const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8; -const DEFAULT_BOOTSTRAP_PLANTS = [ +const DEFAULT_BOOTSTRAP_PLANTS = [ { id: '1', name: 'Monstera Deliciosa', @@ -97,18 +97,18 @@ const DEFAULT_BOOTSTRAP_PLANTS = [ temp: '15-30C', light: 'Low to full light', }, - }, -]; - -const FULL_BOOTSTRAP_CATALOG_CANDIDATES = [ - path.join(__dirname, 'data', 'plants_dump_utf8.json'), - path.join(__dirname, '..', 'plants_dump_utf8.json'), -]; -const FULL_BOOTSTRAP_MANIFEST_CANDIDATES = [ - path.join(__dirname, 'public', 'plants', 'manifest.json'), -]; - -let db; + }, +]; + +const FULL_BOOTSTRAP_CATALOG_CANDIDATES = [ + path.join(__dirname, 'data', 'plants_dump_utf8.json'), + path.join(__dirname, '..', 'plants_dump_utf8.json'), +]; +const FULL_BOOTSTRAP_MANIFEST_CANDIDATES = [ + path.join(__dirname, 'public', 'plants', 'manifest.json'), +]; + +let db; const parseBoolean = (value, fallbackValue) => { if (typeof value !== 'string') return fallbackValue; @@ -180,7 +180,7 @@ const toPlantResult = (entry, confidence) => { }; }; -const pickCatalogFallback = (entries, imageUri, preferHighConfidence = false) => { +const pickCatalogFallback = (entries, imageUri, preferHighConfidence = false, { silent = false } = {}) => { if (!Array.isArray(entries) || entries.length === 0) return null; const baseHash = hashString(`${imageUri || ''}|${entries.length}`); const index = baseHash % entries.length; @@ -188,11 +188,13 @@ const pickCatalogFallback = (entries, imageUri, preferHighConfidence = false) => const confidence = preferHighConfidence ? 0.22 + ((baseHash % 3) / 100) : 0.18 + ((baseHash % 7) / 100); - console.warn('Using hash-based catalog fallback — OpenAI is unavailable or returned null.', { - plant: entries[index]?.name, - confidence, - imageHint: (imageUri || '').slice(0, 80), - }); + if (!silent) { + console.warn('Using hash-based catalog fallback — OpenAI is unavailable or returned null.', { + plant: entries[index]?.name, + confidence, + imageHint: (imageUri || '').slice(0, 80), + }); + } return toPlantResult(entries[index], confidence); }; @@ -278,238 +280,238 @@ const ensureRequestAuth = (request) => { const isGuest = (userId) => userId === 'guest'; -const ensureNonEmptyString = (value, fieldName) => { - if (typeof value === 'string' && value.trim()) return value.trim(); - const error = new Error(`${fieldName} is required.`); - error.code = 'BAD_REQUEST'; - throw error; -}; - -const readJsonFromCandidates = (filePaths) => { - for (const filePath of filePaths) { - if (!fs.existsSync(filePath)) continue; - - try { - const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''); - return { - parsed: JSON.parse(raw), - sourcePath: filePath, - }; - } catch (error) { - console.warn('Failed to parse bootstrap JSON file.', { - filePath, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - return null; -}; - -const buildEntriesFromManifest = (manifest) => { - const items = Array.isArray(manifest?.items) ? manifest.items : []; - return items - .filter((item) => item && typeof item.name === 'string' && typeof item.botanicalName === 'string') - .map((item) => ({ - id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : `${item.botanicalName}`.toLowerCase().replace(/[^a-z0-9]+/g, '-'), - name: item.name.trim(), - botanicalName: item.botanicalName.trim(), - imageUri: typeof item.localImageUri === 'string' && item.localImageUri.trim() - ? item.localImageUri.trim() - : (typeof item.sourceUri === 'string' ? item.sourceUri.trim() : ''), - imageStatus: item.status === 'missing' ? 'missing' : 'ok', - description: '', - categories: [], - confidence: 1, - careInfo: { - waterIntervalDays: 7, - light: 'Unknown', - temp: 'Unknown', - }, - })) - .filter((entry) => entry.imageUri); -}; - -const mergeBootstrapEntries = (primaryEntries, secondaryEntries) => { - const mergedByBotanical = new Map(); - - primaryEntries.forEach((entry) => { - const botanicalKey = typeof entry?.botanicalName === 'string' - ? entry.botanicalName.trim().toLowerCase() - : ''; - if (!botanicalKey || mergedByBotanical.has(botanicalKey)) return; - mergedByBotanical.set(botanicalKey, { ...entry }); - }); - - secondaryEntries.forEach((entry) => { - const botanicalKey = typeof entry?.botanicalName === 'string' - ? entry.botanicalName.trim().toLowerCase() - : ''; - if (!botanicalKey) return; - - const existing = mergedByBotanical.get(botanicalKey); - if (!existing) { - mergedByBotanical.set(botanicalKey, { ...entry }); - return; - } - - const shouldPreferLocalImage = typeof entry.imageUri === 'string' && entry.imageUri.startsWith('/plants/'); - mergedByBotanical.set(botanicalKey, { - ...existing, - imageUri: shouldPreferLocalImage ? entry.imageUri : existing.imageUri, - imageStatus: shouldPreferLocalImage ? entry.imageStatus || existing.imageStatus : existing.imageStatus, - id: existing.id || entry.id, - name: existing.name || entry.name, - botanicalName: existing.botanicalName || entry.botanicalName, - }); - }); - - return Array.from(mergedByBotanical.values()); -}; - -const loadFullBootstrapCatalog = () => { - const catalogDump = readJsonFromCandidates(FULL_BOOTSTRAP_CATALOG_CANDIDATES); - const manifestDump = readJsonFromCandidates(FULL_BOOTSTRAP_MANIFEST_CANDIDATES); - - const catalogEntries = Array.isArray(catalogDump?.parsed) ? catalogDump.parsed : []; - const manifestEntries = manifestDump ? buildEntriesFromManifest(manifestDump.parsed) : []; - const mergedEntries = mergeBootstrapEntries(catalogEntries, manifestEntries); - - if (mergedEntries.length === 0) return null; - - return { - entries: mergedEntries, - sourcePath: [catalogDump?.sourcePath, manifestDump?.sourcePath].filter(Boolean).join(', '), - }; -}; - -const isMinimalBootstrapCatalog = (entries) => { - if (!Array.isArray(entries) || entries.length !== DEFAULT_BOOTSTRAP_PLANTS.length) { - return false; - } - - const botanicalNames = new Set( - entries - .map((entry) => (typeof entry?.botanicalName === 'string' ? entry.botanicalName.trim().toLowerCase() : '')) - .filter(Boolean), - ); - - return DEFAULT_BOOTSTRAP_PLANTS.every((entry) => botanicalNames.has(entry.botanicalName.trim().toLowerCase())); -}; - -const seedBootstrapCatalogIfNeeded = async () => { - const fullCatalog = loadFullBootstrapCatalog(); - const diagnostics = await getPlantDiagnostics(db); - - if (diagnostics.totalCount > 0) { - if (fullCatalog && diagnostics.totalCount === DEFAULT_BOOTSTRAP_PLANTS.length) { - const existingEntries = await getPlants(db, { limit: DEFAULT_BOOTSTRAP_PLANTS.length + 1 }); - if (isMinimalBootstrapCatalog(existingEntries) && fullCatalog.entries.length > existingEntries.length) { - await rebuildPlantsCatalog(db, fullCatalog.entries, { - source: 'bootstrap_upgrade_from_minimal_catalog', - preserveExistingIds: false, - enforceUniqueImages: false, - }); - console.log(`Upgraded minimal bootstrap catalog to full catalog (${fullCatalog.entries.length} entries).`); - } - } - return; - } - - if (fullCatalog) { - await rebuildPlantsCatalog(db, fullCatalog.entries, { - source: 'bootstrap_full_catalog', - preserveExistingIds: false, - enforceUniqueImages: false, - }); - console.log(`Bootstrapped full plant catalog from ${fullCatalog.sourcePath} (${fullCatalog.entries.length} entries).`); - return; - } - - await rebuildPlantsCatalog(db, DEFAULT_BOOTSTRAP_PLANTS, { - source: 'bootstrap_minimal_catalog', - preserveExistingIds: false, - enforceUniqueImages: false, - }); - console.warn('Full bootstrap catalog was not found. Seeded minimal fallback catalog with 2 entries.'); -}; +const ensureNonEmptyString = (value, fieldName) => { + if (typeof value === 'string' && value.trim()) return value.trim(); + const error = new Error(`${fieldName} is required.`); + error.code = 'BAD_REQUEST'; + throw error; +}; + +const readJsonFromCandidates = (filePaths) => { + for (const filePath of filePaths) { + if (!fs.existsSync(filePath)) continue; + + try { + const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''); + return { + parsed: JSON.parse(raw), + sourcePath: filePath, + }; + } catch (error) { + console.warn('Failed to parse bootstrap JSON file.', { + filePath, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return null; +}; + +const buildEntriesFromManifest = (manifest) => { + const items = Array.isArray(manifest?.items) ? manifest.items : []; + return items + .filter((item) => item && typeof item.name === 'string' && typeof item.botanicalName === 'string') + .map((item) => ({ + id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : `${item.botanicalName}`.toLowerCase().replace(/[^a-z0-9]+/g, '-'), + name: item.name.trim(), + botanicalName: item.botanicalName.trim(), + imageUri: typeof item.localImageUri === 'string' && item.localImageUri.trim() + ? item.localImageUri.trim() + : (typeof item.sourceUri === 'string' ? item.sourceUri.trim() : ''), + imageStatus: item.status === 'missing' ? 'missing' : 'ok', + description: '', + categories: [], + confidence: 1, + careInfo: { + waterIntervalDays: 7, + light: 'Unknown', + temp: 'Unknown', + }, + })) + .filter((entry) => entry.imageUri); +}; + +const mergeBootstrapEntries = (primaryEntries, secondaryEntries) => { + const mergedByBotanical = new Map(); + + primaryEntries.forEach((entry) => { + const botanicalKey = typeof entry?.botanicalName === 'string' + ? entry.botanicalName.trim().toLowerCase() + : ''; + if (!botanicalKey || mergedByBotanical.has(botanicalKey)) return; + mergedByBotanical.set(botanicalKey, { ...entry }); + }); + + secondaryEntries.forEach((entry) => { + const botanicalKey = typeof entry?.botanicalName === 'string' + ? entry.botanicalName.trim().toLowerCase() + : ''; + if (!botanicalKey) return; + + const existing = mergedByBotanical.get(botanicalKey); + if (!existing) { + mergedByBotanical.set(botanicalKey, { ...entry }); + return; + } + + const shouldPreferLocalImage = typeof entry.imageUri === 'string' && entry.imageUri.startsWith('/plants/'); + mergedByBotanical.set(botanicalKey, { + ...existing, + imageUri: shouldPreferLocalImage ? entry.imageUri : existing.imageUri, + imageStatus: shouldPreferLocalImage ? entry.imageStatus || existing.imageStatus : existing.imageStatus, + id: existing.id || entry.id, + name: existing.name || entry.name, + botanicalName: existing.botanicalName || entry.botanicalName, + }); + }); + + return Array.from(mergedByBotanical.values()); +}; + +const loadFullBootstrapCatalog = () => { + const catalogDump = readJsonFromCandidates(FULL_BOOTSTRAP_CATALOG_CANDIDATES); + const manifestDump = readJsonFromCandidates(FULL_BOOTSTRAP_MANIFEST_CANDIDATES); + + const catalogEntries = Array.isArray(catalogDump?.parsed) ? catalogDump.parsed : []; + const manifestEntries = manifestDump ? buildEntriesFromManifest(manifestDump.parsed) : []; + const mergedEntries = mergeBootstrapEntries(catalogEntries, manifestEntries); + + if (mergedEntries.length === 0) return null; + + return { + entries: mergedEntries, + sourcePath: [catalogDump?.sourcePath, manifestDump?.sourcePath].filter(Boolean).join(', '), + }; +}; + +const isMinimalBootstrapCatalog = (entries) => { + if (!Array.isArray(entries) || entries.length !== DEFAULT_BOOTSTRAP_PLANTS.length) { + return false; + } + + const botanicalNames = new Set( + entries + .map((entry) => (typeof entry?.botanicalName === 'string' ? entry.botanicalName.trim().toLowerCase() : '')) + .filter(Boolean), + ); + + return DEFAULT_BOOTSTRAP_PLANTS.every((entry) => botanicalNames.has(entry.botanicalName.trim().toLowerCase())); +}; + +const seedBootstrapCatalogIfNeeded = async () => { + const fullCatalog = loadFullBootstrapCatalog(); + const diagnostics = await getPlantDiagnostics(db); + + if (diagnostics.totalCount > 0) { + if (fullCatalog && diagnostics.totalCount === DEFAULT_BOOTSTRAP_PLANTS.length) { + const existingEntries = await getPlants(db, { limit: DEFAULT_BOOTSTRAP_PLANTS.length + 1 }); + if (isMinimalBootstrapCatalog(existingEntries) && fullCatalog.entries.length > existingEntries.length) { + await rebuildPlantsCatalog(db, fullCatalog.entries, { + source: 'bootstrap_upgrade_from_minimal_catalog', + preserveExistingIds: false, + enforceUniqueImages: false, + }); + console.log(`Upgraded minimal bootstrap catalog to full catalog (${fullCatalog.entries.length} entries).`); + } + } + return; + } + + if (fullCatalog) { + await rebuildPlantsCatalog(db, fullCatalog.entries, { + source: 'bootstrap_full_catalog', + preserveExistingIds: false, + enforceUniqueImages: false, + }); + console.log(`Bootstrapped full plant catalog from ${fullCatalog.sourcePath} (${fullCatalog.entries.length} entries).`); + return; + } + + await rebuildPlantsCatalog(db, DEFAULT_BOOTSTRAP_PLANTS, { + source: 'bootstrap_minimal_catalog', + preserveExistingIds: false, + enforceUniqueImages: false, + }); + console.warn('Full bootstrap catalog was not found. Seeded minimal fallback catalog with 2 entries.'); +}; app.use(cors()); app.use('/plants', express.static(plantsPublicDir)); -const revenueCatWebhookSecret = (process.env.REVENUECAT_WEBHOOK_SECRET || '').trim(); - -const isAuthorizedRevenueCatWebhook = (request) => { - if (!revenueCatWebhookSecret) return true; - const headerValue = request.header('authorization') || request.header('Authorization') || ''; - const normalized = String(headerValue).trim(); - return normalized === revenueCatWebhookSecret || normalized === `Bearer ${revenueCatWebhookSecret}`; -}; - -app.post('/api/revenuecat/webhook', express.json({ limit: '1mb' }), async (request, response) => { - try { - if (!isAuthorizedRevenueCatWebhook(request)) { - return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid RevenueCat webhook secret.' }); - } - const eventPayload = request.body?.event || request.body; - const result = await syncRevenueCatWebhookEvent(db, eventPayload); - response.status(200).json({ received: true, syncedAt: result.syncedAt }); - } catch (error) { - const payload = toApiErrorPayload(error); - response.status(payload.status).json(payload.body); - } -}); - -app.use(express.json({ limit: '10mb' })); +const revenueCatWebhookSecret = (process.env.REVENUECAT_WEBHOOK_SECRET || '').trim(); + +const isAuthorizedRevenueCatWebhook = (request) => { + if (!revenueCatWebhookSecret) return true; + const headerValue = request.header('authorization') || request.header('Authorization') || ''; + const normalized = String(headerValue).trim(); + return normalized === revenueCatWebhookSecret || normalized === `Bearer ${revenueCatWebhookSecret}`; +}; + +app.post('/api/revenuecat/webhook', express.json({ limit: '1mb' }), async (request, response) => { + try { + if (!isAuthorizedRevenueCatWebhook(request)) { + return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid RevenueCat webhook secret.' }); + } + const eventPayload = request.body?.event || request.body; + const result = await syncRevenueCatWebhookEvent(db, eventPayload); + response.status(200).json({ received: true, syncedAt: result.syncedAt }); + } catch (error) { + const payload = toApiErrorPayload(error); + response.status(payload.status).json(payload.body); + } +}); + +app.use(express.json({ limit: '10mb' })); app.get('/', (_request, response) => { response.status(200).json({ service: 'greenlns-api', - status: 'ok', - endpoints: [ - 'GET /health', - 'GET /api/plants', - 'POST /api/plants/rebuild', - 'POST /auth/signup', + status: 'ok', + endpoints: [ + 'GET /health', + 'GET /api/plants', + 'POST /api/plants/rebuild', + 'POST /auth/signup', 'POST /auth/login', - 'GET /v1/billing/summary', - 'POST /v1/billing/sync-revenuecat', - 'POST /v1/scan', + 'GET /v1/billing/summary', + 'POST /v1/billing/sync-revenuecat', + 'POST /v1/scan', 'POST /v1/search/semantic', 'POST /v1/health-check', 'POST /v1/billing/simulate-purchase', 'POST /v1/billing/simulate-webhook', - 'POST /v1/upload/image', - 'POST /api/revenuecat/webhook', - ], - }); -}); + 'POST /v1/upload/image', + 'POST /api/revenuecat/webhook', + ], + }); +}); -const getDatabaseHealthTarget = () => { - const raw = getDefaultDbPath(); - if (!raw) return ''; - - try { - const parsed = new URL(raw); - const databaseName = parsed.pathname.replace(/^\//, ''); - return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}/${databaseName}`; - } catch { - return 'configured'; - } -}; - -app.get('/health', (_request, response) => { - response.status(200).json({ - ok: true, - uptimeSec: Math.round(process.uptime()), - timestamp: new Date().toISOString(), - openAiConfigured: isOpenAiConfigured(), - dbReady: Boolean(db), - dbPath: getDatabaseHealthTarget(), - scanModel: getScanModel(), - healthModel: getHealthModel(), - }); -}); +const getDatabaseHealthTarget = () => { + const raw = getDefaultDbPath(); + if (!raw) return ''; + + try { + const parsed = new URL(raw); + const databaseName = parsed.pathname.replace(/^\//, ''); + return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}/${databaseName}`; + } catch { + return 'configured'; + } +}; + +app.get('/health', (_request, response) => { + response.status(200).json({ + ok: true, + uptimeSec: Math.round(process.uptime()), + timestamp: new Date().toISOString(), + openAiConfigured: isOpenAiConfigured(), + dbReady: Boolean(db), + dbPath: getDatabaseHealthTarget(), + scanModel: getScanModel(), + healthModel: getHealthModel(), + }); +}); app.get('/api/plants', async (request, response) => { try { @@ -569,43 +571,43 @@ app.post('/api/plants/rebuild', async (request, response) => { } }); -app.get('/v1/billing/summary', async (request, response) => { - try { - const userId = ensureRequestAuth(request); - if (userId !== 'guest') { - const userExists = await get(db, 'SELECT id FROM auth_users WHERE id = $1', [userId]); - if (!userExists) { - return response.status(401).json({ code: 'UNAUTHORIZED', message: 'User not found.' }); - } +app.get('/v1/billing/summary', async (request, response) => { + try { + const userId = ensureRequestAuth(request); + if (userId !== 'guest') { + const userExists = await get(db, 'SELECT id FROM auth_users WHERE id = $1', [userId]); + if (!userExists) { + return response.status(401).json({ code: 'UNAUTHORIZED', message: 'User not found.' }); + } } const summary = await getBillingSummary(db, userId); response.status(200).json(summary); } catch (error) { const payload = toApiErrorPayload(error); response.status(payload.status).json(payload.body); - } -}); - -app.post('/v1/billing/sync-revenuecat', async (request, response) => { - try { - const userId = ensureRequestAuth(request); - if (userId === 'guest') { - return response.status(400).json({ code: 'BAD_REQUEST', message: 'Guest users cannot sync RevenueCat state.' }); - } - const customerInfo = request.body?.customerInfo; - const source = typeof request.body?.source === 'string' ? request.body.source : undefined; - if (!customerInfo || typeof customerInfo !== 'object' || !customerInfo.entitlements) { - return response.status(400).json({ code: 'BAD_REQUEST', message: 'customerInfo is required.' }); - } - const payload = await syncRevenueCatCustomerInfo(db, userId, customerInfo, { source }); - response.status(200).json(payload); - } catch (error) { - const payload = toApiErrorPayload(error); - response.status(payload.status).json(payload.body); - } -}); - -app.post('/v1/scan', async (request, response) => { + } +}); + +app.post('/v1/billing/sync-revenuecat', async (request, response) => { + try { + const userId = ensureRequestAuth(request); + if (userId === 'guest') { + return response.status(400).json({ code: 'BAD_REQUEST', message: 'Guest users cannot sync RevenueCat state.' }); + } + const customerInfo = request.body?.customerInfo; + const source = typeof request.body?.source === 'string' ? request.body.source : undefined; + if (!customerInfo || typeof customerInfo !== 'object' || !customerInfo.entitlements) { + return response.status(400).json({ code: 'BAD_REQUEST', message: 'customerInfo is required.' }); + } + const payload = await syncRevenueCatCustomerInfo(db, userId, customerInfo, { source }); + response.status(200).json(payload); + } catch (error) { + const payload = toApiErrorPayload(error); + response.status(payload.status).json(payload.body); + } +}); + +app.post('/v1/scan', async (request, response) => { let userId = 'unknown'; try { userId = ensureRequestAuth(request); @@ -637,7 +639,7 @@ app.post('/v1/scan', async (request, response) => { const accountSnapshot = await getAccountSnapshot(db, userId); const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free'; const catalogEntries = await getPlants(db, { limit: 500 }); - let result = pickCatalogFallback(catalogEntries, imageUri, false); + let result = pickCatalogFallback(catalogEntries, imageUri, false, { silent: true }); let usedOpenAi = false; if (isOpenAiConfigured()) { @@ -662,7 +664,10 @@ app.post('/v1/scan', async (request, response) => { modelPath.push('openai-primary'); if (grounded.grounded) modelPath.push('catalog-grounded-primary'); } else { - console.warn(`OpenAI primary identification returned null for user ${userId}`); + console.warn(`OpenAI primary identification returned null for user ${userId} — using catalog fallback.`, { + attemptedModels: openAiPrimary?.attemptedModels, + plant: result?.name, + }); modelPath.push('openai-primary-failed'); modelPath.push('catalog-primary-fallback'); } @@ -711,11 +716,13 @@ app.post('/v1/scan', async (request, response) => { modelPath.push('openai-review'); if (grounded.grounded) modelPath.push('catalog-grounded-review'); } else { - console.warn(`OpenAI review identification returned null for user ${userId}`); + console.warn(`OpenAI review identification returned null for user ${userId}.`, { + attemptedModels: openAiReview?.attemptedModels, + }); modelPath.push('openai-review-failed'); } } else { - const reviewFallback = pickCatalogFallback(catalogEntries, `${imageUri}|review`, true); + const reviewFallback = pickCatalogFallback(catalogEntries, `${imageUri}|review`, true, { silent: true }); if (reviewFallback) { result = reviewFallback; } @@ -827,7 +834,13 @@ app.post('/v1/health-check', async (request, response) => { }); const analysis = analysisResponse?.analysis; if (!analysis) { - const error = new Error('OpenAI health check failed. Please verify API key, model, and network access.'); + console.warn('Health check analysis was null — all models returned unusable output.', { + attemptedModels: analysisResponse?.attemptedModels, + modelUsed: analysisResponse?.modelUsed, + }); + const error = new Error( + `Health check AI failed. Tried: ${(analysisResponse?.attemptedModels || []).join(', ')}. Verify API key, model access, and network.` + ); error.code = 'PROVIDER_ERROR'; throw error; } @@ -958,19 +971,19 @@ app.post('/auth/login', async (request, response) => { // ─── Startup ─────────────────────────────────────────────────────────────── -const start = async () => { - db = await openDatabase(); - await ensurePlantSchema(db); - await ensureBillingSchema(db); - await ensureAuthSchema(db); - await seedBootstrapCatalogIfNeeded(); - if (isStorageConfigured()) { - await ensureStorageBucketWithRetry().catch((err) => console.warn('MinIO bucket setup failed:', err.message)); - } - - const server = app.listen(port, () => { - console.log(`GreenLens server listening at http://localhost:${port}`); - }); +const start = async () => { + db = await openDatabase(); + await ensurePlantSchema(db); + await ensureBillingSchema(db); + await ensureAuthSchema(db); + await seedBootstrapCatalogIfNeeded(); + if (isStorageConfigured()) { + await ensureStorageBucketWithRetry().catch((err) => console.warn('MinIO bucket setup failed:', err.message)); + } + + const server = app.listen(port, () => { + console.log(`GreenLens server listening at http://localhost:${port}`); + }); const gracefulShutdown = async () => { try { diff --git a/server/lib/openai.js b/server/lib/openai.js index 0b64a75..ab9acd7 100644 --- a/server/lib/openai.js +++ b/server/lib/openai.js @@ -137,15 +137,16 @@ const normalizeHealthAnalysis = (raw, language) => { const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 8); const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 10); - if (scoreRaw == null || !statusRaw || !Array.isArray(issuesRaw)) { - return null; - } - - const status = statusRaw === 'healthy' || statusRaw === 'watch' || statusRaw === 'critical' + // Use safe defaults instead of returning null — bad/partial JSON falls through + // to the graceful "Uncertain analysis" fallback at line 164 rather than + // propagating null → PROVIDER_ERROR to the caller. + const score = scoreRaw ?? 50; + const status = (statusRaw === 'healthy' || statusRaw === 'watch' || statusRaw === 'critical') ? statusRaw : 'watch'; + const issuesInput = Array.isArray(issuesRaw) ? issuesRaw : []; - const likelyIssues = issuesRaw + const likelyIssues = issuesInput .map((entry) => { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; const title = getString(entry.title); @@ -168,7 +169,7 @@ const normalizeHealthAnalysis = (raw, language) => { ? 'La IA no pudo extraer senales de salud estables.' : 'AI could not extract stable health signals.'; return { - overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)), + overallHealthScore: Math.round(clamp(score, 0, 100)), status, likelyIssues: [ { @@ -191,7 +192,7 @@ const normalizeHealthAnalysis = (raw, language) => { } return { - overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)), + overallHealthScore: Math.round(clamp(score, 0, 100)), status, likelyIssues, actionsNow: actionsNowRaw, @@ -305,10 +306,14 @@ const postChatCompletion = async ({ modelChain, messages, imageUri, temperature if (!response.ok) { const body = await response.text(); + let parsedError = {}; + try { parsedError = JSON.parse(body); } catch {} console.warn('OpenAI request HTTP error.', { status: response.status, model, endpoint: OPENAI_CHAT_COMPLETIONS_URL, + openAiCode: parsedError?.error?.code, + openAiMessage: parsedError?.error?.message, image: summarizeImageUri(imageUri), bodyPreview: body.slice(0, 300), }); From c3fed5226a26b80d83fffe37854d4bccd37d0188 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Wed, 8 Apr 2026 00:18:09 +0200 Subject: [PATCH 05/10] feat: + initialize project with docker-compose infrastructure and server application logic --- app/profile/billing.tsx | 1968 +++++++++++++++++++-------------------- server/index.js | 41 +- 2 files changed, 1020 insertions(+), 989 deletions(-) diff --git a/app/profile/billing.tsx b/app/profile/billing.tsx index 43faed7..63d0f22 100644 --- a/app/profile/billing.tsx +++ b/app/profile/billing.tsx @@ -1,984 +1,984 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { Ionicons } from '@expo/vector-icons'; -import { useRouter } from 'expo-router'; -import Constants from 'expo-constants'; -import Purchases, { - PACKAGE_TYPE, - PRODUCT_CATEGORY, - PurchasesOffering, - PurchasesPackage, - PurchasesStoreProduct, -} from 'react-native-purchases'; -import { useApp } from '../../context/AppContext'; -import { useColors } from '../../constants/Colors'; -import { ThemeBackdrop } from '../../components/ThemeBackdrop'; -import { Language } from '../../types'; -import { PurchaseProductId } from '../../services/backend/contracts'; - -type SubscriptionProductId = 'monthly_pro' | 'yearly_pro'; -type TopupProductId = Extract; -type SubscriptionPackages = Partial>; -type TopupProducts = Partial>; - -const isMatchingPackage = ( - pkg: PurchasesPackage, - productId: SubscriptionProductId, - expectedPackageType: PACKAGE_TYPE, -) => { - return ( - pkg.product.identifier === productId - || pkg.identifier === productId - || pkg.packageType === expectedPackageType - ); -}; - -const resolveSubscriptionPackages = (offering: PurchasesOffering | null): SubscriptionPackages => { - if (!offering) { - return {}; - } - - const availablePackages = [ - offering.monthly, - offering.annual, - ...offering.availablePackages, - ].filter((value): value is PurchasesPackage => Boolean(value)); - - return { - monthly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'monthly_pro', PACKAGE_TYPE.MONTHLY)), - yearly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'yearly_pro', PACKAGE_TYPE.ANNUAL)), - }; -}; - -const summarizeOfferingPackages = (offering: PurchasesOffering | null) => { - if (!offering) { - return { identifier: null, packages: [] as Array> }; - } - - return { - identifier: offering.identifier, - packages: offering.availablePackages.map((pkg) => ({ - identifier: pkg.identifier, - packageType: pkg.packageType, - productIdentifier: pkg.product.identifier, - priceString: pkg.product.priceString, - })), - }; -}; - -const getBillingCopy = (language: Language) => { - if (language === 'de') { - return { - title: 'Abo und Credits', - planLabel: 'Aktueller Plan', - planFree: 'Free', - planPro: 'Pro', - creditsAvailableLabel: 'Verfügbare Credits', - manageSubscription: 'Abo verwalten', - subscriptionTitle: 'Abos', - subscriptionHint: 'Wähle ein Abo und schalte stärkere KI-Scans sowie mehr Credits frei.', - freePlanName: 'Free', - freePlanPrice: '0 EUR / Monat', - proPlanName: 'Pro', - proPlanPrice: '4,99 € / Monat', - proBadgeText: 'EMPFOHLEN', - proYearlyPlanName: 'Pro', - proYearlyPlanPrice: '39,99 € / Jahr', - proYearlyBadgeText: 'SPAREN', - proBenefits: [ - '250 Credits jeden Monat', - 'Pro-Scans mit GPT-5.4', - 'Unbegrenzte Historie & Galerie', - 'KI-Pflanzendoktor inklusive', - 'Priorisierter Support' - ], - topupTitle: 'Credits Aufladen', - topupSmall: '25 Credits – 1,99 €', - topupMedium: '120 Credits – 6,99 €', - topupLarge: '300 Credits – 12,99 €', - topupBestValue: 'BESTES ANGEBOT', - cancelTitle: 'Schade, dass du gehst', - cancelQuestion: 'Dürfen wir fragen, warum du kündigst?', - reasonTooExpensive: 'Es ist mir zu teuer', - reasonNotUsing: 'Ich nutze die App zu selten', - reasonOther: 'Ein anderer Grund', - offerTitle: 'Ein Geschenk für dich!', - offerText: 'Bleib dabei und erhalte den nächsten Monat für nur 2,49 € (50% Rabatt).', - offerAccept: 'Rabatt sichern', - offerDecline: 'Nein, Kündigung fortsetzen', - confirmCancelBtn: 'Jetzt kündigen', - restorePurchases: 'Käufe wiederherstellen', - autoRenewMonthly: 'Verlängert sich monatlich automatisch. Jederzeit über iOS-Einstellungen kündbar.', - autoRenewYearly: 'Verlängert sich jährlich automatisch. Jederzeit über iOS-Einstellungen kündbar.', - manageInSettings: 'In iOS-Einstellungen verwalten', - }; - } else if (language === 'es') { - return { - title: 'Suscripción y Créditos', - planLabel: 'Plan Actual', - planFree: 'Gratis', - planPro: 'Pro', - creditsAvailableLabel: 'Créditos Disponibles', - manageSubscription: 'Administrar Suscripción', - subscriptionTitle: 'Suscripciones', - subscriptionHint: 'Elige un plan y desbloquea escaneos con IA más potentes y más créditos.', - freePlanName: 'Gratis', - freePlanPrice: '0 EUR / Mes', - proPlanName: 'Pro', - proPlanPrice: '4.99 EUR / Mes', - proBadgeText: 'RECOMENDADO', - proYearlyPlanName: 'Pro', - proYearlyPlanPrice: '39.99 EUR / Año', - proYearlyBadgeText: 'AHORRAR', - proBenefits: [ - '250 créditos cada mes', - 'Escaneos Pro con GPT-5.4', - 'Historial y galería ilimitados', - 'Doctor de plantas de IA incluido', - 'Soporte prioritario' - ], - topupTitle: 'Recargar Créditos', - topupSmall: '25 Créditos – 1,99 €', - topupMedium: '120 Créditos – 6,99 €', - topupLarge: '300 Créditos – 12,99 €', - topupBestValue: 'MEJOR OFERTA', - cancelTitle: 'Lamentamos verte ir', - cancelQuestion: '¿Podemos saber por qué cancelas?', - reasonTooExpensive: 'Es muy caro', - reasonNotUsing: 'No lo uso suficiente', - reasonOther: 'Otra razón', - offerTitle: '¡Un regalo para ti!', - offerText: 'Quédate y obtén el próximo mes por solo 2,49 € (50% de descuento).', - offerAccept: 'Aceptar descuento', - offerDecline: 'No, continuar cancelando', - confirmCancelBtn: 'Cancelar ahora', - restorePurchases: 'Restaurar Compras', - autoRenewMonthly: 'Se renueva mensualmente de forma automática. Cancela cuando quieras en Ajustes de iOS.', - autoRenewYearly: 'Se renueva anualmente de forma automática. Cancela cuando quieras en Ajustes de iOS.', - manageInSettings: 'Administrar en Ajustes de iOS', - }; - } - return { - title: 'Billing & Credits', - planLabel: 'Current Plan', - planFree: 'Free', - planPro: 'Pro', - creditsAvailableLabel: 'Available Credits', - manageSubscription: 'Manage Subscription', - subscriptionTitle: 'Subscriptions', - subscriptionHint: 'Choose a plan to unlock stronger AI scans and more credits.', - freePlanName: 'Free', - freePlanPrice: '0 EUR / Month', - proPlanName: 'Pro', - proPlanPrice: '4.99 EUR / Month', - proBadgeText: 'RECOMMENDED', - proYearlyPlanName: 'Pro', - proYearlyPlanPrice: '39.99 EUR / Year', - proYearlyBadgeText: 'SAVE', - proBenefits: [ - '250 credits every month', - 'Pro scans with GPT-5.4', - 'Unlimited history & gallery', - 'AI Plant Doctor included', - 'Priority support' - ], - topupTitle: 'Topup Credits', - topupSmall: '25 Credits – €1.99', - topupMedium: '120 Credits – €6.99', - topupLarge: '300 Credits – €12.99', - topupBestValue: 'BEST VALUE', - cancelTitle: 'Sorry to see you go', - cancelQuestion: 'May we ask why you are cancelling?', - reasonTooExpensive: 'It is too expensive', - reasonNotUsing: 'I don\'t use it enough', - reasonOther: 'Other reason', - offerTitle: 'A gift for you!', - offerText: 'Stay with us and get your next month for just €2.49 (50% off).', - offerAccept: 'Claim discount', - offerDecline: 'No, continue cancelling', - confirmCancelBtn: 'Cancel now', - restorePurchases: 'Restore Purchases', - autoRenewMonthly: 'Auto-renews monthly. Cancel anytime in iOS Settings.', - autoRenewYearly: 'Auto-renews annually. Cancel anytime in iOS Settings.', - manageInSettings: 'Manage in iOS Settings', - }; -}; - - - -export default function BillingScreen() { - const router = useRouter(); - const { isDarkMode, language, billingSummary, isLoadingBilling, simulatePurchase, simulateWebhookEvent, syncRevenueCatState, colorPalette, session } = useApp(); - const colors = useColors(isDarkMode, colorPalette); - const copy = getBillingCopy(language); - const isExpoGo = Constants.appOwnership === 'expo'; - - const [subModalVisible, setSubModalVisible] = useState(false); - const [isUpdating, setIsUpdating] = useState(false); - const [storeReady, setStoreReady] = useState(isExpoGo); - const [subscriptionPackages, setSubscriptionPackages] = useState({}); - const [topupProducts, setTopupProducts] = useState({}); - - // Cancel Flow State - const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none'); - - const planId = billingSummary?.entitlement?.plan || 'free'; - const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? '--'); - - useEffect(() => { - let cancelled = false; - - const loadStoreProducts = async () => { - if (isExpoGo) { - setStoreReady(true); - return; - } - - try { - const [offerings, topups] = await Promise.all([ - Purchases.getOfferings(), - Purchases.getProducts(['topup_small', 'topup_medium', 'topup_large'], PRODUCT_CATEGORY.NON_SUBSCRIPTION), - ]); - - if (cancelled) return; - - const currentOffering = offerings.current; - const resolvedPackages = resolveSubscriptionPackages(currentOffering); - if (!resolvedPackages.monthly_pro || !resolvedPackages.yearly_pro) { - console.warn('[Billing] RevenueCat offering missing expected subscription packages', summarizeOfferingPackages(currentOffering)); - } - - setSubscriptionPackages(resolvedPackages); - - setTopupProducts({ - topup_small: topups.find((product) => product.identifier === 'topup_small'), - topup_medium: topups.find((product) => product.identifier === 'topup_medium'), - topup_large: topups.find((product) => product.identifier === 'topup_large'), - }); - } catch (error) { - console.warn('Failed to load RevenueCat products', error); - } finally { - if (!cancelled) { - setStoreReady(true); - } - } - }; - - loadStoreProducts(); - - return () => { - cancelled = true; - }; - }, [isExpoGo]); - - const monthlyPackage = subscriptionPackages.monthly_pro; - const yearlyPackage = subscriptionPackages.yearly_pro; - - const monthlyPrice = monthlyPackage?.product.priceString ?? copy.proPlanPrice; - const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPrice; - - const topupLabels = useMemo(() => ({ - topup_small: topupProducts.topup_small ? `25 Credits - ${topupProducts.topup_small.priceString}` : copy.topupSmall, - topup_medium: topupProducts.topup_medium ? `120 Credits - ${topupProducts.topup_medium.priceString}` : copy.topupMedium, - topup_large: topupProducts.topup_large ? `300 Credits - ${topupProducts.topup_large.priceString}` : copy.topupLarge, - }), [copy.topupLarge, copy.topupMedium, copy.topupSmall, topupProducts.topup_large, topupProducts.topup_medium, topupProducts.topup_small]); - - const openAppleSubscriptions = async () => { - await Linking.openURL('itms-apps://apps.apple.com/account/subscriptions'); - }; - - const handlePurchase = async (productId: PurchaseProductId) => { - setIsUpdating(true); - try { - if (isExpoGo) { - // ExpoGo has no native RevenueCat — use simulation for development only - await simulatePurchase(productId); - } else { - if (productId === 'monthly_pro' || productId === 'yearly_pro') { - if (planId === 'pro') { - await openAppleSubscriptions(); - setSubModalVisible(false); - return; - } - const selectedPackage = productId === 'monthly_pro' ? monthlyPackage : yearlyPackage; - const latestOffering = !selectedPackage - ? await Purchases.getOfferings().then((offerings) => offerings.current) - : null; - if (!selectedPackage) { - console.warn('[Billing] Purchase blocked because subscription package was not resolved', { - productId, - offering: summarizeOfferingPackages(latestOffering), - }); - throw new Error('Abo-Paket konnte nicht geladen werden. Bitte RevenueCat Offering prüfen.'); - } - await Purchases.purchasePackage(selectedPackage); - // Derive plan locally from RevenueCat — backend sync via webhook comes later (Step 3) - const customerInfo = await Purchases.getCustomerInfo(); - await syncRevenueCatState(customerInfo as any, 'subscription_purchase'); - } else { - const selectedProduct = topupProducts[productId]; - if (!selectedProduct) { - throw new Error('Top-up Produkt konnte nicht geladen werden. Bitte Store-Produkt IDs prüfen.'); - } - await Purchases.purchaseStoreProduct(selectedProduct); - const customerInfo = await Purchases.getCustomerInfo(); - await syncRevenueCatState(customerInfo as any, 'topup_purchase'); - } - } - setSubModalVisible(false); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - const userCancelled = typeof e === 'object' && e !== null && 'userCancelled' in e && Boolean((e as { userCancelled?: boolean }).userCancelled); - - if (userCancelled) { - return; - } - - // RevenueCat error code 7 = PRODUCT_ALREADY_PURCHASED — the Apple ID already - // owns this subscription on a different GreenLens account. Silently dismiss; - // the current account stays free. The user can restore via "Käufe wiederherstellen". - const rcErrorCode = typeof e === 'object' && e !== null ? (e as Record).code : undefined; - if (rcErrorCode === 7) { - setSubModalVisible(false); - return; - } - - console.error('Payment failed', e); - Alert.alert('Unerwarteter Fehler', msg); - } finally { - setIsUpdating(false); - } - }; - - const handleRestore = async () => { - setIsUpdating(true); - try { - if (!isExpoGo) { - const customerInfo = await Purchases.restorePurchases(); - await syncRevenueCatState(customerInfo as any, 'restore'); - } - Alert.alert(copy.restorePurchases, '✓'); - } catch (e) { - Alert.alert('Error', e instanceof Error ? e.message : String(e)); - } finally { - setIsUpdating(false); - } - }; - - const handleDowngrade = async () => { - if (planId === 'free') return; - if (!isExpoGo) { - await openAppleSubscriptions(); - return; - } - // Expo Go / dev only: simulate cancel flow - setCancelStep('survey'); - }; - - const finalizeCancel = async () => { - setIsUpdating(true); - try { - await simulateWebhookEvent('entitlement_revoked'); - setCancelStep('none'); - setSubModalVisible(false); - } catch (e) { - console.error('Downgrade failed', e); - } finally { - setIsUpdating(false); - } - }; - - return ( - - - - - router.back()} style={styles.backButton}> - - - {copy.title} - - - - - {isLoadingBilling && session ? ( - - ) : ( - <> - {session && ( - - {copy.planLabel} - - - {planId === 'pro' ? copy.planPro : copy.planFree} - - setSubModalVisible(true)} - > - {copy.manageSubscription} - - - - {copy.creditsAvailableLabel} - {credits} - - )} - {!session && ( - - Subscription Plans - - Choose a plan to unlock AI plant scans and care features. - - - {/* Monthly */} - - - GreenLens Pro - - MONTHLY - - - {monthlyPrice} - {copy.autoRenewMonthly} - - {copy.proBenefits.map((b, i) => ( - - - {b} - - ))} - - handlePurchase('monthly_pro')} - disabled={isUpdating || !storeReady} - > - Subscribe Monthly - - - - {/* Yearly */} - - - GreenLens Pro - - YEARLY - - - {yearlyPrice} - {copy.autoRenewYearly} - - {copy.proBenefits.map((b, i) => ( - - - {b} - - ))} - - handlePurchase('yearly_pro')} - disabled={isUpdating || !storeReady} - > - Subscribe Yearly - - - - - Linking.openURL('https://greenlenspro.com/privacy')}> - Privacy Policy - - · - Linking.openURL('https://greenlenspro.com/terms')}> - Terms of Use - - - - {copy.restorePurchases} - - - )} - - - {copy.topupTitle} - - {([ - { id: 'topup_small' as PurchaseProductId, label: topupLabels.topup_small }, - { id: 'topup_medium' as PurchaseProductId, label: topupLabels.topup_medium, badge: copy.topupBestValue }, - { id: 'topup_large' as PurchaseProductId, label: topupLabels.topup_large }, - ] as { id: PurchaseProductId; label: string; badge?: string }[]).map((pack) => ( - handlePurchase(pack.id)} - disabled={isUpdating || !storeReady} - > - - - - {isUpdating ? '...' : pack.label} - - - {pack.badge && ( - - {pack.badge} - - )} - - ))} - - - Linking.openURL('https://greenlenspro.com/privacy')}> - Privacy Policy - - · - Linking.openURL('https://greenlenspro.com/terms')}> - Terms of Use - - - - {copy.restorePurchases} - - - - )} - - - - setSubModalVisible(false)}> - - - - - {cancelStep === 'survey' ? copy.cancelTitle : cancelStep === 'offer' ? copy.offerTitle : copy.subscriptionTitle} - - { - setSubModalVisible(false); - setCancelStep('none'); - }}> - - - - - {cancelStep === 'none' ? ( - <> - {copy.subscriptionHint} - - - - {copy.freePlanName} - {copy.freePlanPrice} - - {planId === 'free' && } - - - handlePurchase('monthly_pro')} - disabled={isUpdating || !storeReady} - > - - - {copy.proPlanName} - - {copy.proBadgeText} - - - {monthlyPrice} - {copy.autoRenewMonthly} - - - {copy.proBenefits.map((b, i) => ( - - - {b} - - ))} - - - {planId === 'pro' && } - - - handlePurchase('yearly_pro')} - disabled={isUpdating || !storeReady} - > - - - {copy.proYearlyPlanName} - - {copy.proYearlyBadgeText} - - - {yearlyPrice} - {copy.autoRenewYearly} - - - {copy.proBenefits.map((b, i) => ( - - - {b} - - ))} - - - {planId === 'pro' && } - - - - Linking.openURL('https://greenlenspro.com/privacy')}> - Privacy Policy - - · - Linking.openURL('https://greenlenspro.com/terms')}> - Terms of Use - - - - {copy.restorePurchases} - - - ) : cancelStep === 'survey' ? ( - - {copy.cancelQuestion} - - {[ - { id: 'expensive', label: copy.reasonTooExpensive, icon: 'cash-outline' }, - { id: 'not_using', label: copy.reasonNotUsing, icon: 'calendar-outline' }, - { id: 'other', label: copy.reasonOther, icon: 'ellipsis-horizontal-outline' }, - ].map((reason) => ( - { - setCancelStep('offer'); - }} - > - - - - {reason.label} - - - ))} - - - ) : ( - - - - - - {copy.offerText} - - { - // Handle applying discount here (future implementation) - Alert.alert('Erfolg', 'Rabatt angewendet! (Mock)'); - setCancelStep('none'); - setSubModalVisible(false); - }} - > - {copy.offerAccept} - - - - - {copy.offerDecline} - - - )} - {(isUpdating || (!storeReady && cancelStep === 'none')) && } - - - - - ); -} - -const styles = StyleSheet.create({ - safeArea: { flex: 1 }, - header: { flexDirection: 'row', alignItems: 'center', padding: 16 }, - backButton: { width: 40, height: 40, justifyContent: 'center' }, - title: { flex: 1, fontSize: 20, fontWeight: '700', textAlign: 'center' }, - scrollContent: { padding: 16, gap: 16 }, - card: { - padding: 16, - borderRadius: 16, - borderWidth: StyleSheet.hairlineWidth, - }, - sectionTitle: { - fontSize: 14, - fontWeight: '600', - textTransform: 'uppercase', - letterSpacing: 0.5, - marginBottom: 8, - }, - row: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - value: { - fontSize: 18, - fontWeight: '600', - }, - manageBtn: { - paddingHorizontal: 16, - paddingVertical: 8, - borderRadius: 20, - }, - manageBtnText: { - color: '#fff', - fontSize: 14, - fontWeight: '600', - }, - creditsValue: { - fontSize: 32, - fontWeight: '700', - }, - topupBtn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 12, - borderRadius: 12, - borderWidth: 2, - gap: 8, - }, - topupText: { - fontSize: 16, - fontWeight: '600', - }, - modalOverlay: { - flex: 1, - backgroundColor: '#00000080', - justifyContent: 'flex-end', - }, - modalContent: { - borderTopLeftRadius: 24, - borderTopRightRadius: 24, - padding: 24, - borderTopWidth: StyleSheet.hairlineWidth, - paddingBottom: 40, - }, - modalHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 8, - }, - modalTitle: { - fontSize: 20, - fontWeight: '700', - }, - modalHint: { - fontSize: 14, - marginBottom: 24, - }, - plansContainer: { - gap: 12, - }, - planOption: { - padding: 16, - borderRadius: 12, - borderWidth: 2, - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - planName: { - fontSize: 18, - fontWeight: '600', - }, - planPrice: { - fontSize: 14, - }, - planHeaderRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - marginBottom: 2, - }, - proBadge: { - paddingHorizontal: 8, - paddingVertical: 2, - borderRadius: 6, - }, - proBadgeText: { - color: '#fff', - fontSize: 10, - fontWeight: '800', - }, - proBenefits: { - marginTop: 12, - gap: 6, - }, - benefitRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - }, - benefitText: { - fontSize: 12, - fontWeight: '500', - }, - cancelFlowContainer: { - marginTop: 8, - }, - cancelHint: { - fontSize: 15, - marginBottom: 16, - }, - reasonList: { - gap: 12, - }, - reasonOption: { - flexDirection: 'row', - alignItems: 'center', - padding: 16, - borderWidth: 1, - borderRadius: 12, - }, - reasonIcon: { - width: 36, - height: 36, - borderRadius: 18, - justifyContent: 'center', - alignItems: 'center', - marginRight: 12, - }, - reasonText: { - flex: 1, - fontSize: 16, - fontWeight: '500', - }, - offerCard: { - borderRadius: 16, - padding: 24, - alignItems: 'center', - marginBottom: 16, - }, - offerIconWrap: { - width: 56, - height: 56, - borderRadius: 28, - justifyContent: 'center', - alignItems: 'center', - marginBottom: 16, - }, - offerText: { - fontSize: 16, - textAlign: 'center', - lineHeight: 24, - marginBottom: 24, - fontWeight: '500', - }, - offerAcceptBtn: { - paddingHorizontal: 24, - paddingVertical: 14, - borderRadius: 24, - width: '100%', - alignItems: 'center', - }, - offerAcceptBtnText: { - color: '#fff', - fontSize: 16, - fontWeight: '700', - }, - offerDeclineBtn: { - paddingVertical: 12, - alignItems: 'center', - }, - offerDeclineBtnText: { - fontSize: 15, - fontWeight: '500', - }, - guestPlanCard: { - borderWidth: 2, - borderRadius: 12, - padding: 16, - }, - guestPlanHeader: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - marginBottom: 4, - }, - guestPlanName: { - fontSize: 18, - fontWeight: '700', - }, - guestPlanPrice: { - fontSize: 22, - fontWeight: '700', - marginBottom: 2, - }, - guestPlanRenew: { - fontSize: 12, - }, - guestSubscribeBtn: { - marginTop: 14, - paddingVertical: 12, - borderRadius: 10, - alignItems: 'center', - }, - legalLinksRow: { - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - marginTop: 16, - }, - legalLink: { - fontSize: 12, - fontWeight: '500', - textDecorationLine: 'underline', - }, - legalSep: { - fontSize: 12, - }, - restoreBtn: { - alignItems: 'center', - paddingVertical: 8, - }, - autoRenewText: { - fontSize: 11, - marginTop: 2, - marginBottom: 4, - }, -}); +import React, { useEffect, useMemo, useState } from 'react'; +import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { useRouter } from 'expo-router'; +import Constants from 'expo-constants'; +import Purchases, { + PACKAGE_TYPE, + PRODUCT_CATEGORY, + PurchasesOffering, + PurchasesPackage, + PurchasesStoreProduct, +} from 'react-native-purchases'; +import { useApp } from '../../context/AppContext'; +import { useColors } from '../../constants/Colors'; +import { ThemeBackdrop } from '../../components/ThemeBackdrop'; +import { Language } from '../../types'; +import { PurchaseProductId } from '../../services/backend/contracts'; + +type SubscriptionProductId = 'monthly_pro' | 'yearly_pro'; +type TopupProductId = Extract; +type SubscriptionPackages = Partial>; +type TopupProducts = Partial>; + +const isMatchingPackage = ( + pkg: PurchasesPackage, + productId: SubscriptionProductId, + expectedPackageType: PACKAGE_TYPE, +) => { + return ( + pkg.product.identifier === productId + || pkg.identifier === productId + || pkg.packageType === expectedPackageType + ); +}; + +const resolveSubscriptionPackages = (offering: PurchasesOffering | null): SubscriptionPackages => { + if (!offering) { + return {}; + } + + const availablePackages = [ + offering.monthly, + offering.annual, + ...offering.availablePackages, + ].filter((value): value is PurchasesPackage => Boolean(value)); + + return { + monthly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'monthly_pro', PACKAGE_TYPE.MONTHLY)), + yearly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'yearly_pro', PACKAGE_TYPE.ANNUAL)), + }; +}; + +const summarizeOfferingPackages = (offering: PurchasesOffering | null) => { + if (!offering) { + return { identifier: null, packages: [] as Array> }; + } + + return { + identifier: offering.identifier, + packages: offering.availablePackages.map((pkg) => ({ + identifier: pkg.identifier, + packageType: pkg.packageType, + productIdentifier: pkg.product.identifier, + priceString: pkg.product.priceString, + })), + }; +}; + +const getBillingCopy = (language: Language) => { + if (language === 'de') { + return { + title: 'Abo und Credits', + planLabel: 'Aktueller Plan', + planFree: 'Free', + planPro: 'Pro', + creditsAvailableLabel: 'Verfügbare Credits', + manageSubscription: 'Abo verwalten', + subscriptionTitle: 'Abos', + subscriptionHint: 'Wähle ein Abo und schalte stärkere KI-Scans sowie mehr Credits frei.', + freePlanName: 'Free', + freePlanPrice: '0 EUR / Monat', + proPlanName: 'Pro', + proPlanPrice: '4,99 € / Monat', + proBadgeText: 'EMPFOHLEN', + proYearlyPlanName: 'Pro', + proYearlyPlanPrice: '39,99 € / Jahr', + proYearlyBadgeText: 'SPAREN', + proBenefits: [ + '250 Credits jeden Monat', + 'Pro-Scans mit GPT-5.4', + 'Unbegrenzte Historie & Galerie', + 'KI-Pflanzendoktor inklusive', + 'Priorisierter Support' + ], + topupTitle: 'Credits Aufladen', + topupSmall: '25 Credits – 1,99 €', + topupMedium: '120 Credits – 6,99 €', + topupLarge: '300 Credits – 12,99 €', + topupBestValue: 'BESTES ANGEBOT', + cancelTitle: 'Schade, dass du gehst', + cancelQuestion: 'Dürfen wir fragen, warum du kündigst?', + reasonTooExpensive: 'Es ist mir zu teuer', + reasonNotUsing: 'Ich nutze die App zu selten', + reasonOther: 'Ein anderer Grund', + offerTitle: 'Ein Geschenk für dich!', + offerText: 'Bleib dabei und erhalte den nächsten Monat für nur 2,49 € (50% Rabatt).', + offerAccept: 'Rabatt sichern', + offerDecline: 'Nein, Kündigung fortsetzen', + confirmCancelBtn: 'Jetzt kündigen', + restorePurchases: 'Käufe wiederherstellen', + autoRenewMonthly: 'Verlängert sich monatlich automatisch. Jederzeit über iOS-Einstellungen kündbar.', + autoRenewYearly: 'Verlängert sich jährlich automatisch. Jederzeit über iOS-Einstellungen kündbar.', + manageInSettings: 'In iOS-Einstellungen verwalten', + }; + } else if (language === 'es') { + return { + title: 'Suscripción y Créditos', + planLabel: 'Plan Actual', + planFree: 'Gratis', + planPro: 'Pro', + creditsAvailableLabel: 'Créditos Disponibles', + manageSubscription: 'Administrar Suscripción', + subscriptionTitle: 'Suscripciones', + subscriptionHint: 'Elige un plan y desbloquea escaneos con IA más potentes y más créditos.', + freePlanName: 'Gratis', + freePlanPrice: '0 EUR / Mes', + proPlanName: 'Pro', + proPlanPrice: '4.99 EUR / Mes', + proBadgeText: 'RECOMENDADO', + proYearlyPlanName: 'Pro', + proYearlyPlanPrice: '39.99 EUR / Año', + proYearlyBadgeText: 'AHORRAR', + proBenefits: [ + '250 créditos cada mes', + 'Escaneos Pro con GPT-5.4', + 'Historial y galería ilimitados', + 'Doctor de plantas de IA incluido', + 'Soporte prioritario' + ], + topupTitle: 'Recargar Créditos', + topupSmall: '25 Créditos – 1,99 €', + topupMedium: '120 Créditos – 6,99 €', + topupLarge: '300 Créditos – 12,99 €', + topupBestValue: 'MEJOR OFERTA', + cancelTitle: 'Lamentamos verte ir', + cancelQuestion: '¿Podemos saber por qué cancelas?', + reasonTooExpensive: 'Es muy caro', + reasonNotUsing: 'No lo uso suficiente', + reasonOther: 'Otra razón', + offerTitle: '¡Un regalo para ti!', + offerText: 'Quédate y obtén el próximo mes por solo 2,49 € (50% de descuento).', + offerAccept: 'Aceptar descuento', + offerDecline: 'No, continuar cancelando', + confirmCancelBtn: 'Cancelar ahora', + restorePurchases: 'Restaurar Compras', + autoRenewMonthly: 'Se renueva mensualmente de forma automática. Cancela cuando quieras en Ajustes de iOS.', + autoRenewYearly: 'Se renueva anualmente de forma automática. Cancela cuando quieras en Ajustes de iOS.', + manageInSettings: 'Administrar en Ajustes de iOS', + }; + } + return { + title: 'Billing & Credits', + planLabel: 'Current Plan', + planFree: 'Free', + planPro: 'Pro', + creditsAvailableLabel: 'Available Credits', + manageSubscription: 'Manage Subscription', + subscriptionTitle: 'Subscriptions', + subscriptionHint: 'Choose a plan to unlock stronger AI scans and more credits.', + freePlanName: 'Free', + freePlanPrice: '0 EUR / Month', + proPlanName: 'Pro', + proPlanPrice: '4.99 EUR / Month', + proBadgeText: 'RECOMMENDED', + proYearlyPlanName: 'Pro', + proYearlyPlanPrice: '39.99 EUR / Year', + proYearlyBadgeText: 'SAVE', + proBenefits: [ + '250 credits every month', + 'Pro scans with GPT-5.4', + 'Unlimited history & gallery', + 'AI Plant Doctor included', + 'Priority support' + ], + topupTitle: 'Topup Credits', + topupSmall: '25 Credits – €1.99', + topupMedium: '120 Credits – €6.99', + topupLarge: '300 Credits – €12.99', + topupBestValue: 'BEST VALUE', + cancelTitle: 'Sorry to see you go', + cancelQuestion: 'May we ask why you are cancelling?', + reasonTooExpensive: 'It is too expensive', + reasonNotUsing: 'I don\'t use it enough', + reasonOther: 'Other reason', + offerTitle: 'A gift for you!', + offerText: 'Stay with us and get your next month for just €2.49 (50% off).', + offerAccept: 'Claim discount', + offerDecline: 'No, continue cancelling', + confirmCancelBtn: 'Cancel now', + restorePurchases: 'Restore Purchases', + autoRenewMonthly: 'Auto-renews monthly. Cancel anytime in iOS Settings.', + autoRenewYearly: 'Auto-renews annually. Cancel anytime in iOS Settings.', + manageInSettings: 'Manage in iOS Settings', + }; +}; + + + +export default function BillingScreen() { + const router = useRouter(); + const { isDarkMode, language, billingSummary, isLoadingBilling, simulatePurchase, simulateWebhookEvent, syncRevenueCatState, colorPalette, session } = useApp(); + const colors = useColors(isDarkMode, colorPalette); + const copy = getBillingCopy(language); + const isExpoGo = Constants.appOwnership === 'expo'; + + const [subModalVisible, setSubModalVisible] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [storeReady, setStoreReady] = useState(isExpoGo); + const [subscriptionPackages, setSubscriptionPackages] = useState({}); + const [topupProducts, setTopupProducts] = useState({}); + + // Cancel Flow State + const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none'); + + const planId = billingSummary?.entitlement?.plan || 'free'; + const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? '--'); + + useEffect(() => { + let cancelled = false; + + const loadStoreProducts = async () => { + if (isExpoGo) { + setStoreReady(true); + return; + } + + try { + const [offerings, topups] = await Promise.all([ + Purchases.getOfferings(), + Purchases.getProducts(['topup_small', 'topup_medium', 'topup_large'], PRODUCT_CATEGORY.NON_SUBSCRIPTION), + ]); + + if (cancelled) return; + + const currentOffering = offerings.current; + const resolvedPackages = resolveSubscriptionPackages(currentOffering); + if (!resolvedPackages.monthly_pro || !resolvedPackages.yearly_pro) { + console.warn('[Billing] RevenueCat offering missing expected subscription packages', summarizeOfferingPackages(currentOffering)); + } + + setSubscriptionPackages(resolvedPackages); + + setTopupProducts({ + topup_small: topups.find((product) => product.identifier === 'topup_small'), + topup_medium: topups.find((product) => product.identifier === 'topup_medium'), + topup_large: topups.find((product) => product.identifier === 'topup_large'), + }); + } catch (error) { + console.warn('Failed to load RevenueCat products', error); + } finally { + if (!cancelled) { + setStoreReady(true); + } + } + }; + + loadStoreProducts(); + + return () => { + cancelled = true; + }; + }, [isExpoGo]); + + const monthlyPackage = subscriptionPackages.monthly_pro; + const yearlyPackage = subscriptionPackages.yearly_pro; + + const monthlyPrice = monthlyPackage?.product.priceString ?? copy.proPlanPrice; + const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPrice; + + const topupLabels = useMemo(() => ({ + topup_small: topupProducts.topup_small ? `25 Credits - ${topupProducts.topup_small.priceString}` : copy.topupSmall, + topup_medium: topupProducts.topup_medium ? `120 Credits - ${topupProducts.topup_medium.priceString}` : copy.topupMedium, + topup_large: topupProducts.topup_large ? `300 Credits - ${topupProducts.topup_large.priceString}` : copy.topupLarge, + }), [copy.topupLarge, copy.topupMedium, copy.topupSmall, topupProducts.topup_large, topupProducts.topup_medium, topupProducts.topup_small]); + + const openAppleSubscriptions = async () => { + await Linking.openURL('itms-apps://apps.apple.com/account/subscriptions'); + }; + + const handlePurchase = async (productId: PurchaseProductId) => { + setIsUpdating(true); + try { + if (isExpoGo) { + // ExpoGo has no native RevenueCat — use simulation for development only + await simulatePurchase(productId); + } else { + if (productId === 'monthly_pro' || productId === 'yearly_pro') { + if (planId === 'pro') { + await openAppleSubscriptions(); + setSubModalVisible(false); + return; + } + const selectedPackage = productId === 'monthly_pro' ? monthlyPackage : yearlyPackage; + const latestOffering = !selectedPackage + ? await Purchases.getOfferings().then((offerings) => offerings.current) + : null; + if (!selectedPackage) { + console.warn('[Billing] Purchase blocked because subscription package was not resolved', { + productId, + offering: summarizeOfferingPackages(latestOffering), + }); + throw new Error('Abo-Paket konnte nicht geladen werden. Bitte RevenueCat Offering prüfen.'); + } + await Purchases.purchasePackage(selectedPackage); + // Derive plan locally from RevenueCat — backend sync via webhook comes later (Step 3) + const customerInfo = await Purchases.getCustomerInfo(); + await syncRevenueCatState(customerInfo as any, 'subscription_purchase'); + } else { + const selectedProduct = topupProducts[productId]; + if (!selectedProduct) { + throw new Error('Top-up Produkt konnte nicht geladen werden. Bitte Store-Produkt IDs prüfen.'); + } + await Purchases.purchaseStoreProduct(selectedProduct); + const customerInfo = await Purchases.getCustomerInfo(); + await syncRevenueCatState(customerInfo as any, 'topup_purchase'); + } + } + setSubModalVisible(false); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const userCancelled = typeof e === 'object' && e !== null && 'userCancelled' in e && Boolean((e as { userCancelled?: boolean }).userCancelled); + + if (userCancelled) { + return; + } + + // RevenueCat error code 7 = PRODUCT_ALREADY_PURCHASED — the Apple ID already + // owns this subscription on a different GreenLens account. Silently dismiss; + // the current account stays free. The user can restore via "Käufe wiederherstellen". + const rcErrorCode = typeof e === 'object' && e !== null ? (e as Record).code : undefined; + if (rcErrorCode === 7) { + setSubModalVisible(false); + return; + } + + console.error('Payment failed', e); + Alert.alert('Unerwarteter Fehler', msg); + } finally { + setIsUpdating(false); + } + }; + + const handleRestore = async () => { + setIsUpdating(true); + try { + if (!isExpoGo) { + const customerInfo = await Purchases.restorePurchases(); + await syncRevenueCatState(customerInfo as any, 'restore'); + } + Alert.alert(copy.restorePurchases, '✓'); + } catch (e) { + Alert.alert('Error', e instanceof Error ? e.message : String(e)); + } finally { + setIsUpdating(false); + } + }; + + const handleDowngrade = async () => { + if (planId === 'free') return; + if (!isExpoGo) { + await openAppleSubscriptions(); + return; + } + // Expo Go / dev only: simulate cancel flow + setCancelStep('survey'); + }; + + const finalizeCancel = async () => { + setIsUpdating(true); + try { + await simulateWebhookEvent('entitlement_revoked'); + setCancelStep('none'); + setSubModalVisible(false); + } catch (e) { + console.error('Downgrade failed', e); + } finally { + setIsUpdating(false); + } + }; + + return ( + + + + + router.back()} style={styles.backButton}> + + + {copy.title} + + + + + {isLoadingBilling && session ? ( + + ) : ( + <> + {session && ( + + {copy.planLabel} + + + {planId === 'pro' ? copy.planPro : copy.planFree} + + setSubModalVisible(true)} + > + {copy.manageSubscription} + + + + {copy.creditsAvailableLabel} + {credits} + + )} + {!session && ( + + Subscription Plans + + Choose a plan to unlock AI plant scans and care features. + + + {/* Monthly */} + + + GreenLens Pro + + MONTHLY + + + {monthlyPrice} + {copy.autoRenewMonthly} + + {copy.proBenefits.map((b, i) => ( + + + {b} + + ))} + + handlePurchase('monthly_pro')} + disabled={isUpdating || !storeReady} + > + Subscribe Monthly + + + + {/* Yearly */} + + + GreenLens Pro + + YEARLY + + + {yearlyPrice} + {copy.autoRenewYearly} + + {copy.proBenefits.map((b, i) => ( + + + {b} + + ))} + + handlePurchase('yearly_pro')} + disabled={isUpdating || !storeReady} + > + Subscribe Yearly + + + + + Linking.openURL('https://greenlenspro.com/privacy')}> + Privacy Policy + + · + Linking.openURL('https://greenlenspro.com/terms')}> + Terms of Use + + + + {copy.restorePurchases} + + + )} + + + {copy.topupTitle} + + {([ + { id: 'topup_small' as PurchaseProductId, label: topupLabels.topup_small }, + { id: 'topup_medium' as PurchaseProductId, label: topupLabels.topup_medium, badge: copy.topupBestValue }, + { id: 'topup_large' as PurchaseProductId, label: topupLabels.topup_large }, + ] as { id: PurchaseProductId; label: string; badge?: string }[]).map((pack) => ( + handlePurchase(pack.id)} + disabled={isUpdating || !storeReady} + > + + + + {isUpdating ? '...' : pack.label} + + + {pack.badge && ( + + {pack.badge} + + )} + + ))} + + + Linking.openURL('https://greenlenspro.com/privacy')}> + Privacy Policy + + · + Linking.openURL('https://greenlenspro.com/terms')}> + Terms of Use + + + + {copy.restorePurchases} + + + + )} + + + + setSubModalVisible(false)}> + + + + + {cancelStep === 'survey' ? copy.cancelTitle : cancelStep === 'offer' ? copy.offerTitle : copy.subscriptionTitle} + + { + setSubModalVisible(false); + setCancelStep('none'); + }}> + + + + + {cancelStep === 'none' ? ( + <> + {copy.subscriptionHint} + + + + {copy.freePlanName} + {copy.freePlanPrice} + + {planId === 'free' && } + + + handlePurchase('monthly_pro')} + disabled={isUpdating || !storeReady} + > + + + {copy.proPlanName} + + {copy.proBadgeText} + + + {monthlyPrice} + {copy.autoRenewMonthly} + + + {copy.proBenefits.map((b, i) => ( + + + {b} + + ))} + + + {planId === 'pro' && } + + + handlePurchase('yearly_pro')} + disabled={isUpdating || !storeReady} + > + + + {copy.proYearlyPlanName} + + {copy.proYearlyBadgeText} + + + {yearlyPrice} + {copy.autoRenewYearly} + + + {copy.proBenefits.map((b, i) => ( + + + {b} + + ))} + + + {planId === 'pro' && } + + + + Linking.openURL('https://greenlenspro.com/privacy')}> + Privacy Policy + + · + Linking.openURL('https://greenlenspro.com/terms')}> + Terms of Use + + + + {copy.restorePurchases} + + + ) : cancelStep === 'survey' ? ( + + {copy.cancelQuestion} + + {[ + { id: 'expensive', label: copy.reasonTooExpensive, icon: 'cash-outline' }, + { id: 'not_using', label: copy.reasonNotUsing, icon: 'calendar-outline' }, + { id: 'other', label: copy.reasonOther, icon: 'ellipsis-horizontal-outline' }, + ].map((reason) => ( + { + setCancelStep('offer'); + }} + > + + + + {reason.label} + + + ))} + + + ) : ( + + + + + + {copy.offerText} + + { + // Handle applying discount here (future implementation) + Alert.alert('Erfolg', 'Rabatt angewendet! (Mock)'); + setCancelStep('none'); + setSubModalVisible(false); + }} + > + {copy.offerAccept} + + + + + {copy.offerDecline} + + + )} + {(isUpdating || (!storeReady && cancelStep === 'none')) && } + + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { flex: 1 }, + header: { flexDirection: 'row', alignItems: 'center', padding: 16 }, + backButton: { width: 40, height: 40, justifyContent: 'center' }, + title: { flex: 1, fontSize: 20, fontWeight: '700', textAlign: 'center' }, + scrollContent: { padding: 16, gap: 16 }, + card: { + padding: 16, + borderRadius: 16, + borderWidth: StyleSheet.hairlineWidth, + }, + sectionTitle: { + fontSize: 14, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 0.5, + marginBottom: 8, + }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + value: { + fontSize: 18, + fontWeight: '600', + }, + manageBtn: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + }, + manageBtnText: { + color: '#fff', + fontSize: 14, + fontWeight: '600', + }, + creditsValue: { + fontSize: 32, + fontWeight: '700', + }, + topupBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 12, + borderRadius: 12, + borderWidth: 2, + gap: 8, + }, + topupText: { + fontSize: 16, + fontWeight: '600', + }, + modalOverlay: { + flex: 1, + backgroundColor: '#00000080', + justifyContent: 'flex-end', + }, + modalContent: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + padding: 24, + borderTopWidth: StyleSheet.hairlineWidth, + paddingBottom: 40, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + modalTitle: { + fontSize: 20, + fontWeight: '700', + }, + modalHint: { + fontSize: 14, + marginBottom: 24, + }, + plansContainer: { + gap: 12, + }, + planOption: { + padding: 16, + borderRadius: 12, + borderWidth: 2, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + planName: { + fontSize: 18, + fontWeight: '600', + }, + planPrice: { + fontSize: 14, + }, + planHeaderRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 2, + }, + proBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 6, + }, + proBadgeText: { + color: '#fff', + fontSize: 10, + fontWeight: '800', + }, + proBenefits: { + marginTop: 12, + gap: 6, + }, + benefitRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + benefitText: { + fontSize: 12, + fontWeight: '500', + }, + cancelFlowContainer: { + marginTop: 8, + }, + cancelHint: { + fontSize: 15, + marginBottom: 16, + }, + reasonList: { + gap: 12, + }, + reasonOption: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderWidth: 1, + borderRadius: 12, + }, + reasonIcon: { + width: 36, + height: 36, + borderRadius: 18, + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + reasonText: { + flex: 1, + fontSize: 16, + fontWeight: '500', + }, + offerCard: { + borderRadius: 16, + padding: 24, + alignItems: 'center', + marginBottom: 16, + }, + offerIconWrap: { + width: 56, + height: 56, + borderRadius: 28, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 16, + }, + offerText: { + fontSize: 16, + textAlign: 'center', + lineHeight: 24, + marginBottom: 24, + fontWeight: '500', + }, + offerAcceptBtn: { + paddingHorizontal: 24, + paddingVertical: 14, + borderRadius: 24, + width: '100%', + alignItems: 'center', + }, + offerAcceptBtnText: { + color: '#fff', + fontSize: 16, + fontWeight: '700', + }, + offerDeclineBtn: { + paddingVertical: 12, + alignItems: 'center', + }, + offerDeclineBtnText: { + fontSize: 15, + fontWeight: '500', + }, + guestPlanCard: { + borderWidth: 2, + borderRadius: 12, + padding: 16, + }, + guestPlanHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 4, + }, + guestPlanName: { + fontSize: 18, + fontWeight: '700', + }, + guestPlanPrice: { + fontSize: 22, + fontWeight: '700', + marginBottom: 2, + }, + guestPlanRenew: { + fontSize: 12, + }, + guestSubscribeBtn: { + marginTop: 14, + paddingVertical: 12, + borderRadius: 10, + alignItems: 'center', + }, + legalLinksRow: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + marginTop: 16, + }, + legalLink: { + fontSize: 12, + fontWeight: '500', + textDecorationLine: 'underline', + }, + legalSep: { + fontSize: 12, + }, + restoreBtn: { + alignItems: 'center', + paddingVertical: 8, + }, + autoRenewText: { + fontSize: 11, + marginTop: 2, + marginBottom: 4, + }, +}); diff --git a/server/index.js b/server/index.js index 044d385..06c1dfd 100644 --- a/server/index.js +++ b/server/index.js @@ -834,15 +834,46 @@ app.post('/v1/health-check', async (request, response) => { }); const analysis = analysisResponse?.analysis; if (!analysis) { + // All models in the chain failed (timeout, quota, network) — return a graceful + // "unavailable" result instead of PROVIDER_ERROR so the user never sees an error alert. + // Credits are NOT charged. Response is NOT cached so the user can retry. console.warn('Health check analysis was null — all models returned unusable output.', { attemptedModels: analysisResponse?.attemptedModels, modelUsed: analysisResponse?.modelUsed, }); - const error = new Error( - `Health check AI failed. Tried: ${(analysisResponse?.attemptedModels || []).join(', ')}. Verify API key, model access, and network.` - ); - error.code = 'PROVIDER_ERROR'; - throw error; + const unavailableIssue = language === 'de' + ? 'Die KI-Analyse ist gerade nicht verfügbar. Bitte versuche es in einem Moment erneut.' + : language === 'es' + ? 'El análisis de IA no está disponible ahora. Inténtalo de nuevo en un momento.' + : 'AI analysis is temporarily unavailable. Please try again in a moment.'; + const unavailableAction = language === 'de' + ? 'Erneut scannen wenn die Verbindung stabil ist.' + : language === 'es' + ? 'Volver a escanear cuando la conexión sea estable.' + : 'Try scanning again when your connection is stable.'; + const fallbackHealthCheck = { + generatedAt: nowIso(), + overallHealthScore: 50, + status: 'watch', + likelyIssues: [{ + title: language === 'de' ? 'Analyse nicht verfügbar' : language === 'es' ? 'Análisis no disponible' : 'Analysis unavailable', + confidence: 0.1, + details: unavailableIssue, + }], + actionsNow: [unavailableAction], + plan7Days: [unavailableAction], + creditsCharged: 0, + imageUri, + }; + const fallbackPayload = { + healthCheck: fallbackHealthCheck, + creditsCharged: 0, + modelUsed: null, + modelFallbackCount: Math.max((analysisResponse?.attemptedModels?.length || 0) - 1, 0), + billing: await getBillingSummary(db, userId), + }; + response.status(200).json(fallbackPayload); + return; } let creditsCharged = 0; From d0a13fa4f02191dccd6b58afe0276725c6d12110 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Wed, 8 Apr 2026 11:53:38 +0200 Subject: [PATCH 06/10] feat: implement multi-language support, SEO metadata, schema markup, and legal pages --- greenlns-landing/app/imprint/page.tsx | 22 ++- greenlns-landing/app/layout.tsx | 186 ++++++++++++++--------- greenlns-landing/app/page.tsx | 109 +++++++++---- greenlns-landing/app/sitemap.ts | 32 ++-- greenlns-landing/context/LangContext.tsx | 73 +++++---- greenlns-landing/lib/site.ts | 22 +-- greenlns-landing/public/og-image.png | Bin 0 -> 171832 bytes 7 files changed, 279 insertions(+), 165 deletions(-) create mode 100644 greenlns-landing/public/og-image.png diff --git a/greenlns-landing/app/imprint/page.tsx b/greenlns-landing/app/imprint/page.tsx index b978eac..8dca5a4 100644 --- a/greenlns-landing/app/imprint/page.tsx +++ b/greenlns-landing/app/imprint/page.tsx @@ -12,7 +12,6 @@ const CONTENT = { contactLabel: 'Kontakt', registryLabel: 'Register', vatLabel: 'USt-ID', - note: 'Vor der Veroeffentlichung muessen alle rechtlichen Angaben mit den echten Firmendaten ersetzt werden.', }, en: { title: 'Imprint', @@ -22,7 +21,6 @@ const CONTENT = { contactLabel: 'Contact', registryLabel: 'Registry', vatLabel: 'VAT ID', - note: 'Replace all legal placeholders with your real company details before publishing the site.', }, es: { title: 'Aviso Legal', @@ -32,7 +30,6 @@ const CONTENT = { contactLabel: 'Contacto', registryLabel: 'Registro', vatLabel: 'IVA', - note: 'Sustituye todos los marcadores legales por tus datos reales antes de publicar el sitio.', }, } @@ -47,9 +44,9 @@ export default function ImprintPage() {

{c.companyLabel}: {siteConfig.company.legalName}

-

- {c.addressLabel}: {siteConfig.company.addressLine1} -

+ {siteConfig.company.addressLine1 ? ( +

{c.addressLabel}: {siteConfig.company.addressLine1}

+ ) : null} {siteConfig.company.addressLine2 ?

{siteConfig.company.addressLine2}

: null}

{siteConfig.company.country}

@@ -58,13 +55,12 @@ export default function ImprintPage() {

{c.contactLabel}: {siteConfig.legalEmail}

-

- {c.registryLabel}: {siteConfig.company.registry} -

-

- {c.vatLabel}: {siteConfig.company.vatId} -

-

{c.note}

+ {siteConfig.company.registry ? ( +

{c.registryLabel}: {siteConfig.company.registry}

+ ) : null} + {siteConfig.company.vatId ? ( +

{c.vatLabel}: {siteConfig.company.vatId}

+ ) : null} ) diff --git a/greenlns-landing/app/layout.tsx b/greenlns-landing/app/layout.tsx index c88ee1d..c22c4cb 100644 --- a/greenlns-landing/app/layout.tsx +++ b/greenlns-landing/app/layout.tsx @@ -1,72 +1,114 @@ -import type { Metadata } from 'next' -import './globals.css' -import { LangProvider } from '@/context/LangContext' -import { siteConfig } from '@/lib/site' - -export const metadata: Metadata = { - metadataBase: new URL(siteConfig.domain), - title: { - default: 'GreenLens - Plant Identifier and Care Planner', - template: '%s | GreenLens', - }, - description: - 'GreenLens helps you identify plants, organize your collection, and keep up with care routines in one app.', - keywords: [ - 'plant identifier by picture', - 'plant care app', - 'watering reminders', - 'houseplant tracker', - 'plant identification', - 'plant health check', - 'Pflanzen App', - 'GreenLens', - ], - authors: [{ name: siteConfig.name }], - openGraph: { - title: 'GreenLens - Plant Identifier and Care Planner', - description: 'Identify plants, get care guidance, and manage your collection with GreenLens.', - type: 'website', - url: siteConfig.domain, - }, - alternates: { - canonical: '/', - languages: { - de: '/', - en: '/', - es: '/', - 'x-default': '/', - }, - }, -} - -export default function RootLayout({ children }: { children: React.ReactNode }) { - return ( - - - - - -