Merge branch 'main' of git.bizmatch.net:tknuth/Greenlens

This commit is contained in:
2026-04-11 16:34:23 -05:00
43 changed files with 2421 additions and 673 deletions

49
SplitImage.ps1 Normal file
View File

@@ -0,0 +1,49 @@
param (
[string]$ImagePath,
[string]$OutputDir,
[string]$Prefix
)
Add-Type -AssemblyName System.Drawing
$img = [System.Drawing.Image]::FromFile($ImagePath)
$w = $img.Width
$h = $img.Height
$targetWidth = $w
$targetHeight = [math]::Floor($w / 3.0)
$left = 0
$top = 0
if ($targetHeight -gt $h) {
$targetHeight = $h
$targetWidth = $h * 3
$left = [math]::Floor(($w - $targetWidth) / 2.0)
} else {
$top = [math]::Floor(($h - $targetHeight) / 2.0)
}
[int]$sqSize = [math]::Floor($targetWidth / 3.0)
[int]$leftInt = $left
[int]$topInt = $top
for ($i = 0; $i -lt 3; $i++) {
$bmp = New-Object System.Drawing.Bitmap -ArgumentList $sqSize, $sqSize
$g = [System.Drawing.Graphics]::FromImage($bmp)
[int]$rx = $leftInt + ($i * $sqSize)
$srcRect = New-Object System.Drawing.Rectangle -ArgumentList $rx, $topInt, $sqSize, $sqSize
$destRect = New-Object System.Drawing.Rectangle -ArgumentList 0, 0, $sqSize, $sqSize
$g.DrawImage($img, $destRect, $srcRect, [System.Drawing.GraphicsUnit]::Pixel)
$g.Dispose()
$outFile = Join-Path $OutputDir ("{0}_part{1}.png" -f $Prefix, ($i + 1))
$bmp.Save($outFile, [System.Drawing.Imaging.ImageFormat]::Png)
$bmp.Dispose()
}
$img.Dispose()
Write-Output "Successfully split the image into 3 pieces."

View File

@@ -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');
});
});

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "GreenLens", "name": "GreenLens",
"slug": "greenlens", "slug": "greenlens",
"version": "2.1.6", "version": "2.2.1",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
@@ -68,4 +68,4 @@
} }
} }
} }
} }

View File

@@ -4,68 +4,68 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import Purchases, { import Purchases, {
PACKAGE_TYPE, PACKAGE_TYPE,
PRODUCT_CATEGORY, PRODUCT_CATEGORY,
PurchasesOffering, PurchasesOffering,
PurchasesPackage, PurchasesPackage,
PurchasesStoreProduct, PurchasesStoreProduct,
} from 'react-native-purchases'; } from 'react-native-purchases';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors'; import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop'; import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { Language } from '../../types'; import { Language } from '../../types';
import { PurchaseProductId } from '../../services/backend/contracts'; import { PurchaseProductId } from '../../services/backend/contracts';
type SubscriptionProductId = 'monthly_pro' | 'yearly_pro'; type SubscriptionProductId = 'monthly_pro' | 'yearly_pro';
type TopupProductId = Extract<PurchaseProductId, 'topup_small' | 'topup_medium' | 'topup_large'>; type TopupProductId = Extract<PurchaseProductId, 'topup_small' | 'topup_medium' | 'topup_large'>;
type SubscriptionPackages = Partial<Record<SubscriptionProductId, PurchasesPackage>>; type SubscriptionPackages = Partial<Record<SubscriptionProductId, PurchasesPackage>>;
type TopupProducts = Partial<Record<TopupProductId, PurchasesStoreProduct>>; type TopupProducts = Partial<Record<TopupProductId, PurchasesStoreProduct>>;
const isMatchingPackage = ( const isMatchingPackage = (
pkg: PurchasesPackage, pkg: PurchasesPackage,
productId: SubscriptionProductId, productId: SubscriptionProductId,
expectedPackageType: PACKAGE_TYPE, expectedPackageType: PACKAGE_TYPE,
) => { ) => {
return ( return (
pkg.product.identifier === productId pkg.product.identifier === productId
|| pkg.identifier === productId || pkg.identifier === productId
|| pkg.packageType === expectedPackageType || pkg.packageType === expectedPackageType
); );
}; };
const resolveSubscriptionPackages = (offering: PurchasesOffering | null): SubscriptionPackages => { const resolveSubscriptionPackages = (offering: PurchasesOffering | null): SubscriptionPackages => {
if (!offering) { if (!offering) {
return {}; return {};
} }
const availablePackages = [ const availablePackages = [
offering.monthly, offering.monthly,
offering.annual, offering.annual,
...offering.availablePackages, ...offering.availablePackages,
].filter((value): value is PurchasesPackage => Boolean(value)); ].filter((value): value is PurchasesPackage => Boolean(value));
return { return {
monthly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'monthly_pro', PACKAGE_TYPE.MONTHLY)), monthly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'monthly_pro', PACKAGE_TYPE.MONTHLY)),
yearly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'yearly_pro', PACKAGE_TYPE.ANNUAL)), yearly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'yearly_pro', PACKAGE_TYPE.ANNUAL)),
}; };
}; };
const summarizeOfferingPackages = (offering: PurchasesOffering | null) => { const summarizeOfferingPackages = (offering: PurchasesOffering | null) => {
if (!offering) { if (!offering) {
return { identifier: null, packages: [] as Array<Record<string, string | null>> }; return { identifier: null, packages: [] as Array<Record<string, string | null>> };
} }
return { return {
identifier: offering.identifier, identifier: offering.identifier,
packages: offering.availablePackages.map((pkg) => ({ packages: offering.availablePackages.map((pkg) => ({
identifier: pkg.identifier, identifier: pkg.identifier,
packageType: pkg.packageType, packageType: pkg.packageType,
productIdentifier: pkg.product.identifier, productIdentifier: pkg.product.identifier,
priceString: pkg.product.priceString, priceString: pkg.product.priceString,
})), })),
}; };
}; };
const getBillingCopy = (language: Language) => { const getBillingCopy = (language: Language) => {
if (language === 'de') { if (language === 'de') {
@@ -81,10 +81,10 @@ const getBillingCopy = (language: Language) => {
freePlanName: 'Free', freePlanName: 'Free',
freePlanPrice: '0 EUR / Monat', freePlanPrice: '0 EUR / Monat',
proPlanName: 'Pro', proPlanName: 'Pro',
proPlanPrice: '4.99 EUR / Monat', proPlanPrice: '4,99 / Monat',
proBadgeText: 'EMPFOHLEN', proBadgeText: 'EMPFOHLEN',
proYearlyPlanName: 'Pro', proYearlyPlanName: 'Pro',
proYearlyPlanPrice: '39.99 EUR / Jahr', proYearlyPlanPrice: '39,99 / Jahr',
proYearlyBadgeText: 'SPAREN', proYearlyBadgeText: 'SPAREN',
proBenefits: [ proBenefits: [
'250 Credits jeden Monat', '250 Credits jeden Monat',
@@ -209,7 +209,7 @@ const getBillingCopy = (language: Language) => {
export default function BillingScreen() { export default function BillingScreen() {
const router = useRouter(); const router = useRouter();
const { isDarkMode, language, billingSummary, isLoadingBilling, simulatePurchase, simulateWebhookEvent, syncRevenueCatState, colorPalette, session } = useApp(); const { isDarkMode, language, billingSummary, isLoadingBilling, simulatePurchase, simulateWebhookEvent, syncRevenueCatState, colorPalette, session } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const copy = getBillingCopy(language); const copy = getBillingCopy(language);
const isExpoGo = Constants.appOwnership === 'expo'; const isExpoGo = Constants.appOwnership === 'expo';
@@ -217,8 +217,8 @@ export default function BillingScreen() {
const [subModalVisible, setSubModalVisible] = useState(false); const [subModalVisible, setSubModalVisible] = useState(false);
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const [storeReady, setStoreReady] = useState(isExpoGo); const [storeReady, setStoreReady] = useState(isExpoGo);
const [subscriptionPackages, setSubscriptionPackages] = useState<SubscriptionPackages>({}); const [subscriptionPackages, setSubscriptionPackages] = useState<SubscriptionPackages>({});
const [topupProducts, setTopupProducts] = useState<TopupProducts>({}); const [topupProducts, setTopupProducts] = useState<TopupProducts>({});
// Cancel Flow State // Cancel Flow State
const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none'); const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none');
@@ -243,13 +243,13 @@ export default function BillingScreen() {
if (cancelled) return; if (cancelled) return;
const currentOffering = offerings.current; const currentOffering = offerings.current;
const resolvedPackages = resolveSubscriptionPackages(currentOffering); const resolvedPackages = resolveSubscriptionPackages(currentOffering);
if (!resolvedPackages.monthly_pro || !resolvedPackages.yearly_pro) { if (!resolvedPackages.monthly_pro || !resolvedPackages.yearly_pro) {
console.warn('[Billing] RevenueCat offering missing expected subscription packages', summarizeOfferingPackages(currentOffering)); console.warn('[Billing] RevenueCat offering missing expected subscription packages', summarizeOfferingPackages(currentOffering));
} }
setSubscriptionPackages(resolvedPackages); setSubscriptionPackages(resolvedPackages);
setTopupProducts({ setTopupProducts({
topup_small: topups.find((product) => product.identifier === 'topup_small'), topup_small: topups.find((product) => product.identifier === 'topup_small'),
@@ -278,15 +278,15 @@ export default function BillingScreen() {
const monthlyPrice = monthlyPackage?.product.priceString ?? copy.proPlanPrice; const monthlyPrice = monthlyPackage?.product.priceString ?? copy.proPlanPrice;
const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPrice; const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPrice;
const topupLabels = useMemo(() => ({ const topupLabels = useMemo(() => ({
topup_small: topupProducts.topup_small ? `25 Credits - ${topupProducts.topup_small.priceString}` : copy.topupSmall, topup_small: topupProducts.topup_small ? `25 Credits - ${topupProducts.topup_small.priceString}` : copy.topupSmall,
topup_medium: topupProducts.topup_medium ? `120 Credits - ${topupProducts.topup_medium.priceString}` : copy.topupMedium, topup_medium: topupProducts.topup_medium ? `120 Credits - ${topupProducts.topup_medium.priceString}` : copy.topupMedium,
topup_large: topupProducts.topup_large ? `300 Credits - ${topupProducts.topup_large.priceString}` : copy.topupLarge, topup_large: topupProducts.topup_large ? `300 Credits - ${topupProducts.topup_large.priceString}` : copy.topupLarge,
}), [copy.topupLarge, copy.topupMedium, copy.topupSmall, topupProducts.topup_large, topupProducts.topup_medium, topupProducts.topup_small]); }), [copy.topupLarge, copy.topupMedium, copy.topupSmall, topupProducts.topup_large, topupProducts.topup_medium, topupProducts.topup_small]);
const openAppleSubscriptions = async () => { const openAppleSubscriptions = async () => {
await Linking.openURL('itms-apps://apps.apple.com/account/subscriptions'); await Linking.openURL('itms-apps://apps.apple.com/account/subscriptions');
}; };
const handlePurchase = async (productId: PurchaseProductId) => { const handlePurchase = async (productId: PurchaseProductId) => {
setIsUpdating(true); setIsUpdating(true);
@@ -296,35 +296,35 @@ export default function BillingScreen() {
await simulatePurchase(productId); await simulatePurchase(productId);
} else { } else {
if (productId === 'monthly_pro' || productId === 'yearly_pro') { if (productId === 'monthly_pro' || productId === 'yearly_pro') {
if (planId === 'pro') { if (planId === 'pro') {
await openAppleSubscriptions(); await openAppleSubscriptions();
setSubModalVisible(false); setSubModalVisible(false);
return; return;
} }
const selectedPackage = productId === 'monthly_pro' ? monthlyPackage : yearlyPackage; const selectedPackage = productId === 'monthly_pro' ? monthlyPackage : yearlyPackage;
const latestOffering = !selectedPackage const latestOffering = !selectedPackage
? await Purchases.getOfferings().then((offerings) => offerings.current) ? await Purchases.getOfferings().then((offerings) => offerings.current)
: null; : null;
if (!selectedPackage) { if (!selectedPackage) {
console.warn('[Billing] Purchase blocked because subscription package was not resolved', { console.warn('[Billing] Purchase blocked because subscription package was not resolved', {
productId, productId,
offering: summarizeOfferingPackages(latestOffering), offering: summarizeOfferingPackages(latestOffering),
}); });
throw new Error('Abo-Paket konnte nicht geladen werden. Bitte RevenueCat Offering prüfen.'); throw new Error('Abo-Paket konnte nicht geladen werden. Bitte RevenueCat Offering prüfen.');
} }
await Purchases.purchasePackage(selectedPackage); await Purchases.purchasePackage(selectedPackage);
// Derive plan locally from RevenueCat — backend sync via webhook comes later (Step 3) // Derive plan locally from RevenueCat — backend sync via webhook comes later (Step 3)
const customerInfo = await Purchases.getCustomerInfo(); const customerInfo = await Purchases.getCustomerInfo();
await syncRevenueCatState(customerInfo as any, 'subscription_purchase'); await syncRevenueCatState(customerInfo as any, 'subscription_purchase');
} else { } else {
const selectedProduct = topupProducts[productId]; const selectedProduct = topupProducts[productId];
if (!selectedProduct) { if (!selectedProduct) {
throw new Error('Top-up Produkt konnte nicht geladen werden. Bitte Store-Produkt IDs prüfen.'); throw new Error('Top-up Produkt konnte nicht geladen werden. Bitte Store-Produkt IDs prüfen.');
} }
await Purchases.purchaseStoreProduct(selectedProduct); await Purchases.purchaseStoreProduct(selectedProduct);
const customerInfo = await Purchases.getCustomerInfo(); const customerInfo = await Purchases.getCustomerInfo();
await syncRevenueCatState(customerInfo as any, 'topup_purchase'); await syncRevenueCatState(customerInfo as any, 'topup_purchase');
} }
} }
setSubModalVisible(false); setSubModalVisible(false);
} catch (e) { } catch (e) {
@@ -335,6 +335,15 @@ export default function BillingScreen() {
return; 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<string, unknown>).code : undefined;
if (rcErrorCode === 7) {
setSubModalVisible(false);
return;
}
console.error('Payment failed', e); console.error('Payment failed', e);
Alert.alert('Unerwarteter Fehler', msg); Alert.alert('Unerwarteter Fehler', msg);
} finally { } finally {
@@ -346,8 +355,8 @@ export default function BillingScreen() {
setIsUpdating(true); setIsUpdating(true);
try { try {
if (!isExpoGo) { if (!isExpoGo) {
const customerInfo = await Purchases.restorePurchases(); const customerInfo = await Purchases.restorePurchases();
await syncRevenueCatState(customerInfo as any, 'restore'); await syncRevenueCatState(customerInfo as any, 'restore');
} }
Alert.alert(copy.restorePurchases, '✓'); Alert.alert(copy.restorePurchases, '✓');
} catch (e) { } catch (e) {
@@ -358,11 +367,11 @@ export default function BillingScreen() {
}; };
const handleDowngrade = async () => { const handleDowngrade = async () => {
if (planId === 'free') return; if (planId === 'free') return;
if (!isExpoGo) { if (!isExpoGo) {
await openAppleSubscriptions(); await openAppleSubscriptions();
return; return;
} }
// Expo Go / dev only: simulate cancel flow // Expo Go / dev only: simulate cancel flow
setCancelStep('survey'); setCancelStep('survey');
}; };
@@ -478,11 +487,11 @@ export default function BillingScreen() {
</View> </View>
<View style={[styles.legalLinksRow, { marginTop: 16 }]}> <View style={[styles.legalLinksRow, { marginTop: 16 }]}>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}> <TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}>
<Text style={[styles.legalLink, { color: colors.primary }]}>Privacy Policy</Text> <Text style={[styles.legalLink, { color: colors.primary }]}>Privacy Policy</Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={[styles.legalSep, { color: colors.textMuted }]}> · </Text> <Text style={[styles.legalSep, { color: colors.textMuted }]}> · </Text>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/terms')}> <TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/terms')}>
<Text style={[styles.legalLink, { color: colors.primary }]}>Terms of Use</Text> <Text style={[styles.legalLink, { color: colors.primary }]}>Terms of Use</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -532,11 +541,11 @@ export default function BillingScreen() {
))} ))}
</View> </View>
<View style={[styles.legalLinksRow, { marginTop: 12 }]}> <View style={[styles.legalLinksRow, { marginTop: 12 }]}>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}> <TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}>
<Text style={[styles.legalLink, { color: colors.primary }]}>Privacy Policy</Text> <Text style={[styles.legalLink, { color: colors.primary }]}>Privacy Policy</Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={[styles.legalSep, { color: colors.textMuted }]}> · </Text> <Text style={[styles.legalSep, { color: colors.textMuted }]}> · </Text>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/terms')}> <TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/terms')}>
<Text style={[styles.legalLink, { color: colors.primary }]}>Terms of Use</Text> <Text style={[styles.legalLink, { color: colors.primary }]}>Terms of Use</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -589,10 +598,10 @@ export default function BillingScreen() {
styles.planOption, styles.planOption,
{ borderColor: colors.border }, { borderColor: colors.border },
planId === 'pro' && { borderColor: colors.primary, backgroundColor: colors.primary + '10' } planId === 'pro' && { borderColor: colors.primary, backgroundColor: colors.primary + '10' }
]} ]}
onPress={() => handlePurchase('monthly_pro')} onPress={() => handlePurchase('monthly_pro')}
disabled={isUpdating || !storeReady} disabled={isUpdating || !storeReady}
> >
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<View style={styles.planHeaderRow}> <View style={styles.planHeaderRow}>
<Text style={[styles.planName, { color: colors.text }]}>{copy.proPlanName}</Text> <Text style={[styles.planName, { color: colors.text }]}>{copy.proPlanName}</Text>
@@ -647,11 +656,11 @@ export default function BillingScreen() {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<View style={styles.legalLinksRow}> <View style={styles.legalLinksRow}>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}> <TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}>
<Text style={[styles.legalLink, { color: colors.primary }]}>Privacy Policy</Text> <Text style={[styles.legalLink, { color: colors.primary }]}>Privacy Policy</Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={[styles.legalSep, { color: colors.textMuted }]}> · </Text> <Text style={[styles.legalSep, { color: colors.textMuted }]}> · </Text>
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/terms')}> <TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/terms')}>
<Text style={[styles.legalLink, { color: colors.primary }]}>Terms of Use</Text> <Text style={[styles.legalLink, { color: colors.primary }]}>Terms of Use</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>

View File

@@ -7,6 +7,7 @@ import { Ionicons } from '@expo/vector-icons';
import { CameraView, useCameraPermissions } from 'expo-camera'; import { CameraView, useCameraPermissions } from 'expo-camera';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import * as ImageManipulator from 'expo-image-manipulator';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { usePostHog } from 'posthog-react-native'; import { usePostHog } from 'posthog-react-native';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
@@ -14,7 +15,7 @@ import { useColors } from '../constants/Colors';
import { PlantRecognitionService } from '../services/plantRecognitionService'; import { PlantRecognitionService } from '../services/plantRecognitionService';
import { IdentificationResult } from '../types'; import { IdentificationResult } from '../types';
import { ResultCard } from '../components/ResultCard'; import { ResultCard } from '../components/ResultCard';
import { backendApiClient, isInsufficientCreditsError, isNetworkError } from '../services/backend/backendApiClient'; import { backendApiClient, isInsufficientCreditsError, isNetworkError, isTimeoutError } from '../services/backend/backendApiClient';
import { isBackendApiError } from '../services/backend/contracts'; import { isBackendApiError } from '../services/backend/contracts';
import { createIdempotencyKey } from '../utils/idempotency'; import { createIdempotencyKey } from '../utils/idempotency';
@@ -33,6 +34,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
genericErrorMessage: 'Analyse fehlgeschlagen.', genericErrorMessage: 'Analyse fehlgeschlagen.',
noConnectionTitle: 'Keine Verbindung', noConnectionTitle: 'Keine Verbindung',
noConnectionMessage: 'Keine Verbindung zum Server. Bitte prüfe deine Internetverbindung und versuche es erneut.', noConnectionMessage: 'Keine Verbindung zum Server. Bitte prüfe deine Internetverbindung und versuche es erneut.',
timeoutTitle: 'Scan zu langsam',
timeoutMessage: 'Die Analyse hat zu lange gedauert. Bitte erneut versuchen.',
retryLabel: 'Erneut versuchen', retryLabel: 'Erneut versuchen',
providerErrorMessage: 'KI-Scan gerade nicht verfügbar. Bitte versuche es erneut.', providerErrorMessage: 'KI-Scan gerade nicht verfügbar. Bitte versuche es erneut.',
healthProviderErrorMessage: 'KI-Health-Check gerade nicht verfügbar. Bitte versuche es erneut.', healthProviderErrorMessage: 'KI-Health-Check gerade nicht verfügbar. Bitte versuche es erneut.',
@@ -55,6 +58,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
genericErrorMessage: 'Analisis fallido.', genericErrorMessage: 'Analisis fallido.',
noConnectionTitle: 'Sin conexión', noConnectionTitle: 'Sin conexión',
noConnectionMessage: 'Sin conexión al servidor. Comprueba tu internet e inténtalo de nuevo.', noConnectionMessage: 'Sin conexión al servidor. Comprueba tu internet e inténtalo de nuevo.',
timeoutTitle: 'Escaneo lento',
timeoutMessage: 'El análisis tardó demasiado. Inténtalo de nuevo.',
retryLabel: 'Reintentar', retryLabel: 'Reintentar',
providerErrorMessage: 'Escaneo IA no disponible ahora. Inténtalo de nuevo.', providerErrorMessage: 'Escaneo IA no disponible ahora. Inténtalo de nuevo.',
healthProviderErrorMessage: 'Health-check IA no disponible ahora. Inténtalo de nuevo.', healthProviderErrorMessage: 'Health-check IA no disponible ahora. Inténtalo de nuevo.',
@@ -76,6 +81,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
genericErrorMessage: 'Analysis failed.', genericErrorMessage: 'Analysis failed.',
noConnectionTitle: 'No connection', noConnectionTitle: 'No connection',
noConnectionMessage: 'Could not reach the server. Check your internet connection and try again.', noConnectionMessage: 'Could not reach the server. Check your internet connection and try again.',
timeoutTitle: 'Scan Too Slow',
timeoutMessage: 'Analysis took too long. Please try again.',
retryLabel: 'Try again', retryLabel: 'Try again',
providerErrorMessage: 'AI scan is currently unavailable. Please try again.', providerErrorMessage: 'AI scan is currently unavailable. Please try again.',
healthProviderErrorMessage: 'AI health check is currently unavailable. Please try again.', healthProviderErrorMessage: 'AI health check is currently unavailable. Please try again.',
@@ -169,6 +176,20 @@ export default function ScannerScreen() {
}; };
}, [isAnalyzing, scanLineProgress, scanPulse]); }, [isAnalyzing, scanLineProgress, scanPulse]);
const resizeForAnalysis = async (uri: string): Promise<string> => {
if (uri.startsWith('data:')) return uri;
try {
const result = await ImageManipulator.manipulateAsync(
uri,
[{ resize: { width: 1024 } }],
{ compress: 0.6, format: ImageManipulator.SaveFormat.JPEG, base64: true },
);
return result.base64 ? `data:image/jpeg;base64,${result.base64}` : result.uri;
} catch {
return uri;
}
};
const analyzeImage = async (imageUri: string, galleryImageUri?: string) => { const analyzeImage = async (imageUri: string, galleryImageUri?: string) => {
if (isAnalyzing) return; if (isAnalyzing) return;
@@ -295,6 +316,15 @@ export default function ScannerScreen() {
}, },
], ],
); );
} else if (isTimeoutError(error)) {
Alert.alert(
billingCopy.timeoutTitle,
billingCopy.timeoutMessage,
[
{ text: billingCopy.dismiss, style: 'cancel' },
{ text: billingCopy.retryLabel, onPress: () => analyzeImage(imageUri, galleryImageUri) },
],
);
} else if (isNetworkError(error)) { } else if (isNetworkError(error)) {
Alert.alert( Alert.alert(
billingCopy.noConnectionTitle, billingCopy.noConnectionTitle,
@@ -327,7 +357,7 @@ export default function ScannerScreen() {
const takePicture = async () => { const takePicture = async () => {
if (!cameraRef.current || isAnalyzing) return; if (!cameraRef.current || isAnalyzing) return;
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
const photo = await cameraRef.current.takePictureAsync({ base64: true, quality: 0.7 }); const photo = await cameraRef.current.takePictureAsync({ base64: true, quality: 0.5 });
if (photo) { if (photo) {
const analysisUri = photo.base64 const analysisUri = photo.base64
? `data:image/jpeg;base64,${photo.base64}` ? `data:image/jpeg;base64,${photo.base64}`
@@ -343,17 +373,14 @@ export default function ScannerScreen() {
const result = await ImagePicker.launchImageLibraryAsync({ const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'], mediaTypes: ['images'],
quality: 0.7, quality: 1,
base64: true, base64: false,
}); });
if (!result.canceled && result.assets[0]) { if (!result.canceled && result.assets[0]) {
const asset = result.assets[0]; const asset = result.assets[0];
const uri = asset.base64 const analysisUri = await resizeForAnalysis(asset.uri);
? `data:image/jpeg;base64,${asset.base64}` setSelectedImage(asset.uri);
: asset.uri; analyzeImage(analysisUri, asset.uri);
setSelectedImage(uri);
analyzeImage(uri, asset.uri || uri);
} }
}; };

View File

@@ -99,9 +99,9 @@ export const ResultCard: React.FC<ResultCardProps> = ({
<View style={styles.careGrid}> <View style={styles.careGrid}>
{[ {[
{ icon: 'water' as const, label: t.water, value: result.careInfo.waterIntervalDays <= 7 ? t.waterModerate : t.waterLittle, color: colors.info, bg: colors.infoSoft }, { icon: 'water' as const, label: t.water, value: t.waterEveryXDays.replace('{0}', result.careInfo.waterIntervalDays.toString()), color: colors.info, bg: colors.infoSoft },
{ icon: 'sunny' as const, label: t.light, value: result.careInfo.light, color: colors.warning, bg: colors.warningSoft }, { icon: 'sunny' as const, label: t.light, value: (result.careInfo.light && result.careInfo.light !== 'Unknown') ? result.careInfo.light : t.unknown, color: colors.warning, bg: colors.warningSoft },
{ icon: 'thermometer' as const, label: t.temp, value: result.careInfo.temp, color: colors.danger, bg: colors.dangerSoft }, { icon: 'thermometer' as const, label: t.temp, value: (result.careInfo.temp && result.careInfo.temp !== 'Unknown') ? result.careInfo.temp : t.unknown, color: colors.danger, bg: colors.dangerSoft },
].map((item) => ( ].map((item) => (
<View key={item.label} style={[styles.careCard, { backgroundColor: colors.surface, borderColor: colors.border }]}> <View key={item.label} style={[styles.careCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<View style={[styles.careIcon, { backgroundColor: item.bg }]}> <View style={[styles.careIcon, { backgroundColor: item.bg }]}>
@@ -118,8 +118,8 @@ export const ResultCard: React.FC<ResultCardProps> = ({
<Text style={[styles.detailsTitle, { color: colors.textSecondary }]}>{t.detailedCare}</Text> <Text style={[styles.detailsTitle, { color: colors.textSecondary }]}>{t.detailedCare}</Text>
{[ {[
{ text: t.careTextWater.replace('{0}', result.careInfo.waterIntervalDays.toString()), color: colors.success }, { text: t.careTextWater.replace('{0}', result.careInfo.waterIntervalDays.toString()), color: colors.success },
{ text: t.careTextLight.replace('{0}', result.careInfo.light), color: colors.warning }, { text: t.careTextLight.replace('{0}', (result.careInfo.light && result.careInfo.light !== 'Unknown') ? result.careInfo.light : t.unknown), color: colors.warning },
{ text: t.careTextTemp.replace('{0}', result.careInfo.temp), color: colors.danger }, { text: t.careTextTemp.replace('{0}', (result.careInfo.temp && result.careInfo.temp !== 'Unknown') ? result.careInfo.temp : t.unknown), color: colors.danger },
].map((item, i) => ( ].map((item, i) => (
<View key={i} style={styles.detailRow}> <View key={i} style={styles.detailRow}>
<View style={[styles.detailDot, { backgroundColor: item.color }]} /> <View style={[styles.detailDot, { backgroundColor: item.color }]} />

View File

@@ -1208,14 +1208,291 @@ h3 {
margin-bottom: var(--s2); margin-bottom: var(--s2);
} }
.support-faq-item p { .support-faq-item p {
color: var(--muted); color: var(--muted);
} }
/* ============================================= /* =============================================
RESPONSIVE COMPARISON PAGES
============================================= */ ============================================= */
@media (max-width: 1024px) { .comparison-page {
background:
radial-gradient(circle at top left, rgba(86, 160, 116, 0.16), transparent 26%),
linear-gradient(180deg, var(--cream) 0%, var(--white) 100%);
}
.comparison-hero {
background:
linear-gradient(135deg, rgba(13, 22, 15, 0.96) 0%, rgba(28, 46, 33, 0.92) 45%, rgba(42, 92, 63, 0.86) 100%);
color: var(--cream);
padding: 11rem 0 5rem;
}
.comparison-hero-grid,
.comparison-context-grid,
.comparison-fit-grid,
.comparison-links-grid {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: var(--s4);
}
.comparison-hero-copy h1 {
max-width: 12ch;
margin-bottom: var(--s3);
}
.comparison-lead,
.comparison-disclaimer,
.comparison-context-card p,
.comparison-thesis-copy p,
.comparison-row-verdict,
.comparison-faq-card p,
.comparison-link-card p,
.comparison-scenario-copy p {
line-height: 1.75;
}
.comparison-lead {
max-width: 700px;
color: rgba(244, 241, 232, 0.86);
font-size: 1.08rem;
}
.comparison-actions {
display: flex;
flex-wrap: wrap;
gap: var(--s2);
margin: var(--s4) 0 var(--s3);
}
.comparison-disclaimer,
.comparison-verified {
font-size: 0.82rem;
color: rgba(244, 241, 232, 0.72);
}
.comparison-hero-card,
.comparison-context-card,
.comparison-pain-card,
.comparison-thesis-card,
.comparison-fit-card,
.comparison-scenario-card,
.comparison-faq-card,
.comparison-link-card,
.comparison-row {
border-radius: var(--r-lg);
box-shadow: 0 24px 60px rgba(19, 31, 22, 0.08);
}
.comparison-hero-card {
background: rgba(244, 241, 232, 0.08);
border: 1px solid rgba(244, 241, 232, 0.12);
padding: var(--s4);
align-self: start;
}
.comparison-hero-card h2 {
font-size: clamp(1.55rem, 2.2vw, 2.2rem);
margin-bottom: var(--s3);
}
.comparison-card-label,
.comparison-mini-label {
display: inline-block;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.comparison-card-label {
color: var(--green-light);
margin-bottom: var(--s2);
}
.comparison-mini-label {
color: var(--accent);
margin-bottom: 0.55rem;
}
.comparison-bullet-list {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.comparison-bullet-list li {
position: relative;
padding-left: 1.25rem;
}
.comparison-bullet-list li::before {
content: '';
position: absolute;
top: 0.7rem;
left: 0;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent);
}
.comparison-bullet-list--dark li::before {
background: var(--green-mid);
}
.comparison-context,
.comparison-theses,
.comparison-table-section,
.comparison-fit,
.comparison-emergency,
.comparison-faq,
.comparison-links {
padding: var(--s12) 0;
}
.comparison-context-card,
.comparison-pain-card,
.comparison-thesis-card,
.comparison-fit-card,
.comparison-scenario-card,
.comparison-faq-card,
.comparison-link-card {
background: rgba(255, 255, 255, 0.86);
border: 1px solid rgba(19, 31, 22, 0.08);
padding: var(--s4);
}
.comparison-context-card h2,
.comparison-fit-card h2,
.comparison-link-card h3 {
margin-bottom: var(--s2);
}
.comparison-context-card--accent,
.comparison-fit-card--greenlens {
background:
linear-gradient(180deg, rgba(86, 160, 116, 0.12) 0%, rgba(255, 255, 255, 0.96) 100%);
}
.comparison-section-head {
max-width: 720px;
margin-bottom: var(--s4);
}
.comparison-section-head h2 {
color: var(--dark);
}
.comparison-pain-grid,
.comparison-scenario-grid,
.comparison-faq-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--s3);
}
.comparison-pain-card {
background: var(--dark);
color: var(--cream);
}
.comparison-pain-card h3,
.comparison-thesis-card h3,
.comparison-scenario-card h3,
.comparison-faq-card h3 {
margin-bottom: var(--s2);
}
.comparison-thesis-copy,
.comparison-scenario-copy {
display: grid;
gap: var(--s3);
}
.comparison-table {
display: grid;
gap: var(--s3);
}
.comparison-table-header {
display: grid;
grid-template-columns: 0.75fr 1fr 1fr;
gap: var(--s3);
padding: 0 var(--s2);
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.comparison-row {
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(19, 31, 22, 0.08);
display: grid;
grid-template-columns: 0.75fr 1fr 1fr;
gap: var(--s3);
padding: var(--s3);
}
.comparison-row-title {
font-family: var(--display);
font-size: 1.4rem;
color: var(--dark);
}
.comparison-cell {
padding: var(--s3);
border-radius: var(--r-md);
line-height: 1.75;
}
.comparison-cell--greenlens {
background: rgba(86, 160, 116, 0.12);
border: 1px solid rgba(86, 160, 116, 0.18);
}
.comparison-cell--competitor {
background: rgba(19, 31, 22, 0.05);
border: 1px solid rgba(19, 31, 22, 0.08);
}
.comparison-row-verdict {
grid-column: 1 / -1;
margin-top: 0.2rem;
color: var(--muted);
}
.comparison-links-grid {
grid-template-columns: repeat(2, 1fr);
}
.comparison-link-card {
display: block;
transition: transform var(--t), box-shadow var(--t);
}
.comparison-link-card:hover {
transform: translateY(-4px);
box-shadow: 0 20px 50px rgba(19, 31, 22, 0.12);
}
.comparison-link-card--support {
background: var(--dark);
color: var(--cream);
}
.comparison-link-card--support .comparison-mini-label,
.comparison-link-card--support p {
color: rgba(244, 241, 232, 0.76);
}
/* =============================================
RESPONSIVE
============================================= */
@media (max-width: 1024px) {
.hero .container { .hero .container {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1266,13 +1543,29 @@ h3 {
display: none; display: none;
} }
.footer-inner { .footer-inner {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: var(--s6); gap: var(--s6);
} }
}
.comparison-hero-grid,
@media (max-width: 768px) { .comparison-context-grid,
.comparison-fit-grid,
.comparison-links-grid,
.comparison-pain-grid,
.comparison-scenario-grid,
.comparison-faq-grid,
.comparison-table-header,
.comparison-row {
grid-template-columns: 1fr;
}
.comparison-row-title {
font-size: 1.7rem;
}
}
@media (max-width: 768px) {
.nav-links { .nav-links {
display: none; display: none;
} }
@@ -1315,8 +1608,34 @@ h3 {
text-align: center; text-align: center;
} }
.support-grid, .support-grid,
.support-faq-list { .support-faq-list {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
}
.comparison-hero {
padding-top: 9rem;
}
.comparison-actions {
flex-direction: column;
align-items: stretch;
}
.comparison-pain-grid,
.comparison-scenario-grid,
.comparison-faq-grid,
.comparison-links-grid {
grid-template-columns: 1fr;
}
.comparison-context,
.comparison-theses,
.comparison-table-section,
.comparison-fit,
.comparison-emergency,
.comparison-faq,
.comparison-links {
padding: var(--s8) 0;
}
}

View File

@@ -12,7 +12,6 @@ const CONTENT = {
contactLabel: 'Kontakt', contactLabel: 'Kontakt',
registryLabel: 'Register', registryLabel: 'Register',
vatLabel: 'USt-ID', vatLabel: 'USt-ID',
note: 'Vor der Veroeffentlichung muessen alle rechtlichen Angaben mit den echten Firmendaten ersetzt werden.',
}, },
en: { en: {
title: 'Imprint', title: 'Imprint',
@@ -22,7 +21,6 @@ const CONTENT = {
contactLabel: 'Contact', contactLabel: 'Contact',
registryLabel: 'Registry', registryLabel: 'Registry',
vatLabel: 'VAT ID', vatLabel: 'VAT ID',
note: 'Replace all legal placeholders with your real company details before publishing the site.',
}, },
es: { es: {
title: 'Aviso Legal', title: 'Aviso Legal',
@@ -32,7 +30,6 @@ const CONTENT = {
contactLabel: 'Contacto', contactLabel: 'Contacto',
registryLabel: 'Registro', registryLabel: 'Registro',
vatLabel: 'IVA', 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() {
<p> <p>
<strong>{c.companyLabel}:</strong> {siteConfig.company.legalName} <strong>{c.companyLabel}:</strong> {siteConfig.company.legalName}
</p> </p>
<p> {siteConfig.company.addressLine1 ? (
<strong>{c.addressLabel}:</strong> {siteConfig.company.addressLine1} <p><strong>{c.addressLabel}:</strong> {siteConfig.company.addressLine1}</p>
</p> ) : null}
{siteConfig.company.addressLine2 ? <p>{siteConfig.company.addressLine2}</p> : null} {siteConfig.company.addressLine2 ? <p>{siteConfig.company.addressLine2}</p> : null}
<p>{siteConfig.company.country}</p> <p>{siteConfig.company.country}</p>
<p> <p>
@@ -58,13 +55,12 @@ export default function ImprintPage() {
<p> <p>
<strong>{c.contactLabel}:</strong> <a href={`mailto:${siteConfig.legalEmail}`}>{siteConfig.legalEmail}</a> <strong>{c.contactLabel}:</strong> <a href={`mailto:${siteConfig.legalEmail}`}>{siteConfig.legalEmail}</a>
</p> </p>
<p> {siteConfig.company.registry ? (
<strong>{c.registryLabel}:</strong> {siteConfig.company.registry} <p><strong>{c.registryLabel}:</strong> {siteConfig.company.registry}</p>
</p> ) : null}
<p> {siteConfig.company.vatId ? (
<strong>{c.vatLabel}:</strong> {siteConfig.company.vatId} <p><strong>{c.vatLabel}:</strong> {siteConfig.company.vatId}</p>
</p> ) : null}
<p style={{ marginTop: '1rem', fontSize: '0.95rem', opacity: 0.8 }}>{c.note}</p>
</div> </div>
</main> </main>
) )

View File

@@ -1,72 +1,105 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import './globals.css' import { cookies } from 'next/headers'
import { LangProvider } from '@/context/LangContext' import './globals.css'
import { siteConfig } from '@/lib/site' import { LangProvider } from '@/context/LangContext'
import { siteConfig, hasIosStoreUrl } from '@/lib/site'
export const metadata: Metadata = {
metadataBase: new URL(siteConfig.domain), export const metadata: Metadata = {
title: { metadataBase: new URL(siteConfig.domain),
default: 'GreenLens - Plant Identifier and Care Planner', title: {
template: '%s | GreenLens', 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.', description:
keywords: [ 'GreenLens helps you identify plants, organize your collection, and keep up with care routines in one app.',
'plant identifier by picture', keywords: [
'plant care app', 'plant identifier by picture',
'watering reminders', 'plant care app',
'houseplant tracker', 'watering reminders',
'plant identification', 'houseplant tracker',
'plant health check', 'plant identification',
'Pflanzen App', 'plant health check',
'GreenLens', 'Pflanzen App',
], 'GreenLens',
authors: [{ name: siteConfig.name }], ],
openGraph: { authors: [{ name: siteConfig.name }],
title: 'GreenLens - Plant Identifier and Care Planner', openGraph: {
description: 'Identify plants, get care guidance, and manage your collection with GreenLens.', title: 'GreenLens - Plant Identifier and Care Planner',
type: 'website', description: 'Identify plants, get care guidance, and manage your collection with GreenLens.',
url: siteConfig.domain, type: 'website',
}, url: siteConfig.domain,
alternates: { },
canonical: '/', twitter: {
languages: { card: 'summary_large_image',
de: '/', title: 'GreenLens - Plant Identifier and Care Planner',
en: '/', description: 'Identify plants, get care guidance, and manage your collection with GreenLens.',
es: '/', },
'x-default': '/', alternates: {
}, canonical: '/',
}, languages: {
} de: '/',
en: '/',
export default function RootLayout({ children }: { children: React.ReactNode }) { es: '/',
return ( 'x-default': '/',
<html lang="de"> },
<head> },
<link rel="preconnect" href="https://fonts.googleapis.com" /> }
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> export default async function RootLayout({ children }: { children: React.ReactNode }) {
<script const cookieStore = await cookies()
type="application/ld+json" const lang = (cookieStore.get('lang')?.value ?? 'de') as 'de' | 'en' | 'es'
dangerouslySetInnerHTML={{ const validLangs = ['de', 'en', 'es']
__html: JSON.stringify({ const htmlLang = validLangs.includes(lang) ? lang : 'de'
'@context': 'https://schema.org',
'@type': 'SoftwareApplication', return (
name: siteConfig.name, <html lang={htmlLang}>
operatingSystem: 'iOS, Android', <head>
applicationCategory: 'LifestyleApplication', <link rel="preconnect" href="https://fonts.googleapis.com" />
offers: { <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
'@type': 'Offer', <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
price: '0', <script
priceCurrency: 'EUR', type="application/ld+json"
}, dangerouslySetInnerHTML={{
}), __html: JSON.stringify([
}} {
/> '@context': 'https://schema.org',
</head> '@type': 'SoftwareApplication',
<body> name: siteConfig.name,
<LangProvider>{children}</LangProvider> operatingSystem: 'iOS, Android',
</body> applicationCategory: 'LifestyleApplication',
</html> description:
) 'Identify plants, track care schedules, and manage your collection with AI-powered scans.',
} inLanguage: ['de', 'en', 'es'],
...(hasIosStoreUrl && { downloadUrl: siteConfig.iosAppStoreUrl }),
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'EUR',
},
},
{
'@context': 'https://schema.org',
'@type': 'Organization',
name: siteConfig.name,
url: siteConfig.domain,
description:
'GreenLens is a plant identification and care planning app for iOS and Android.',
contactPoint: {
'@type': 'ContactPoint',
contactType: 'customer support',
email: siteConfig.supportEmail,
},
...(hasIosStoreUrl && {
sameAs: [siteConfig.iosAppStoreUrl],
}),
},
]),
}}
/>
</head>
<body>
<LangProvider>{children}</LangProvider>
</body>
</html>
)
}

View File

@@ -0,0 +1,83 @@
import { ImageResponse } from 'next/og'
export const runtime = 'edge'
export const alt = 'GreenLens Plant Identifier and Care Planner'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default function OGImage() {
return new ImageResponse(
(
<div
style={{
background: '#131f16',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '80px',
}}
>
<div
style={{
fontSize: 26,
fontWeight: 600,
color: '#56a074',
letterSpacing: '0.15em',
textTransform: 'uppercase',
marginBottom: 28,
display: 'flex',
}}
>
Plant Identifier &amp; Care App
</div>
<div
style={{
fontSize: 100,
fontWeight: 800,
color: '#f4f1e8',
marginBottom: 28,
display: 'flex',
}}
>
GreenLens
</div>
<div
style={{
fontSize: 34,
color: 'rgba(244,241,232,0.65)',
textAlign: 'center',
maxWidth: 820,
display: 'flex',
}}
>
Identify plants, get AI-powered care plans, and manage your collection.
</div>
<div style={{ marginTop: 56, display: 'flex', gap: 16 }}>
{['450+ plant species', 'AI-powered scans', 'iOS & Android'].map((label) => (
<div
key={label}
style={{
background: 'rgba(86,160,116,0.15)',
border: '1.5px solid rgba(86,160,116,0.4)',
borderRadius: 100,
padding: '14px 30px',
fontSize: 22,
color: '#7ac99a',
display: 'flex',
}}
>
{label}
</div>
))}
</div>
</div>
),
{ ...size },
)
}

View File

@@ -1,29 +1,116 @@
import Navbar from '@/components/Navbar' import Navbar from '@/components/Navbar'
import Hero from '@/components/Hero' import Hero from '@/components/Hero'
import Ticker from '@/components/Ticker' import Ticker from '@/components/Ticker'
import Features from '@/components/Features' import Features from '@/components/Features'
import BrownLeaf from '@/components/BrownLeaf' import BrownLeaf from '@/components/BrownLeaf'
import Intelligence from '@/components/Intelligence' import Intelligence from '@/components/Intelligence'
import HowItWorks from '@/components/HowItWorks' import HowItWorks from '@/components/HowItWorks'
import FAQ from '@/components/FAQ' import FAQ from '@/components/FAQ'
import CTA from '@/components/CTA' import CTA from '@/components/CTA'
import Footer from '@/components/Footer' import Footer from '@/components/Footer'
export default function Home() { const howToSchema = {
return ( '@context': 'https://schema.org',
<> '@type': 'HowTo',
<Navbar /> name: 'How to identify a plant with GreenLens',
<main> step: [
<Hero /> {
<Ticker /> '@type': 'HowToStep',
<Features /> position: 1,
<BrownLeaf /> name: 'Photograph your plant',
<Intelligence /> text: 'Open the app, point the camera at your plant and tap Scan.',
<HowItWorks /> },
<FAQ /> {
<CTA /> '@type': 'HowToStep',
</main> position: 2,
<Footer /> name: 'AI identifies instantly',
</> text: 'In under a second you get the exact name, species and all key details.',
) },
} {
'@type': 'HowToStep',
position: 3,
name: 'Receive care plan',
text: 'GreenLens automatically creates a personalized care plan for your plant and location.',
},
{
'@type': 'HowToStep',
position: 4,
name: 'Track growth',
text: 'Document photos, track watering and get reminded of important care dates.',
},
],
}
const faqSchema = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: [
{
'@type': 'Question',
name: 'How does GreenLens identify a plant?',
acceptedAnswer: {
'@type': 'Answer',
text: 'GreenLens analyzes the plant photo and combines that with app-side care guidance so you can move from scan to next steps faster.',
},
},
{
'@type': 'Question',
name: 'Is GreenLens free to use?',
acceptedAnswer: {
'@type': 'Answer',
text: 'GreenLens includes free functionality plus paid options such as subscriptions and credit top-ups for advanced AI features.',
},
},
{
'@type': 'Question',
name: 'Can I use GreenLens offline?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Plant identification and health checks require an internet connection. Your saved collection, care notes, and watering reminders are available offline.',
},
},
{
'@type': 'Question',
name: 'What kind of plants can I use GreenLens for?',
acceptedAnswer: {
'@type': 'Answer',
text: 'GreenLens covers 450+ plant species including houseplants, garden plants, and succulents. It is built for everyday plant owners who want identification and care guidance in one place.',
},
},
{
'@type': 'Question',
name: 'How do I start my plant collection in GreenLens?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Start with a scan, review the result, and save the plant to your collection to keep notes, reminders, and follow-up care in one place.',
},
},
],
}
export default function Home() {
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(howToSchema) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
/>
<Navbar />
<main>
<Hero />
<Ticker />
<Features />
<BrownLeaf />
<Intelligence />
<HowItWorks />
<FAQ />
<CTA />
</main>
<Footer />
</>
)
}

View File

@@ -5,26 +5,44 @@ export default function sitemap(): MetadataRoute.Sitemap {
return [ return [
{ {
url: baseUrl, url: baseUrl,
lastModified: new Date(), lastModified: new Date('2026-04-08'),
changeFrequency: 'weekly', changeFrequency: 'weekly',
priority: 1, priority: 1,
}, },
{ {
url: `${baseUrl}/imprint`, url: `${baseUrl}/support`,
lastModified: new Date(), lastModified: new Date('2026-04-08'),
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.3, priority: 0.5,
}, },
{
url: `${baseUrl}/vs/picturethis`,
lastModified: new Date('2026-04-10'),
changeFrequency: 'monthly',
priority: 0.65,
},
{
url: `${baseUrl}/vs/plantum`,
lastModified: new Date('2026-04-10'),
changeFrequency: 'monthly',
priority: 0.65,
},
{
url: `${baseUrl}/imprint`,
lastModified: new Date('2026-04-08'),
changeFrequency: 'monthly',
priority: 0.3,
},
{ {
url: `${baseUrl}/privacy`, url: `${baseUrl}/privacy`,
lastModified: new Date(), lastModified: new Date('2026-04-08'),
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.3, priority: 0.3,
}, },
{ {
url: `${baseUrl}/terms`, url: `${baseUrl}/terms`,
lastModified: new Date(), lastModified: new Date('2026-04-08'),
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.3, priority: 0.3,
}, },

View File

@@ -0,0 +1,70 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import ComparisonPage from '@/components/ComparisonPage'
import { competitorOrder, getCompetitorBySlug, getPeerCompetitors } from '@/lib/competitors'
import { siteConfig } from '@/lib/site'
type ComparisonRouteProps = {
params: Promise<{ competitor: string }>
}
export function generateStaticParams() {
return competitorOrder.map((competitor) => ({ competitor }))
}
export async function generateMetadata({ params }: ComparisonRouteProps): Promise<Metadata> {
const { competitor } = await params
const profile = getCompetitorBySlug(competitor)
if (!profile) {
return {}
}
const path = `/vs/${profile.slug}`
return {
title: profile.metaTitle,
description: profile.metaDescription,
alternates: {
canonical: path,
},
keywords: [
`${siteConfig.name.toLowerCase()} vs ${profile.name.toLowerCase()}`,
`${profile.name.toLowerCase()} alternative`,
'plant emergency app',
'plant care app comparison',
'plant diagnosis app',
],
openGraph: {
title: profile.metaTitle,
description: profile.metaDescription,
url: `${siteConfig.domain}${path}`,
type: 'website',
images: [
{
url: '/og-image.png',
width: 1200,
height: 630,
alt: `${profile.metaTitle} comparison page`,
},
],
},
twitter: {
card: 'summary_large_image',
title: profile.metaTitle,
description: profile.metaDescription,
images: ['/og-image.png'],
},
}
}
export default async function ComparisonRoute({ params }: ComparisonRouteProps) {
const { competitor } = await params
const profile = getCompetitorBySlug(competitor)
if (!profile) {
notFound()
}
return <ComparisonPage competitor={profile} peers={getPeerCompetitors(profile.slug)} />
}

View File

@@ -0,0 +1,236 @@
import Link from 'next/link'
import Navbar from '@/components/Navbar'
import CTA from '@/components/CTA'
import Footer from '@/components/Footer'
import type { CompetitorProfile } from '@/lib/competitors'
import { siteConfig } from '@/lib/site'
interface ComparisonPageProps {
competitor: CompetitorProfile
peers: CompetitorProfile[]
}
export default function ComparisonPage({ competitor, peers }: ComparisonPageProps) {
const faqSchema = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: competitor.faqs.map((item) => ({
'@type': 'Question',
name: item.question,
acceptedAnswer: {
'@type': 'Answer',
text: item.answer,
},
})),
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
/>
<Navbar />
<main className="comparison-page">
<section className="comparison-hero">
<div className="container comparison-hero-grid">
<div className="comparison-hero-copy">
<p className="tag">Comparison</p>
<h1>{siteConfig.name} vs {competitor.name}</h1>
<p className="comparison-lead">{competitor.heroSummary}</p>
<div className="comparison-actions">
<a href="#cta" className="btn-primary">Try GreenLens</a>
<a href="#comparison-table" className="btn-outline">See full comparison</a>
</div>
<p className="comparison-disclaimer">{competitor.disclaimer}</p>
</div>
<aside className="comparison-hero-card">
<p className="comparison-card-label">Fast verdict</p>
<h2>Pick GreenLens when your plant already looks wrong.</h2>
<ul className="comparison-bullet-list">
{competitor.heroVerdict.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
<p className="comparison-verified">Research summary refreshed {competitor.lastVerified}</p>
</aside>
</div>
</section>
<section className="comparison-context">
<div className="container comparison-context-grid">
<article className="comparison-context-card">
<p className="tag">The competitor</p>
<h2>{competitor.name} at a glance</h2>
<p>{competitor.competitorSnapshot}</p>
</article>
<article className="comparison-context-card comparison-context-card--accent">
<p className="tag">The GreenLens angle</p>
<h2>The plant ER, not the encyclopedia.</h2>
<p>{competitor.greenLensPositioning}</p>
</article>
</div>
</section>
<section className="comparison-theses">
<div className="container">
<div className="comparison-section-head">
<p className="tag">Core difference</p>
<h2>Why users compare these two apps.</h2>
</div>
<div className="comparison-pain-grid">
<article className="comparison-pain-card">
<h3>Why searchers keep looking</h3>
<ul className="comparison-bullet-list comparison-bullet-list--dark">
{competitor.whyPeopleCompare.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</article>
{competitor.theses.map((item) => (
<article key={item.title} className="comparison-thesis-card">
<h3>{item.title}</h3>
<div className="comparison-thesis-copy">
<div>
<p className="comparison-mini-label">GreenLens</p>
<p>{item.greenlens}</p>
</div>
<div>
<p className="comparison-mini-label">{competitor.name}</p>
<p>{item.competitor}</p>
</div>
</div>
</article>
))}
</div>
</div>
</section>
<section className="comparison-table-section" id="comparison-table">
<div className="container">
<div className="comparison-section-head">
<p className="tag">At a glance</p>
<h2>Where GreenLens and {competitor.name} differ.</h2>
</div>
<div className="comparison-table">
<div className="comparison-table-header">
<span>Category</span>
<span>GreenLens</span>
<span>{competitor.name}</span>
</div>
{competitor.categories.map((item) => (
<article key={item.title} className="comparison-row">
<div className="comparison-row-title">{item.title}</div>
<div className="comparison-cell comparison-cell--greenlens">{item.greenlens}</div>
<div className="comparison-cell comparison-cell--competitor">{item.competitor}</div>
<p className="comparison-row-verdict">{item.whyItMatters}</p>
</article>
))}
</div>
</div>
</section>
<section className="comparison-fit">
<div className="container comparison-fit-grid">
<article className="comparison-fit-card comparison-fit-card--greenlens">
<p className="tag">Best fit</p>
<h2>Choose GreenLens if you need:</h2>
<ul className="comparison-bullet-list comparison-bullet-list--dark">
{competitor.greenLensBestFor.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</article>
<article className="comparison-fit-card">
<p className="tag">Still a fit</p>
<h2>Choose {competitor.name} if you need:</h2>
<ul className="comparison-bullet-list comparison-bullet-list--dark">
{competitor.competitorBestFor.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</article>
</div>
</section>
<section className="comparison-emergency">
<div className="container">
<div className="comparison-section-head">
<p className="tag">Plant ER scenarios</p>
<h2>What this difference looks like in real use.</h2>
</div>
<div className="comparison-scenario-grid">
{competitor.emergencyScenarios.map((item) => (
<article key={item.symptom} className="comparison-scenario-card">
<h3>{item.symptom}</h3>
<div className="comparison-scenario-copy">
<div>
<p className="comparison-mini-label">GreenLens</p>
<p>{item.greenlens}</p>
</div>
<div>
<p className="comparison-mini-label">{competitor.name}</p>
<p>{item.competitor}</p>
</div>
</div>
</article>
))}
</div>
</div>
</section>
<section className="comparison-faq">
<div className="container">
<div className="comparison-section-head">
<p className="tag">FAQ</p>
<h2>Questions users ask before switching.</h2>
</div>
<div className="comparison-faq-grid">
{competitor.faqs.map((item) => (
<article key={item.question} className="comparison-faq-card">
<h3>{item.question}</h3>
<p>{item.answer}</p>
</article>
))}
</div>
</div>
</section>
<section className="comparison-links">
<div className="container comparison-links-grid">
{peers.map((peer) => (
<Link key={peer.slug} href={`/vs/${peer.slug}`} className="comparison-link-card">
<p className="comparison-mini-label">Compare next</p>
<h3>{siteConfig.name} vs {peer.name}</h3>
<p>
See how GreenLens stacks up against {peer.name} for plant emergencies,
diagnosis clarity, and care workflow design.
</p>
</Link>
))}
<Link href="/support" className="comparison-link-card comparison-link-card--support">
<p className="comparison-mini-label">Need more detail?</p>
<h3>Talk to GreenLens support</h3>
<p>
Questions about billing, scans, care plans, or rollout? Use the support page
and we will help from there.
</p>
</Link>
</div>
</section>
<CTA />
</main>
<Footer />
</>
)
}

View File

@@ -31,26 +31,26 @@ const faqs = [
}, },
{ {
question: { question: {
en: 'Can I use it offline?', en: 'Can I use GreenLens offline?',
de: 'Kann ich die App offline nutzen?', de: 'Kann ich GreenLens offline nutzen?',
es: 'Puedo usarla sin conexion?' es: 'Puedo usar GreenLens sin conexion?'
}, },
answer: { answer: {
en: 'Some experiences may require a connection, especially for scan-related features. Saved information inside the app can remain available afterward.', en: 'Plant identification and health checks require an internet connection. Your saved collection, care notes, and watering reminders are available offline.',
de: 'Einige Funktionen benoetigen eine Verbindung, besonders scanbezogene Features. Gespeicherte Informationen in der App koennen danach weiter verfuegbar bleiben.', de: 'Pflanzenidentifikation und Gesundheitscheck benoetigen eine Internetverbindung. Deine gespeicherte Sammlung, Pflegenotizen und Giess-Erinnerungen sind offline verfuegbar.',
es: 'Algunas funciones requieren conexion, especialmente las relacionadas con escaneos. La informacion guardada puede seguir disponible despues.' es: 'La identificacion de plantas y el control de salud requieren conexion a internet. Tu coleccion guardada, notas de cuidado y recordatorios de riego estan disponibles sin conexion.'
} }
}, },
{ {
question: { question: {
en: 'What kind of plants can I use it for?', en: 'What kind of plants can I use GreenLens for?',
de: 'Fuer welche Pflanzen kann ich die App nutzen?', de: 'Fuer welche Pflanzen kann ich GreenLens nutzen?',
es: 'Para que tipo de plantas puedo usar la app?' es: 'Para que tipo de plantas puedo usar GreenLens?'
}, },
answer: { answer: {
en: 'GreenLens is built for everyday plant owners who want help with houseplants, garden plants, and general care questions.', en: 'GreenLens covers 450+ plant species including houseplants, garden plants, and succulents. It is built for everyday plant owners who want identification and care guidance in one place.',
de: 'GreenLens richtet sich an Pflanzenbesitzer, die Hilfe bei Zimmerpflanzen, Gartenpflanzen und allgemeinen Pflegefragen wollen.', de: 'GreenLens umfasst ueber 450 Pflanzenarten, darunter Zimmerpflanzen, Gartenpflanzen und Sukkulenten. Die App richtet sich an Pflanzenbesitzer, die Identifikation und Pflege an einem Ort wollen.',
es: 'GreenLens esta pensada para personas que quieren ayuda con plantas de interior, jardin y preguntas generales de cuidado.' es: 'GreenLens cubre mas de 450 especies de plantas, incluyendo plantas de interior, de jardin y suculentas. Esta pensada para quienes quieren identificacion y cuidado en un solo lugar.'
} }
}, },
{ {

View File

@@ -27,14 +27,20 @@ export default function Footer() {
{t.footer.cols.map((col, ci) => ( {t.footer.cols.map((col, ci) => (
<div className="footer-col" key={col.title}> <div className="footer-col" key={col.title}>
<div className="footer-col-title">{col.title}</div> <div className="footer-col-title">{col.title}</div>
{col.links.map((label, li) => ( {col.links.map((label, li) => (
<Link key={label} href={LINK_HREFS[ci]?.[li] ?? '/support'}> <Link key={label} href={LINK_HREFS[ci]?.[li] ?? '/support'}>
{label} {label}
</Link> </Link>
))} ))}
</div> {ci === 1 && (
))} <>
</div> <Link href="/vs/picturethis">GreenLens vs PictureThis</Link>
<Link href="/vs/plantum">GreenLens vs Plantum</Link>
</>
)}
</div>
))}
</div>
<div className="footer-brand-xl" aria-hidden="true">GREENLENS</div> <div className="footer-brand-xl" aria-hidden="true">GREENLENS</div>

View File

@@ -109,7 +109,7 @@ export default function Hero() {
<div className="hero-visual reveal-fade delay-2"> <div className="hero-visual reveal-fade delay-2">
<div className="hero-video-card hero-video-16-9"> <div className="hero-video-card hero-video-16-9">
<video autoPlay loop muted playsInline aria-label="GreenLens App Demo"> <video autoPlay loop muted playsInline aria-label="GreenLens App Demo">
<source src="/GreenLensHype.mp4" type="video/mp4" /> <source src="/greenlens.mp4" type="video/mp4" />
</video> </video>
<div className="hero-video-card-overlay" /> <div className="hero-video-card-overlay" />
<div className="hero-video-badge"> <div className="hero-video-badge">

View File

@@ -1,27 +1,46 @@
'use client' 'use client'
import { createContext, useContext, useState, ReactNode } from 'react' import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import { Lang, translations } from '@/lib/i18n' import { Lang, translations } from '@/lib/i18n'
interface LangCtx { interface LangCtx {
lang: Lang lang: Lang
setLang: (l: Lang) => void setLang: (l: Lang) => void
t: typeof translations.de t: typeof translations.de
} }
const LangContext = createContext<LangCtx>({ const LangContext = createContext<LangCtx>({
lang: 'de', lang: 'de',
setLang: () => {}, setLang: () => {},
t: translations.de, t: translations.de,
}) })
export function LangProvider({ children }: { children: ReactNode }) { function getInitialLang(): Lang {
const [lang, setLang] = useState<Lang>('de') if (typeof document === 'undefined') return 'de'
return ( const match = document.cookie.match(/(?:^|;\s*)lang=([^;]+)/)
<LangContext.Provider value={{ lang, setLang, t: translations[lang] }}> const val = match?.[1]
{children} return val === 'en' || val === 'es' || val === 'de' ? val : 'de'
</LangContext.Provider> }
)
} export function LangProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Lang>('de')
export const useLang = () => useContext(LangContext)
useEffect(() => {
setLangState(getInitialLang())
}, [])
const setLang = (l: Lang) => {
document.cookie = `lang=${l};path=/;max-age=31536000;SameSite=Lax`
// Update <html lang> for the current page visit without a full reload
document.documentElement.lang = l
setLangState(l)
}
return (
<LangContext.Provider value={{ lang, setLang, t: translations[lang] }}>
{children}
</LangContext.Provider>
)
}
export const useLang = () => useContext(LangContext)

View File

@@ -41,7 +41,8 @@ services:
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-https://greenlenspro.com/storage} MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-https://greenlenspro.com/storage}
OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-}
OPENAI_SCAN_MODEL: ${OPENAI_SCAN_MODEL:-gpt-5-mini} 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_WEBHOOK_SECRET: ${REVENUECAT_WEBHOOK_SECRET:-}
REVENUECAT_PRO_ENTITLEMENT_ID: ${REVENUECAT_PRO_ENTITLEMENT_ID:-pro} REVENUECAT_PRO_ENTITLEMENT_ID: ${REVENUECAT_PRO_ENTITLEMENT_ID:-pro}
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}

View File

@@ -0,0 +1,384 @@
export type CompetitorSlug = 'picturethis' | 'plantum'
export interface ComparisonThesis {
title: string
greenlens: string
competitor: string
}
export interface ComparisonCategory {
title: string
greenlens: string
competitor: string
whyItMatters: string
}
export interface EmergencyScenario {
symptom: string
greenlens: string
competitor: string
}
export interface ComparisonFaq {
question: string
answer: string
}
export interface CompetitorProfile {
slug: CompetitorSlug
name: string
metaTitle: string
metaDescription: string
heroSummary: string
heroVerdict: string[]
disclaimer: string
lastVerified: string
competitorSnapshot: string
greenLensPositioning: string
whyPeopleCompare: string[]
theses: ComparisonThesis[]
categories: ComparisonCategory[]
greenLensBestFor: string[]
competitorBestFor: string[]
emergencyScenarios: EmergencyScenario[]
faqs: ComparisonFaq[]
}
export const competitorProfiles: Record<CompetitorSlug, CompetitorProfile> = {
picturethis: {
slug: 'picturethis',
name: 'PictureThis',
metaTitle: 'GreenLens vs PictureThis',
metaDescription:
'Compare GreenLens vs PictureThis for plant emergencies, next-step diagnosis, pricing friction, and care guidance. See when GreenLens is the better fit.',
heroSummary:
'PictureThis is one of the best-known plant ID apps on the market, but GreenLens is built for a different moment: when your plant already looks wrong and you need the next correct action, not another generic care checklist.',
heroVerdict: [
'Choose GreenLens if your real question is what to do next about yellow leaves, soft stems, or a sudden decline.',
'Choose PictureThis if your priority is broad plant identification and a large reference library first.',
'Do not assume a rigid watering calendar is safer. For stressed plants, that habit often creates the next mistake.',
],
disclaimer:
'Pricing, trials, and feature gates can change by market and over time. This page reflects the current research summary used for GreenLens landing content.',
lastVerified: 'April 10, 2026',
competitorSnapshot:
'PictureThis is the category leader for mainstream plant ID. It is commonly associated with a large botanical database, fast scan-to-name results, and an aggressive subscription flow that many users describe as frustrating during onboarding.',
greenLensPositioning:
'GreenLens is the plant ER angle: situational triage, calmer next-step guidance, and a clearer path from symptom to action when a plant suddenly starts struggling.',
whyPeopleCompare: [
'They can identify a plant, but still do not know what to do after the scan.',
'They want help with emergencies, not just an encyclopedia in their pocket.',
'They are tired of paywall pressure before they feel confident about the diagnosis.',
],
theses: [
{
title: 'Subscription pressure vs calmer triage',
greenlens:
'GreenLens is positioned to get users to the situation first and the decision second, without making the panic moment feel like a billing funnel.',
competitor:
'PictureThis is widely known for aggressive paywalls and hard-to-dismiss upgrade prompts before trust is fully earned.',
},
{
title: 'Calendar reminders vs situational judgment',
greenlens:
'GreenLens frames care around what changed, what the soil feels like, and what happened in the last 14 days.',
competitor:
'PictureThis leans on scheduled care reminders that can encourage overwatering when symptoms are misread.',
},
{
title: 'Generic suggestions vs the next right step',
greenlens:
'GreenLens focuses on one clear next move: check the soil, stop fertilizing, review the recent change, or isolate the cause.',
competitor:
'PictureThis disease and health guidance often lands on broad advice such as more light or more fertilizer, even when the user needs sharper triage.',
},
],
categories: [
{
title: 'Plant emergencies',
greenlens:
'Built around fast triage for visible problems like yellow leaves, mushy stems, droop after repotting, or sudden decline.',
competitor:
'Strong at telling you what the plant is, less convincing when the real problem is deciding the safest next intervention.',
whyItMatters:
'A stressed plant does not need more content. It needs the next low-risk action that prevents the owner from making things worse.',
},
{
title: 'Identification and plant database',
greenlens:
'Useful when identification is part of the rescue flow, but not positioned as the largest encyclopedia in the category.',
competitor:
'This is the core PictureThis strength: broad species coverage and fast recognition backed by a very large library.',
whyItMatters:
'If naming the plant is the end goal, PictureThis is strong. If naming the plant is just step one, GreenLens has a clearer story.',
},
{
title: 'Care philosophy',
greenlens:
'Situational care. The app should ask what changed recently and whether the soil or environment actually supports the next move.',
competitor:
'Calendar-driven care plans and reminders that can feel tidy, but often miss the context that matters most for beginners.',
whyItMatters:
'Strict calendars are one of the easiest ways to overwater a plant that already shows stress.',
},
{
title: 'Diagnosis output',
greenlens:
'Prioritizes a smaller number of concrete interventions with clearer sequencing and less noise.',
competitor:
'Often returns generic advice that sounds plausible but does not reduce uncertainty enough for first-time plant owners.',
whyItMatters:
'The user is not buying a list of possibilities. They are trying to avoid the wrong action today.',
},
{
title: 'Pricing and trust',
greenlens:
'Paid features still exist, but the brand story is fairer and more transparent than hiding the choice behind manipulative UI.',
competitor:
'PictureThis is frequently criticized for paywall-first moments, especially around trials and dismiss states.',
whyItMatters:
'Trust matters more when someone is already anxious about killing a plant.',
},
{
title: 'Beginner clarity',
greenlens:
'Designed to calm the situation down and turn a messy symptom into a single next step.',
competitor:
'The app gives users a lot of information quickly, which is helpful for reference and less helpful for triage.',
whyItMatters:
'Beginners rarely need more detail first. They need a better decision path.',
},
],
greenLensBestFor: [
'People dealing with a plant that looks wrong right now and want the safest next step.',
'Beginners who need triage, not a full plant encyclopedia.',
'Users who distrust manipulative subscription flows and want clearer product positioning.',
],
competitorBestFor: [
'Users who mainly want broad plant identification from a very large reference database.',
'People who enjoy an all-purpose plant encyclopedia and do not mind more aggressive upsell patterns.',
'Plant owners whose first question is what the plant is, not how to stabilize it.',
],
emergencyScenarios: [
{
symptom: 'Yellow leaves after a recent move',
greenlens:
'GreenLens frames the issue around the recent change, environment shift, and whether watering behavior also changed.',
competitor:
'PictureThis may still identify the plant correctly, but the next-step guidance is more likely to stay broad and less situational.',
},
{
symptom: 'Soft stems or signs of overwatering',
greenlens:
'GreenLens emphasizes checking moisture and stopping instinctive “care stacking” before adding fertilizer or another routine task.',
competitor:
'A calendar-driven reminder model can push users toward the exact behavior that created the problem.',
},
{
symptom: 'Sudden decline with unclear cause',
greenlens:
'GreenLens narrows the response to the next safest action instead of overwhelming the user with a long diagnosis tree.',
competitor:
'PictureThis is more useful as a reference layer than as a focused emergency workflow.',
},
],
faqs: [
{
question: 'Is GreenLens more accurate than PictureThis for plant identification?',
answer:
'GreenLens does not need to win the encyclopedia race to be the better choice in a plant emergency. PictureThis is still stronger if broad ID coverage is your main requirement. GreenLens is stronger when the real job is choosing the next action after the scan.',
},
{
question: 'Why compare GreenLens and PictureThis if both use AI?',
answer:
'Because they optimize for different outcomes. PictureThis is strongest as a mainstream identification and reference app. GreenLens is framed around triage, situational care decisions, and calmer guidance when something is already going wrong.',
},
{
question: 'Does GreenLens replace watering calendars?',
answer:
'It replaces the idea that a calendar alone is enough. GreenLens emphasizes what changed, what the soil feels like, and whether a plant is showing stress before another routine task is triggered.',
},
{
question: 'Does GreenLens also have paid features?',
answer:
'Yes. GreenLens includes paid functionality such as subscriptions and AI-related credits. The difference in this comparison is the positioning: the diagnosis moment should feel clearer and fairer, not like a hidden-dismiss billing trap.',
},
],
},
plantum: {
slug: 'plantum',
name: 'Plantum',
metaTitle: 'GreenLens vs Plantum',
metaDescription:
'Compare GreenLens vs Plantum for plant diagnosis, care workflows, pricing friction, and beginner clarity. See why GreenLens is the better plant ER choice.',
heroSummary:
'Plantum markets itself as a high-accuracy, all-in-one plant care assistant. GreenLens is the sharper choice when the user does not want an all-in-one system right now, but a clear answer to what to do next for a struggling plant.',
heroVerdict: [
'Choose GreenLens if you want triage, not another stack of care hacks and tasks.',
'Choose Plantum if you want a broader all-in-one assistant with more expansive care reporting.',
'If the plant is already in trouble, clarity beats completeness.',
],
disclaimer:
'Pricing, feature limits, and diagnosis depth can change by region and plan. This page reflects the current research summary used for GreenLens landing content.',
lastVerified: 'April 10, 2026',
competitorSnapshot:
'Plantum, formerly NatureID, competes on AI precision, plant disease reports, and the promise of being a full care assistant. It often looks more detailed than GreenLens at first glance, but that detail can turn into checklist overload for beginners.',
greenLensPositioning:
'GreenLens is the anti-actionism option: diagnose the situation, reduce noise, and recommend the next lowest-risk step instead of flooding the user with tasks.',
whyPeopleCompare: [
'They want help with a sick plant but do not want to decode a long report.',
'They are looking for a realistic alternative to rigid plant journals and task stacks.',
'They want a tool that helps them decide, not just one that generates more plant-care output.',
],
theses: [
{
title: 'Actionism vs next-step clarity',
greenlens:
'GreenLens reduces the problem to the next clear intervention instead of rewarding users with a longer checklist.',
competitor:
'Plantum can feel thorough, but the volume of advice often creates urgency and overreaction rather than confidence.',
},
{
title: 'Plant ER vs all-in-one care assistant',
greenlens:
'GreenLens is strongest when the user is already worried and needs triage first.',
competitor:
'Plantum is built as a broad assistant with journals, tasks, and deeper care material around each plant.',
},
{
title: 'Situational care vs rigid task systems',
greenlens:
'GreenLens emphasizes recent change, soil condition, and symptom severity over routine schedules.',
competitor:
'Plantum still leans on structured care workflows that can miss whether the current advice matches the actual state of the plant.',
},
],
categories: [
{
title: 'Diagnosis depth',
greenlens:
'GreenLens goes narrower and sharper: fewer steps, stronger sequencing, more focus on what to do now.',
competitor:
'Plantum often presents a more detailed health report and a denser care framework around the diagnosis.',
whyItMatters:
'Detail can look impressive while still failing the anxious beginner who needs one confident decision.',
},
{
title: 'Beginner usability',
greenlens:
'Built to calm the situation down and reduce the chance of stacking too many fixes at once.',
competitor:
'Plantum can overwhelm newer plant owners with too many care hacks, checks, and supporting explanations.',
whyItMatters:
'In plant care, too many “helpful” tasks often create the next error.',
},
{
title: 'Care tracking model',
greenlens:
'GreenLens frames care around evidence from the plant and the recent environment, not routine by default.',
competitor:
'Plantum includes journals and care tasks, but the structure still tends to pull users into predefined care systems.',
whyItMatters:
'A system is only useful if it matches the current state of the plant.',
},
{
title: 'Pricing friction',
greenlens:
'GreenLens can still monetize advanced AI help, but the value story is clarity and fairness at the decision point.',
competitor:
'Plantum typically gates deeper diagnosis and larger plant management needs behind subscription pressure.',
whyItMatters:
'People comparing alternatives often feel they are paying for complexity before they see clear help.',
},
{
title: 'Plant identification',
greenlens:
'Identification supports the diagnosis flow, but GreenLens is not positioned as the broadest species database.',
competitor:
'Plantum markets strong identification depth, often with claims around tens of thousands of species and very high accuracy.',
whyItMatters:
'If you mainly want a broad AI plant assistant, Plantum stays credible. If you need triage, GreenLens is easier to justify.',
},
{
title: 'Outcome for stressed plants',
greenlens:
'The product story is built around the next right move: inspect moisture, review the last change, and avoid reactive care stacking.',
competitor:
'Plantum can generate a more comprehensive response, but not always a more usable one under stress.',
whyItMatters:
'The best care plan is the one a worried beginner can actually follow correctly.',
},
],
greenLensBestFor: [
'Plant owners who want a practical rescue workflow instead of a bigger care system.',
'Beginners who get overwhelmed by long disease reports and plant-care checklists.',
'Users who care more about the next safe action than a full assistant dashboard.',
],
competitorBestFor: [
'Users who want a richer all-in-one plant assistant with more structured care content.',
'People who are comfortable interpreting longer reports and broader care workflows.',
'Plant owners who want journals, tasks, and a deeper “care assistant” feel around every plant.',
],
emergencyScenarios: [
{
symptom: 'Yellow leaves with no obvious cause',
greenlens:
'GreenLens narrows the response to what changed recently and what the safest next check is before the user starts “doing more.”',
competitor:
'Plantum is more likely to send the user into a broader diagnostic and care framework that feels complete but slower to act on.',
},
{
symptom: 'Soft stems or soggy soil',
greenlens:
'GreenLens keeps the focus on stopping the wrong behavior first instead of layering more plant-care tasks on top.',
competitor:
'Plantum can provide extensive advice, but more depth is not always better when the likely issue is already over-care.',
},
{
symptom: 'Multiple symptoms after a repot or environment shift',
greenlens:
'GreenLens frames the situation around the recent change event and next low-risk step.',
competitor:
'Plantum offers more reporting, but that can still leave the user deciding among too many actions at once.',
},
],
faqs: [
{
question: 'Is GreenLens less powerful than Plantum because it is simpler?',
answer:
'Not for the job GreenLens is trying to do. Plantum offers a wider assistant model. GreenLens intentionally narrows the workflow so a stressed plant owner gets to the next decision faster.',
},
{
question: 'Who should still choose Plantum over GreenLens?',
answer:
'Choose Plantum if you want a more expansive all-in-one care assistant, broader reporting, and a more structured plant-management experience around each plant.',
},
{
question: 'Why does GreenLens emphasize triage instead of full care plans first?',
answer:
'Because the biggest beginner mistake is often reacting too fast with too many fixes. GreenLens is designed to reduce that risk by sequencing the next step more clearly.',
},
{
question: 'Does GreenLens ignore long-term care tracking?',
answer:
'No. GreenLens still supports ongoing care and collection management. The difference is that the comparison pages prioritize its emergency and decision-support value over the promise of being an all-in-one assistant for everything.',
},
],
},
}
export const competitorOrder: CompetitorSlug[] = ['picturethis', 'plantum']
export function getCompetitorBySlug(slug: string): CompetitorProfile | undefined {
if (slug === 'picturethis' || slug === 'plantum') {
return competitorProfiles[slug]
}
return undefined
}
export function getPeerCompetitors(currentSlug: CompetitorSlug): CompetitorProfile[] {
return competitorOrder
.filter((slug) => slug !== currentSlug)
.map((slug) => competitorProfiles[slug])
}

View File

@@ -41,7 +41,7 @@ export const translations = {
tag: 'Features', tag: 'Features',
h2a: 'Alles, was dein', h2a: 'Alles, was dein',
h2b: 'Urban Jungle braucht.', h2b: 'Urban Jungle braucht.',
desc: 'Von der ersten Identifikation bis zur laufenden Pflege hilft GreenLens dir, Pflanzen besser zu verstehen und besser zu organisieren.', desc: 'Von der ersten Identifikation bis zur laufenden Pflege hilft GreenLens dir, Pflanzen besser zu verstehen und besser zu organisieren. Das Lexikon umfasst ueber 450 Pflanzenarten.',
}, },
cta: { cta: {
tag: 'Download', tag: 'Download',
@@ -107,7 +107,7 @@ export const translations = {
tag: 'Features', tag: 'Features',
h2a: 'Everything your', h2a: 'Everything your',
h2b: 'Urban Jungle needs.', h2b: 'Urban Jungle needs.',
desc: 'From first identification to ongoing care, GreenLens helps you understand plants better and stay organized.', desc: 'From first identification to ongoing care, GreenLens helps you understand plants better and stay organized. The lexicon covers 450+ plant species.',
}, },
cta: { cta: {
tag: 'Download', tag: 'Download',
@@ -173,7 +173,7 @@ export const translations = {
tag: 'Funciones', tag: 'Funciones',
h2a: 'Todo lo que tu', h2a: 'Todo lo que tu',
h2b: 'jardin urbano necesita.', h2b: 'jardin urbano necesita.',
desc: 'Desde la primera identificacion hasta el cuidado continuo, GreenLens te ayuda a entender mejor tus plantas y a organizarte.', desc: 'Desde la primera identificacion hasta el cuidado continuo, GreenLens te ayuda a entender mejor tus plantas y a organizarte. El lexico cubre mas de 450 especies de plantas.',
}, },
cta: { cta: {
tag: 'Descarga', tag: 'Descarga',

View File

@@ -1,20 +1,20 @@
const siteUrl = (process.env.NEXT_PUBLIC_SITE_URL || 'https://greenlenspro.com').trim() const siteUrl = (process.env.NEXT_PUBLIC_SITE_URL || 'https://greenlenspro.com').trim()
export const siteConfig = { export const siteConfig = {
name: 'GreenLens', name: 'GreenLens',
domain: siteUrl, domain: siteUrl,
supportEmail: 'knuth.timo@gmail.com', supportEmail: 'knuth.timo@gmail.com',
legalEmail: 'knuth.timo@gmail.com', legalEmail: 'knuth.timo@gmail.com',
iosAppStoreUrl: '', iosAppStoreUrl: 'https://apps.apple.com/de/app/greenlens-pro/id6759843546?l=en-GB',
androidPlayStoreUrl: '', androidPlayStoreUrl: '',
company: { company: {
legalName: 'GreenLens', legalName: 'GreenLens',
representative: 'Tim Knuth', representative: 'Tim Knuth',
addressLine1: 'Replace with your legal business address', addressLine1: '',
addressLine2: '', addressLine2: '',
country: 'Germany', country: 'Germany',
registry: 'Replace with your company registry details', registry: '',
vatId: 'Replace with your VAT ID or remove this line', vatId: '',
}, },
} as const } as const

View File

@@ -3,9 +3,6 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', output: 'standalone',
images: {
unoptimized: true,
},
turbopack: { turbopack: {
root: path.join(__dirname), root: path.join(__dirname),
}, },

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 985 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 854 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 KiB

85
keyword-research.csv Normal file
View File

@@ -0,0 +1,85 @@
Cluster;Keyword;Suchanfragen/Monat;Trend 3M;Trend YoY;Wettbewerb;CPC Min (€);CPC Max (€);Typ
Plant Identification;plant identifier app;10.000-100.000;0%;0%;Mittel;0,62;2,58;Eingabe
Plant Identification;identify plants by photo;10.000-100.000;+900%;+900%;Mittel;0,44;1,56;Eingabe
Plant Identification;plant recognition app;10.000-100.000;0%;0%;Mittel;0,62;2,58;Eingabe
Plant Identification;identify plant from picture;10.000-100.000;+900%;+900%;Mittel;0,44;1,56;Eingabe
Plant Identification;plant scanner app;100-1.000;0%;0%;Mittel;1,03;3,99;Eingabe
Plant Identification;plant id app;10.000-100.000;0%;0%;Mittel;0,62;2,58;Eingabe
Plant Identification;free plant identifier app;10.000-100.000;+900%;0%;Mittel;0,32;1,25;Idee
Plant Identification;plant app;1.000-10.000;0%;-90%;Mittel;1,14;3,78;Idee
Plant Identification;plant identifier free;10.000-100.000;0%;0%;Mittel;0,41;1,49;Idee
Plant Identification;free plant identifier;1.000-10.000;0%;0%;Mittel;0,32;1,07;Idee
Plant Identification;free plant identification app;1.000-10.000;0%;0%;Mittel;0,27;1,08;Idee
Plant Identification;app to identify plants;10.000-100.000;0%;0%;Mittel;0,62;2,58;Idee
Plant Identification;tree identification app;10.000-100.000;0%;0%;Mittel;0,58;2,28;Idee
Plant Identification;plant diagnosis app;1.000-10.000;0%;0%;Hoch;1,63;5,22;Idee
Plant Identification;plant app free;1.000-10.000;0%;0%;Hoch;0,70;2,64;Idee
Plant Identification;plant identifier;100.000-1.000.000;0%;-90%;Mittel;0,55;1,83;Idee
Plant Care;plant care app;1.000-10.000;0%;0%;Mittel;1,28;3,78;Eingabe
Plant Care;houseplant care app;10-100;0%;+900%;Mittel;1,30;3,59;Eingabe
Plant Care;plant watering reminder;100-1.000;0%;0%;Hoch;0,45;1,77;Eingabe
Plant Care;plant care tracker;10-100;0%;0%;Hoch;1,38;4,40;Eingabe
Plant Care;plant care;1.000-10.000;0%;-90%;Gering;0,81;2,35;Eingabe
Plant Care;plant care planner;10-100;0%;0%;Hoch;;;Eingabe
Plant Care;indoor plant care app;10-100;0%;0%;Gering;0,98;3,70;Eingabe
Plant Care;plant watering app;100-1.000;0%;0%;Mittel;1,00;2,65;Eingabe
Plant Care;plant care reminder app;10-100;0%;0%;Gering;0,75;3,03;Eingabe
Plant Health;plant disease identifier;1.000-10.000;+900%;0%;Mittel;0,95;3,04;Eingabe
Plant Health;plant health checker;100-1.000;0%;-90%;Hoch;0,77;2,16;Eingabe
Plant Health;sick plant diagnosis;100-1.000;0%;-90%;Hoch;0,97;3,23;Eingabe
Plant Health;plant disease app;100-1.000;0%;0%;Gering;0,99;3,53;Eingabe
Plant Health;brown leaves;1.000-10.000;0%;0%;Gering;0,86;2,33;Eingabe
Plant Health;plant leaves turning yellow;1.000-10.000;0%;0%;Gering;0,03;2,44;Eingabe
Plant Health;plant problem diagnosis;100-1.000;0%;-90%;Mittel;0,95;3,80;Eingabe
Plant Health;plant health app;1.000-10.000;0%;0%;Hoch;1,49;4,73;Eingabe
Plant Health;pest identification;100-1.000;0%;0%;Gering;0,73;7,76;Eingabe
Deutsche Keywords;pflanzen erkennen app;1.000-10.000;0%;0%;Mittel;0,13;0,74;Eingabe
Deutsche Keywords;pflanzenerkennung app;1.000-10.000;+900%;0%;Mittel;0,12;0,66;Eingabe
Deutsche Keywords;pflanzen bestimmen app;1.000-10.000;+900%;0%;Mittel;0,11;0,68;Eingabe
Deutsche Keywords;pflanzen app;1.000-10.000;0%;0%;Mittel;0,22;1,03;Eingabe
Deutsche Keywords;zimmerpflanzen;10.000-100.000;0%;0%;Hoch;0,15;0,51;Eingabe
Deutsche Keywords;pflanzen scanner app;100-1.000;+900%;0%;Mittel;0,26;1,27;Eingabe
Deutsche Keywords;pflanzen identifizieren app;100-1.000;+900%;0%;Mittel;0,17;0,83;Eingabe
Collection & Tracking;plant collection app;10-100;0%;0%;Gering;0,82;2,65;Eingabe
Collection & Tracking;track plant growth;10-100;0%;0%;Gering;;;Eingabe
Collection & Tracking;plant journal app;10-100;0%;0%;Mittel;0,80;2,09;Eingabe
Collection & Tracking;plant tracker app;100-1.000;0%;0%;Gering;1,16;2,93;Eingabe
Collection & Tracking;houseplant tracker;10-100;0%;0%;Gering;;;Eingabe
Collection & Tracking;plant diary app;10-100;0%;0%;Gering;0,57;1,70;Eingabe
Collection & Tracking;plant log app;10-100;0%;0%;Mittel;0,86;2,94;Eingabe
Collection & Tracking;plant management app;10-100;0%;0%;Mittel;1,09;2,20;Eingabe
Collection & Tracking;plant growth journal;10-100;0%;0%;Mittel;;;Eingabe
Competitor Alternatives;picturethis alternative;10-100;0%;0%;Gering;0,18;1,30;Eingabe
Competitor Alternatives;plantnet alternative;10-100;0%;0%;Mittel;;;Eingabe
Competitor Alternatives;best plant identification app;1.000-10.000;0%;0%;Mittel;0,92;3,35;Eingabe
Competitor Alternatives;plant identifier free;10.000-100.000;0%;0%;Mittel;0,41;1,49;Eingabe
Competitor Alternatives;plant scanner free;100-1.000;0%;0%;Hoch;0,69;2,69;Eingabe
Competitor Alternatives;plant recognition free;10-100;0%;0%;Hoch;0,21;0,55;Eingabe
Competitor Alternatives;plant id free app;100-1.000;0%;0%;Mittel;0,28;1,16;Eingabe
Competitor Alternatives;inaturalist;10.000-100.000;+900%;+900%;Gering;0,30;1,03;Eingabe
Urban Jungle & Indoor;urban jungle app;10-100;0%;0%;Gering;;;Eingabe
Urban Jungle & Indoor;indoor plant app;100-1.000;0%;0%;Hoch;1,24;3,83;Eingabe
Urban Jungle & Indoor;houseplant app;100-1.000;0%;0%;Mittel;1,13;3,25;Eingabe
Urban Jungle & Indoor;indoor gardening app;10-100;0%;0%;Mittel;;;Eingabe
Urban Jungle & Indoor;succulent care app;10-100;0%;0%;Gering;;;Eingabe
Urban Jungle & Indoor;fiddle leaf fig;10.000-100.000;0%;0%;Hoch;0,03;0,91;Eingabe
iOS & App Store;plant app iphone;10-100;0%;0%;Gering;1,45;3,89;Eingabe
iOS & App Store;plant app ios;10-100;0%;0%;Mittel;;;Eingabe
iOS & App Store;best plant app for iphone;10-100;0%;-90%;Mittel;1,20;3,06;Eingabe
iOS & App Store;plant identifier iphone;100-1.000;0%;0%;Mittel;0,43;1,27;Eingabe
iOS & App Store;plant scanner iphone;10-100;0%;0%;Hoch;;;Eingabe
iOS & App Store;gardening app iphone;10-100;0%;0%;Mittel;0,65;2,28;Eingabe
AI & Technology;ai plant identifier;1.000-10.000;0%;0%;Mittel;0,65;2,22;Eingabe
AI & Technology;ai plant recognition;10-100;0%;0%;Mittel;0,40;2,00;Eingabe
AI & Technology;ai plant care;10-100;0%;0%;Mittel;0,71;2,45;Eingabe
AI & Technology;plant id;100.000-1.000.000;0%;-90%;Mittel;0,55;1,83;Eingabe
AI & Technology;smart plant care;10-100;0%;0%;Hoch;0,51;1,71;Eingabe
AI & Technology;plant ai app;100-1.000;0%;0%;Mittel;0,88;3,31;Eingabe
Spanish Keywords;identificar plantas app;10-100;0%;0%;Mittel;0,13;0,81;Eingabe
Spanish Keywords;app para identificar plantas;100-1.000;0%;0%;Mittel;0,07;0,77;Eingabe
Spanish Keywords;cuidado de plantas app;10-100;0%;0%;Gering;0,03;0,22;Eingabe
Spanish Keywords;identificador de plantas;1.000-10.000;+900%;0%;Mittel;0,06;0,39;Eingabe
Spanish Keywords;app plantas gratis;100-1.000;0%;0%;Mittel;0,08;0,50;Eingabe
Spanish Keywords;cuidado plantas interior;10-100;0%;-90%;Hoch;0,15;0,88;Eingabe
Spanish Keywords;app jardinería;10-100;0%;0%;Gering;0,22;0,75;Eingabe
Spanish Keywords;identificar planta foto;10-100;0%;0%;Gering;;;Eingabe
1 Cluster Keyword Suchanfragen/Monat Trend 3M Trend YoY Wettbewerb CPC Min (€) CPC Max (€) Typ
2 Plant Identification plant identifier app 10.000-100.000 0% 0% Mittel 0,62 2,58 Eingabe
3 Plant Identification identify plants by photo 10.000-100.000 +900% +900% Mittel 0,44 1,56 Eingabe
4 Plant Identification plant recognition app 10.000-100.000 0% 0% Mittel 0,62 2,58 Eingabe
5 Plant Identification identify plant from picture 10.000-100.000 +900% +900% Mittel 0,44 1,56 Eingabe
6 Plant Identification plant scanner app 100-1.000 0% 0% Mittel 1,03 3,99 Eingabe
7 Plant Identification plant id app 10.000-100.000 0% 0% Mittel 0,62 2,58 Eingabe
8 Plant Identification free plant identifier app 10.000-100.000 +900% 0% Mittel 0,32 1,25 Idee
9 Plant Identification plant app 1.000-10.000 0% -90% Mittel 1,14 3,78 Idee
10 Plant Identification plant identifier free 10.000-100.000 0% 0% Mittel 0,41 1,49 Idee
11 Plant Identification free plant identifier 1.000-10.000 0% 0% Mittel 0,32 1,07 Idee
12 Plant Identification free plant identification app 1.000-10.000 0% 0% Mittel 0,27 1,08 Idee
13 Plant Identification app to identify plants 10.000-100.000 0% 0% Mittel 0,62 2,58 Idee
14 Plant Identification tree identification app 10.000-100.000 0% 0% Mittel 0,58 2,28 Idee
15 Plant Identification plant diagnosis app 1.000-10.000 0% 0% Hoch 1,63 5,22 Idee
16 Plant Identification plant app free 1.000-10.000 0% 0% Hoch 0,70 2,64 Idee
17 Plant Identification plant identifier 100.000-1.000.000 0% -90% Mittel 0,55 1,83 Idee
18 Plant Care plant care app 1.000-10.000 0% 0% Mittel 1,28 3,78 Eingabe
19 Plant Care houseplant care app 10-100 0% +900% Mittel 1,30 3,59 Eingabe
20 Plant Care plant watering reminder 100-1.000 0% 0% Hoch 0,45 1,77 Eingabe
21 Plant Care plant care tracker 10-100 0% 0% Hoch 1,38 4,40 Eingabe
22 Plant Care plant care 1.000-10.000 0% -90% Gering 0,81 2,35 Eingabe
23 Plant Care plant care planner 10-100 0% 0% Hoch Eingabe
24 Plant Care indoor plant care app 10-100 0% 0% Gering 0,98 3,70 Eingabe
25 Plant Care plant watering app 100-1.000 0% 0% Mittel 1,00 2,65 Eingabe
26 Plant Care plant care reminder app 10-100 0% 0% Gering 0,75 3,03 Eingabe
27 Plant Health plant disease identifier 1.000-10.000 +900% 0% Mittel 0,95 3,04 Eingabe
28 Plant Health plant health checker 100-1.000 0% -90% Hoch 0,77 2,16 Eingabe
29 Plant Health sick plant diagnosis 100-1.000 0% -90% Hoch 0,97 3,23 Eingabe
30 Plant Health plant disease app 100-1.000 0% 0% Gering 0,99 3,53 Eingabe
31 Plant Health brown leaves 1.000-10.000 0% 0% Gering 0,86 2,33 Eingabe
32 Plant Health plant leaves turning yellow 1.000-10.000 0% 0% Gering 0,03 2,44 Eingabe
33 Plant Health plant problem diagnosis 100-1.000 0% -90% Mittel 0,95 3,80 Eingabe
34 Plant Health plant health app 1.000-10.000 0% 0% Hoch 1,49 4,73 Eingabe
35 Plant Health pest identification 100-1.000 0% 0% Gering 0,73 7,76 Eingabe
36 Deutsche Keywords pflanzen erkennen app 1.000-10.000 0% 0% Mittel 0,13 0,74 Eingabe
37 Deutsche Keywords pflanzenerkennung app 1.000-10.000 +900% 0% Mittel 0,12 0,66 Eingabe
38 Deutsche Keywords pflanzen bestimmen app 1.000-10.000 +900% 0% Mittel 0,11 0,68 Eingabe
39 Deutsche Keywords pflanzen app 1.000-10.000 0% 0% Mittel 0,22 1,03 Eingabe
40 Deutsche Keywords zimmerpflanzen 10.000-100.000 0% 0% Hoch 0,15 0,51 Eingabe
41 Deutsche Keywords pflanzen scanner app 100-1.000 +900% 0% Mittel 0,26 1,27 Eingabe
42 Deutsche Keywords pflanzen identifizieren app 100-1.000 +900% 0% Mittel 0,17 0,83 Eingabe
43 Collection & Tracking plant collection app 10-100 0% 0% Gering 0,82 2,65 Eingabe
44 Collection & Tracking track plant growth 10-100 0% 0% Gering Eingabe
45 Collection & Tracking plant journal app 10-100 0% 0% Mittel 0,80 2,09 Eingabe
46 Collection & Tracking plant tracker app 100-1.000 0% 0% Gering 1,16 2,93 Eingabe
47 Collection & Tracking houseplant tracker 10-100 0% 0% Gering Eingabe
48 Collection & Tracking plant diary app 10-100 0% 0% Gering 0,57 1,70 Eingabe
49 Collection & Tracking plant log app 10-100 0% 0% Mittel 0,86 2,94 Eingabe
50 Collection & Tracking plant management app 10-100 0% 0% Mittel 1,09 2,20 Eingabe
51 Collection & Tracking plant growth journal 10-100 0% 0% Mittel Eingabe
52 Competitor Alternatives picturethis alternative 10-100 0% 0% Gering 0,18 1,30 Eingabe
53 Competitor Alternatives plantnet alternative 10-100 0% 0% Mittel Eingabe
54 Competitor Alternatives best plant identification app 1.000-10.000 0% 0% Mittel 0,92 3,35 Eingabe
55 Competitor Alternatives plant identifier free 10.000-100.000 0% 0% Mittel 0,41 1,49 Eingabe
56 Competitor Alternatives plant scanner free 100-1.000 0% 0% Hoch 0,69 2,69 Eingabe
57 Competitor Alternatives plant recognition free 10-100 0% 0% Hoch 0,21 0,55 Eingabe
58 Competitor Alternatives plant id free app 100-1.000 0% 0% Mittel 0,28 1,16 Eingabe
59 Competitor Alternatives inaturalist 10.000-100.000 +900% +900% Gering 0,30 1,03 Eingabe
60 Urban Jungle & Indoor urban jungle app 10-100 0% 0% Gering Eingabe
61 Urban Jungle & Indoor indoor plant app 100-1.000 0% 0% Hoch 1,24 3,83 Eingabe
62 Urban Jungle & Indoor houseplant app 100-1.000 0% 0% Mittel 1,13 3,25 Eingabe
63 Urban Jungle & Indoor indoor gardening app 10-100 0% 0% Mittel Eingabe
64 Urban Jungle & Indoor succulent care app 10-100 0% 0% Gering Eingabe
65 Urban Jungle & Indoor fiddle leaf fig 10.000-100.000 0% 0% Hoch 0,03 0,91 Eingabe
66 iOS & App Store plant app iphone 10-100 0% 0% Gering 1,45 3,89 Eingabe
67 iOS & App Store plant app ios 10-100 0% 0% Mittel Eingabe
68 iOS & App Store best plant app for iphone 10-100 0% -90% Mittel 1,20 3,06 Eingabe
69 iOS & App Store plant identifier iphone 100-1.000 0% 0% Mittel 0,43 1,27 Eingabe
70 iOS & App Store plant scanner iphone 10-100 0% 0% Hoch Eingabe
71 iOS & App Store gardening app iphone 10-100 0% 0% Mittel 0,65 2,28 Eingabe
72 AI & Technology ai plant identifier 1.000-10.000 0% 0% Mittel 0,65 2,22 Eingabe
73 AI & Technology ai plant recognition 10-100 0% 0% Mittel 0,40 2,00 Eingabe
74 AI & Technology ai plant care 10-100 0% 0% Mittel 0,71 2,45 Eingabe
75 AI & Technology plant id 100.000-1.000.000 0% -90% Mittel 0,55 1,83 Eingabe
76 AI & Technology smart plant care 10-100 0% 0% Hoch 0,51 1,71 Eingabe
77 AI & Technology plant ai app 100-1.000 0% 0% Mittel 0,88 3,31 Eingabe
78 Spanish Keywords identificar plantas app 10-100 0% 0% Mittel 0,13 0,81 Eingabe
79 Spanish Keywords app para identificar plantas 100-1.000 0% 0% Mittel 0,07 0,77 Eingabe
80 Spanish Keywords cuidado de plantas app 10-100 0% 0% Gering 0,03 0,22 Eingabe
81 Spanish Keywords identificador de plantas 1.000-10.000 +900% 0% Mittel 0,06 0,39 Eingabe
82 Spanish Keywords app plantas gratis 100-1.000 0% 0% Mittel 0,08 0,50 Eingabe
83 Spanish Keywords cuidado plantas interior 10-100 0% -90% Hoch 0,15 0,88 Eingabe
84 Spanish Keywords app jardinería 10-100 0% 0% Gering 0,22 0,75 Eingabe
85 Spanish Keywords identificar planta foto 10-100 0% 0% Gering Eingabe

View File

@@ -1,8 +1,8 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const dotenv = require('dotenv'); const dotenv = require('dotenv');
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const loadEnvFiles = (filePaths) => { const loadEnvFiles = (filePaths) => {
const mergedFileEnv = {}; const mergedFileEnv = {};
@@ -25,7 +25,7 @@ loadEnvFiles([
path.join(__dirname, '.env.local'), 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 { ensureAuthSchema, signUp: authSignUp, login: authLogin, issueToken, verifyJwt } = require('./lib/auth');
const { const {
PlantImportValidationError, PlantImportValidationError,
@@ -34,34 +34,34 @@ const {
getPlants, getPlants,
rebuildPlantsCatalog, rebuildPlantsCatalog,
} = require('./lib/plants'); } = require('./lib/plants');
const { const {
chargeKey, chargeKey,
consumeCreditsWithIdempotency, consumeCreditsWithIdempotency,
endpointKey, endpointKey,
ensureBillingSchema, ensureBillingSchema,
getAccountSnapshot, getAccountSnapshot,
getBillingSummary, getBillingSummary,
getEndpointResponse, getEndpointResponse,
isInsufficientCreditsError, isInsufficientCreditsError,
simulatePurchase, simulatePurchase,
simulateWebhook, simulateWebhook,
syncRevenueCatCustomerInfo, syncRevenueCatCustomerInfo,
syncRevenueCatWebhookEvent, syncRevenueCatWebhookEvent,
storeEndpointResponse, storeEndpointResponse,
} = require('./lib/billing'); } = require('./lib/billing');
const { const {
analyzePlantHealth, analyzePlantHealth,
getHealthModel, getHealthModel,
getScanModel, getScanModel,
identifyPlant, identifyPlant,
isConfigured: isOpenAiConfigured, isConfigured: isOpenAiConfigured,
} = require('./lib/openai'); } = require('./lib/openai');
const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding'); const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding');
const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage'); const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage');
const app = express(); const app = express();
const port = Number(process.env.PORT || 3000); const port = Number(process.env.PORT || 3000);
const plantsPublicDir = path.join(__dirname, 'public', 'plants'); const plantsPublicDir = path.join(__dirname, 'public', 'plants');
const SCAN_PRIMARY_COST = 1; const SCAN_PRIMARY_COST = 1;
const SCAN_REVIEW_COST = 1; const SCAN_REVIEW_COST = 1;
@@ -69,6 +69,14 @@ const SEMANTIC_SEARCH_COST = 2;
const HEALTH_CHECK_COST = 2; const HEALTH_CHECK_COST = 2;
const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8; const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8;
let catalogCache = null;
const getCachedCatalogEntries = async (db) => {
if (catalogCache) return catalogCache;
catalogCache = await getPlants(db, { limit: 500 });
return catalogCache;
};
const DEFAULT_BOOTSTRAP_PLANTS = [ const DEFAULT_BOOTSTRAP_PLANTS = [
{ {
id: '1', id: '1',
@@ -100,6 +108,14 @@ const DEFAULT_BOOTSTRAP_PLANTS = [
}, },
]; ];
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; let db;
const parseBoolean = (value, fallbackValue) => { const parseBoolean = (value, fallbackValue) => {
@@ -172,7 +188,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; if (!Array.isArray(entries) || entries.length === 0) return null;
const baseHash = hashString(`${imageUri || ''}|${entries.length}`); const baseHash = hashString(`${imageUri || ''}|${entries.length}`);
const index = baseHash % entries.length; const index = baseHash % entries.length;
@@ -180,11 +196,13 @@ const pickCatalogFallback = (entries, imageUri, preferHighConfidence = false) =>
const confidence = preferHighConfidence const confidence = preferHighConfidence
? 0.22 + ((baseHash % 3) / 100) ? 0.22 + ((baseHash % 3) / 100)
: 0.18 + ((baseHash % 7) / 100); : 0.18 + ((baseHash % 7) / 100);
console.warn('Using hash-based catalog fallback — OpenAI is unavailable or returned null.', { if (!silent) {
plant: entries[index]?.name, console.warn('Using hash-based catalog fallback — OpenAI is unavailable or returned null.', {
confidence, plant: entries[index]?.name,
imageHint: (imageUri || '').slice(0, 80), confidence,
}); imageHint: (imageUri || '').slice(0, 80),
});
}
return toPlantResult(entries[index], confidence); return toPlantResult(entries[index], confidence);
}; };
@@ -277,93 +295,231 @@ const ensureNonEmptyString = (value, fieldName) => {
throw error; 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 seedBootstrapCatalogIfNeeded = async () => {
const existing = await getPlants(db, { limit: 1 }); const fullCatalog = loadFullBootstrapCatalog();
if (existing.length > 0) return; 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, { await rebuildPlantsCatalog(db, DEFAULT_BOOTSTRAP_PLANTS, {
source: 'bootstrap', source: 'bootstrap_minimal_catalog',
preserveExistingIds: false, preserveExistingIds: false,
enforceUniqueImages: false, enforceUniqueImages: false,
}); });
console.warn('Full bootstrap catalog was not found. Seeded minimal fallback catalog with 2 entries.');
}; };
app.use(cors()); app.use(cors());
app.use('/plants', express.static(plantsPublicDir)); app.use('/plants', express.static(plantsPublicDir));
const revenueCatWebhookSecret = (process.env.REVENUECAT_WEBHOOK_SECRET || '').trim(); const revenueCatWebhookSecret = (process.env.REVENUECAT_WEBHOOK_SECRET || '').trim();
const isAuthorizedRevenueCatWebhook = (request) => { const isAuthorizedRevenueCatWebhook = (request) => {
if (!revenueCatWebhookSecret) return true; if (!revenueCatWebhookSecret) return true;
const headerValue = request.header('authorization') || request.header('Authorization') || ''; const headerValue = request.header('authorization') || request.header('Authorization') || '';
const normalized = String(headerValue).trim(); const normalized = String(headerValue).trim();
return normalized === revenueCatWebhookSecret || normalized === `Bearer ${revenueCatWebhookSecret}`; return normalized === revenueCatWebhookSecret || normalized === `Bearer ${revenueCatWebhookSecret}`;
}; };
app.post('/api/revenuecat/webhook', express.json({ limit: '1mb' }), async (request, response) => { app.post('/api/revenuecat/webhook', express.json({ limit: '1mb' }), async (request, response) => {
try { try {
if (!isAuthorizedRevenueCatWebhook(request)) { if (!isAuthorizedRevenueCatWebhook(request)) {
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid RevenueCat webhook secret.' }); return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid RevenueCat webhook secret.' });
} }
const eventPayload = request.body?.event || request.body; const eventPayload = request.body?.event || request.body;
const result = await syncRevenueCatWebhookEvent(db, eventPayload); const result = await syncRevenueCatWebhookEvent(db, eventPayload);
response.status(200).json({ received: true, syncedAt: result.syncedAt }); response.status(200).json({ received: true, syncedAt: result.syncedAt });
} catch (error) { } catch (error) {
const payload = toApiErrorPayload(error); const payload = toApiErrorPayload(error);
response.status(payload.status).json(payload.body); response.status(payload.status).json(payload.body);
} }
}); });
app.use(express.json({ limit: '10mb' })); app.use(express.json({ limit: '10mb' }));
app.get('/', (_request, response) => { app.get('/', (_request, response) => {
response.status(200).json({ response.status(200).json({
service: 'greenlns-api', service: 'greenlns-api',
status: 'ok', status: 'ok',
endpoints: [ endpoints: [
'GET /health', 'GET /health',
'GET /api/plants', 'GET /api/plants',
'POST /api/plants/rebuild', 'POST /api/plants/rebuild',
'POST /auth/signup', 'POST /auth/signup',
'POST /auth/login', 'POST /auth/login',
'GET /v1/billing/summary', 'GET /v1/billing/summary',
'POST /v1/billing/sync-revenuecat', 'POST /v1/billing/sync-revenuecat',
'POST /v1/scan', 'POST /v1/scan',
'POST /v1/search/semantic', 'POST /v1/search/semantic',
'POST /v1/health-check', 'POST /v1/health-check',
'POST /v1/billing/simulate-purchase', 'POST /v1/billing/simulate-purchase',
'POST /v1/billing/simulate-webhook', 'POST /v1/billing/simulate-webhook',
'POST /v1/upload/image', 'POST /v1/upload/image',
'POST /api/revenuecat/webhook', 'POST /api/revenuecat/webhook',
], ],
}); });
}); });
const getDatabaseHealthTarget = () => { const getDatabaseHealthTarget = () => {
const raw = getDefaultDbPath(); const raw = getDefaultDbPath();
if (!raw) return ''; if (!raw) return '';
try { try {
const parsed = new URL(raw); const parsed = new URL(raw);
const databaseName = parsed.pathname.replace(/^\//, ''); const databaseName = parsed.pathname.replace(/^\//, '');
return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}/${databaseName}`; return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}/${databaseName}`;
} catch { } catch {
return 'configured'; return 'configured';
} }
}; };
app.get('/health', (_request, response) => { app.get('/health', (_request, response) => {
response.status(200).json({ response.status(200).json({
ok: true, ok: true,
uptimeSec: Math.round(process.uptime()), uptimeSec: Math.round(process.uptime()),
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
openAiConfigured: isOpenAiConfigured(), openAiConfigured: isOpenAiConfigured(),
dbReady: Boolean(db), dbReady: Boolean(db),
dbPath: getDatabaseHealthTarget(), dbPath: getDatabaseHealthTarget(),
scanModel: getScanModel(), scanModel: getScanModel(),
healthModel: getHealthModel(), healthModel: getHealthModel(),
}); });
}); });
app.get('/api/plants', async (request, response) => { app.get('/api/plants', async (request, response) => {
try { try {
@@ -423,43 +579,43 @@ app.post('/api/plants/rebuild', async (request, response) => {
} }
}); });
app.get('/v1/billing/summary', async (request, response) => { app.get('/v1/billing/summary', async (request, response) => {
try { try {
const userId = ensureRequestAuth(request); const userId = ensureRequestAuth(request);
if (userId !== 'guest') { if (userId !== 'guest') {
const userExists = await get(db, 'SELECT id FROM auth_users WHERE id = $1', [userId]); const userExists = await get(db, 'SELECT id FROM auth_users WHERE id = $1', [userId]);
if (!userExists) { if (!userExists) {
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'User not found.' }); return response.status(401).json({ code: 'UNAUTHORIZED', message: 'User not found.' });
} }
} }
const summary = await getBillingSummary(db, userId); const summary = await getBillingSummary(db, userId);
response.status(200).json(summary); response.status(200).json(summary);
} catch (error) { } catch (error) {
const payload = toApiErrorPayload(error); const payload = toApiErrorPayload(error);
response.status(payload.status).json(payload.body); response.status(payload.status).json(payload.body);
} }
}); });
app.post('/v1/billing/sync-revenuecat', async (request, response) => { app.post('/v1/billing/sync-revenuecat', async (request, response) => {
try { try {
const userId = ensureRequestAuth(request); const userId = ensureRequestAuth(request);
if (userId === 'guest') { if (userId === 'guest') {
return response.status(400).json({ code: 'BAD_REQUEST', message: 'Guest users cannot sync RevenueCat state.' }); return response.status(400).json({ code: 'BAD_REQUEST', message: 'Guest users cannot sync RevenueCat state.' });
} }
const customerInfo = request.body?.customerInfo; const customerInfo = request.body?.customerInfo;
const source = typeof request.body?.source === 'string' ? request.body.source : undefined; const source = typeof request.body?.source === 'string' ? request.body.source : undefined;
if (!customerInfo || typeof customerInfo !== 'object' || !customerInfo.entitlements) { if (!customerInfo || typeof customerInfo !== 'object' || !customerInfo.entitlements) {
return response.status(400).json({ code: 'BAD_REQUEST', message: 'customerInfo is required.' }); return response.status(400).json({ code: 'BAD_REQUEST', message: 'customerInfo is required.' });
} }
const payload = await syncRevenueCatCustomerInfo(db, userId, customerInfo, { source }); const payload = await syncRevenueCatCustomerInfo(db, userId, customerInfo, { source });
response.status(200).json(payload); response.status(200).json(payload);
} catch (error) { } catch (error) {
const payload = toApiErrorPayload(error); const payload = toApiErrorPayload(error);
response.status(payload.status).json(payload.body); response.status(payload.status).json(payload.body);
} }
}); });
app.post('/v1/scan', async (request, response) => { app.post('/v1/scan', async (request, response) => {
let userId = 'unknown'; let userId = 'unknown';
try { try {
userId = ensureRequestAuth(request); userId = ensureRequestAuth(request);
@@ -479,19 +635,17 @@ app.post('/v1/scan', async (request, response) => {
let modelUsed = null; let modelUsed = null;
let modelFallbackCount = 0; let modelFallbackCount = 0;
if (!isGuest(userId)) { const [creditResult, accountSnapshot, catalogEntries] = await Promise.all([
creditsCharged += await consumeCreditsWithIdempotency( isGuest(userId)
db, ? Promise.resolve(0)
userId, : consumeCreditsWithIdempotency(db, userId, chargeKey('scan-primary', userId, idempotencyKey), SCAN_PRIMARY_COST),
chargeKey('scan-primary', userId, idempotencyKey), getAccountSnapshot(db, userId),
SCAN_PRIMARY_COST, getCachedCatalogEntries(db),
); ]);
} creditsCharged += creditResult;
const accountSnapshot = await getAccountSnapshot(db, userId);
const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free'; const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free';
const catalogEntries = await getPlants(db, { limit: 500 }); let result = pickCatalogFallback(catalogEntries, imageUri, false, { silent: true });
let result = pickCatalogFallback(catalogEntries, imageUri, false);
let usedOpenAi = false; let usedOpenAi = false;
if (isOpenAiConfigured()) { if (isOpenAiConfigured()) {
@@ -516,7 +670,10 @@ app.post('/v1/scan', async (request, response) => {
modelPath.push('openai-primary'); modelPath.push('openai-primary');
if (grounded.grounded) modelPath.push('catalog-grounded-primary'); if (grounded.grounded) modelPath.push('catalog-grounded-primary');
} else { } 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('openai-primary-failed');
modelPath.push('catalog-primary-fallback'); modelPath.push('catalog-primary-fallback');
} }
@@ -565,11 +722,13 @@ app.post('/v1/scan', async (request, response) => {
modelPath.push('openai-review'); modelPath.push('openai-review');
if (grounded.grounded) modelPath.push('catalog-grounded-review'); if (grounded.grounded) modelPath.push('catalog-grounded-review');
} else { } 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'); modelPath.push('openai-review-failed');
} }
} else { } else {
const reviewFallback = pickCatalogFallback(catalogEntries, `${imageUri}|review`, true); const reviewFallback = pickCatalogFallback(catalogEntries, `${imageUri}|review`, true, { silent: true });
if (reviewFallback) { if (reviewFallback) {
result = reviewFallback; result = reviewFallback;
} }
@@ -681,9 +840,46 @@ app.post('/v1/health-check', async (request, response) => {
}); });
const analysis = analysisResponse?.analysis; const analysis = analysisResponse?.analysis;
if (!analysis) { if (!analysis) {
const error = new Error('OpenAI health check failed. Please verify API key, model, and network access.'); // All models in the chain failed (timeout, quota, network) — return a graceful
error.code = 'PROVIDER_ERROR'; // "unavailable" result instead of PROVIDER_ERROR so the user never sees an error alert.
throw error; // 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 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; let creditsCharged = 0;
@@ -812,19 +1008,19 @@ app.post('/auth/login', async (request, response) => {
// ─── Startup ─────────────────────────────────────────────────────────────── // ─── Startup ───────────────────────────────────────────────────────────────
const start = async () => { const start = async () => {
db = await openDatabase(); db = await openDatabase();
await ensurePlantSchema(db); await ensurePlantSchema(db);
await ensureBillingSchema(db); await ensureBillingSchema(db);
await ensureAuthSchema(db); await ensureAuthSchema(db);
await seedBootstrapCatalogIfNeeded(); await seedBootstrapCatalogIfNeeded();
if (isStorageConfigured()) { if (isStorageConfigured()) {
await ensureStorageBucketWithRetry().catch((err) => console.warn('MinIO bucket setup failed:', err.message)); await ensureStorageBucketWithRetry().catch((err) => console.warn('MinIO bucket setup failed:', err.message));
} }
const server = app.listen(port, () => { const server = app.listen(port, () => {
console.log(`GreenLens server listening at http://localhost:${port}`); console.log(`GreenLens server listening at http://localhost:${port}`);
}); });
const gracefulShutdown = async () => { const gracefulShutdown = async () => {
try { try {

View File

@@ -18,6 +18,28 @@ const AVAILABLE_PRODUCTS = ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_m
const nowIso = () => new Date().toISOString(); 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) => { const startOfUtcMonth = (date) => {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0)); 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) => { const normalizeAccountRow = (row) => {
if (!row) return null; if (!row) return null;
const now = new Date();
const { cycleStartedAt: defaultCycleStartedAt, cycleEndsAt: defaultCycleEndsAt } = getCycleBounds(now);
return { return {
userId: String(row.userId), userId: String(row.userId),
plan: row.plan === 'pro' ? 'pro' : 'free', plan: row.plan === 'pro' ? 'pro' : 'free',
provider: typeof row.provider === 'string' && row.provider ? row.provider : 'revenuecat', provider: typeof row.provider === 'string' && row.provider ? row.provider : 'revenuecat',
cycleStartedAt: String(row.cycleStartedAt), cycleStartedAt: asIsoDate(row.cycleStartedAt) || defaultCycleStartedAt.toISOString(),
cycleEndsAt: String(row.cycleEndsAt), cycleEndsAt: asIsoDate(row.cycleEndsAt) || defaultCycleEndsAt.toISOString(),
monthlyAllowance: Number(row.monthlyAllowance) || FREE_MONTHLY_CREDITS, monthlyAllowance: Number(row.monthlyAllowance) || FREE_MONTHLY_CREDITS,
usedThisCycle: Number(row.usedThisCycle) || 0, usedThisCycle: Number(row.usedThisCycle) || 0,
topupBalance: Number(row.topupBalance) || 0, topupBalance: Number(row.topupBalance) || 0,
renewsAt: row.renewsAt ? String(row.renewsAt) : null, renewsAt: asIsoDate(row.renewsAt),
updatedAt: row.updatedAt ? String(row.updatedAt) : nowIso(), 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) => { const isSupportedTopupProduct = (productId) => {
return typeof productId === 'string' return typeof productId === 'string'
&& productId.startsWith('topup_') && productId.startsWith('topup_')
@@ -303,6 +308,18 @@ const getValidProEntitlement = (customerInfo) => {
return proEntitlement; 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)); console.warn('[Billing] Ignoring unsupported RevenueCat pro entitlement', summarizeRevenueCatCustomerInfo(customerInfo));
return null; return null;
}; };
@@ -398,6 +415,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(); account.updatedAt = nowIso();
await upsertAccount(tx, account); await upsertAccount(tx, account);
return { return {

View File

@@ -107,10 +107,16 @@ const normalizeIdentifyResult = (raw, language) => {
const waterIntervalRaw = getNumber(careInfoRaw.waterIntervalDays); const waterIntervalRaw = getNumber(careInfoRaw.waterIntervalDays);
const light = getString(careInfoRaw.light); const light = getString(careInfoRaw.light);
const temp = getString(careInfoRaw.temp); const temp = getString(careInfoRaw.temp);
if (waterIntervalRaw == null || !light || !temp) { if (waterIntervalRaw == null) {
return null; return null;
} }
const LIGHT_DEFAULTS = { de: 'Helles indirektes Licht', es: 'Luz indirecta brillante', en: 'Bright indirect light' };
const TEMP_DEFAULTS = { de: '1525 °C', es: '1525 °C', en: '5977 °F (1525 °C)' };
const lang = language || 'en';
const resolvedLight = (light && light !== 'Unknown') ? light : (LIGHT_DEFAULTS[lang] || LIGHT_DEFAULTS.en);
const resolvedTemp = (temp && temp !== 'Unknown') ? temp : (TEMP_DEFAULTS[lang] || TEMP_DEFAULTS.en);
const fallbackDescription = language === 'de' const fallbackDescription = language === 'de'
? `${name} wurde per KI erkannt. Pflegehinweise sind unten aufgefuehrt.` ? `${name} wurde per KI erkannt. Pflegehinweise sind unten aufgefuehrt.`
: language === 'es' : language === 'es'
@@ -124,8 +130,8 @@ const normalizeIdentifyResult = (raw, language) => {
description: description || fallbackDescription, description: description || fallbackDescription,
careInfo: { careInfo: {
waterIntervalDays: Math.round(clamp(waterIntervalRaw, 1, 45)), waterIntervalDays: Math.round(clamp(waterIntervalRaw, 1, 45)),
light, light: resolvedLight,
temp, temp: resolvedTemp,
}, },
}; };
}; };
@@ -137,15 +143,16 @@ const normalizeHealthAnalysis = (raw, language) => {
const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 8); const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 8);
const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 10); const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 10);
if (scoreRaw == null || !statusRaw || !Array.isArray(issuesRaw)) { // Use safe defaults instead of returning null — bad/partial JSON falls through
return null; // 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' const status = (statusRaw === 'healthy' || statusRaw === 'watch' || statusRaw === 'critical')
? statusRaw ? statusRaw
: 'watch'; : 'watch';
const issuesInput = Array.isArray(issuesRaw) ? issuesRaw : [];
const likelyIssues = issuesRaw const likelyIssues = issuesInput
.map((entry) => { .map((entry) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
const title = getString(entry.title); const title = getString(entry.title);
@@ -168,7 +175,7 @@ const normalizeHealthAnalysis = (raw, language) => {
? 'La IA no pudo extraer senales de salud estables.' ? 'La IA no pudo extraer senales de salud estables.'
: 'AI could not extract stable health signals.'; : 'AI could not extract stable health signals.';
return { return {
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)), overallHealthScore: Math.round(clamp(score, 0, 100)),
status, status,
likelyIssues: [ likelyIssues: [
{ {
@@ -191,7 +198,7 @@ const normalizeHealthAnalysis = (raw, language) => {
} }
return { return {
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)), overallHealthScore: Math.round(clamp(score, 0, 100)),
status, status,
likelyIssues, likelyIssues,
actionsNow: actionsNowRaw, actionsNow: actionsNowRaw,
@@ -215,6 +222,10 @@ const buildIdentifyPrompt = (language, mode) => {
'Rules:', 'Rules:',
nameLanguageInstruction, nameLanguageInstruction,
`- "description" and "careInfo.light" must be written in ${getLanguageLabel(language)}.`, `- "description" and "careInfo.light" must be written in ${getLanguageLabel(language)}.`,
`- "careInfo.light": short light requirement in ${getLanguageLabel(language)} (e.g. "bright indirect light", "full sun", "partial shade"). Must always be a real value, never "Unknown".`,
language === 'en'
? '- "careInfo.temp": temperature range in both Celsius and Fahrenheit (e.g. "1824 °C (6475 °F)"). Must always be a real plant-specific value, never "Unknown".'
: '- "careInfo.temp": temperature range in Celsius (e.g. "1824 °C"). Must always be a real plant-specific value, never "Unknown".',
'- "botanicalName" must use accepted Latin scientific naming and must not be invented or misspelled.', '- "botanicalName" must use accepted Latin scientific naming and must not be invented or misspelled.',
'- If species is uncertain, prefer genus-level naming (for example: "Calathea sp.").', '- If species is uncertain, prefer genus-level naming (for example: "Calathea sp.").',
'- "confidence" must be between 0 and 1.', '- "confidence" must be between 0 and 1.',
@@ -305,10 +316,14 @@ const postChatCompletion = async ({ modelChain, messages, imageUri, temperature
if (!response.ok) { if (!response.ok) {
const body = await response.text(); const body = await response.text();
let parsedError = {};
try { parsedError = JSON.parse(body); } catch {}
console.warn('OpenAI request HTTP error.', { console.warn('OpenAI request HTTP error.', {
status: response.status, status: response.status,
model, model,
endpoint: OPENAI_CHAT_COMPLETIONS_URL, endpoint: OPENAI_CHAT_COMPLETIONS_URL,
openAiCode: parsedError?.error?.code,
openAiMessage: parsedError?.error?.message,
image: summarizeImageUri(imageUri), image: summarizeImageUri(imageUri),
bodyPreview: body.slice(0, 300), bodyPreview: body.slice(0, 300),
}); });
@@ -351,7 +366,7 @@ const identifyPlant = async ({ imageUri, language, mode = 'primary', plan = 'fre
role: 'user', role: 'user',
content: [ content: [
{ type: 'text', text: buildIdentifyPrompt(language, mode) }, { type: 'text', text: buildIdentifyPrompt(language, mode) },
{ type: 'image_url', image_url: { url: imageUri } }, { type: 'image_url', image_url: { url: imageUri, detail: 'low' } },
], ],
}, },
], ],
@@ -411,7 +426,7 @@ const analyzePlantHealth = async ({ imageUri, language, plantContext }) => {
role: 'user', role: 'user',
content: [ content: [
{ type: 'text', text: buildHealthPrompt(language, plantContext) }, { type: 'text', text: buildHealthPrompt(language, plantContext) },
{ type: 'image_url', image_url: { url: imageUri } }, { type: 'image_url', image_url: { url: imageUri, detail: 'low' } },
], ],
}, },
], ],

View File

@@ -115,8 +115,8 @@ const applyCatalogGrounding = (aiResult, catalogEntries, language = 'en') => {
description: aiResult.description || matchedEntry.description || '', description: aiResult.description || matchedEntry.description || '',
careInfo: { careInfo: {
waterIntervalDays: Math.max(1, Number(matchedEntry.careInfo?.waterIntervalDays) || Number(aiResult.careInfo?.waterIntervalDays) || 7), waterIntervalDays: Math.max(1, Number(matchedEntry.careInfo?.waterIntervalDays) || Number(aiResult.careInfo?.waterIntervalDays) || 7),
light: matchedEntry.careInfo?.light || aiResult.careInfo?.light || 'Unknown', light: (matchedEntry.careInfo?.light && matchedEntry.careInfo.light !== 'Unknown') ? matchedEntry.careInfo.light : (aiResult.careInfo?.light || 'Unknown'),
temp: matchedEntry.careInfo?.temp || aiResult.careInfo?.temp || 'Unknown', temp: (matchedEntry.careInfo?.temp && matchedEntry.careInfo.temp !== 'Unknown') ? matchedEntry.careInfo.temp : (aiResult.careInfo?.temp || 'Unknown'),
}, },
}, },
}; };

View File

@@ -1,25 +1,25 @@
import { import {
BackendApiError, BackendApiError,
BackendErrorCode, BackendErrorCode,
BillingSummary, BillingSummary,
HealthCheckResponse, HealthCheckResponse,
PurchaseProductId, PurchaseProductId,
RevenueCatCustomerInfo, RevenueCatCustomerInfo,
RevenueCatSyncSource, RevenueCatSyncSource,
ScanPlantResponse, ScanPlantResponse,
SemanticSearchResponse, SemanticSearchResponse,
ServiceHealthResponse, ServiceHealthResponse,
SimulatedWebhookEvent, SimulatedWebhookEvent,
SimulatePurchaseResponse, SimulatePurchaseResponse,
SimulateWebhookResponse, SimulateWebhookResponse,
SyncRevenueCatStateResponse, SyncRevenueCatStateResponse,
} from './contracts'; } from './contracts';
import { getAuthToken } from './userIdentityService'; import { getAuthToken } from './userIdentityService';
import { mockBackendService } from './mockBackendService'; import { mockBackendService } from './mockBackendService';
import { CareInfo, Language } from '../../types'; import { CareInfo, Language } from '../../types';
import { getConfiguredBackendRootUrl } from '../../utils/backendUrl'; import { getConfiguredBackendRootUrl } from '../../utils/backendUrl';
const REQUEST_TIMEOUT_MS = 15000; const REQUEST_TIMEOUT_MS = 60000;
const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => { const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => {
if (status === 400) return 'BAD_REQUEST'; if (status === 400) return 'BAD_REQUEST';
@@ -29,12 +29,12 @@ const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => {
return 'PROVIDER_ERROR'; return 'PROVIDER_ERROR';
}; };
const buildBackendUrl = (path: string): string => { const buildBackendUrl = (path: string): string => {
const backendBaseUrl = getConfiguredBackendRootUrl(); const backendBaseUrl = getConfiguredBackendRootUrl();
if (!backendBaseUrl) return path; if (!backendBaseUrl) return path;
const base = backendBaseUrl.replace(/\/$/, ''); const base = backendBaseUrl.replace(/\/$/, '');
return `${base}${path}`; return `${base}${path}`;
}; };
const parseMaybeJson = (value: string): Record<string, unknown> | null => { const parseMaybeJson = (value: string): Record<string, unknown> | null => {
if (!value) return null; if (!value) return null;
@@ -107,18 +107,18 @@ const makeRequest = async <T,>(
}; };
export const backendApiClient = { export const backendApiClient = {
getServiceHealth: async (): Promise<ServiceHealthResponse> => { getServiceHealth: async (): Promise<ServiceHealthResponse> => {
if (!getConfiguredBackendRootUrl()) { if (!getConfiguredBackendRootUrl()) {
return { return {
ok: true, ok: true,
uptimeSec: 0, uptimeSec: 0,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
openAiConfigured: Boolean(process.env.EXPO_PUBLIC_OPENAI_API_KEY), openAiConfigured: Boolean(process.env.EXPO_PUBLIC_OPENAI_API_KEY),
dbReady: true, dbReady: true,
dbPath: 'in-app-mock-backend', dbPath: 'in-app-mock-backend',
scanModel: (process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(), scanModel: (process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(),
healthModel: (process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(), healthModel: (process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(),
}; };
} }
const token = await getAuthToken(); const token = await getAuthToken();
@@ -128,49 +128,49 @@ export const backendApiClient = {
}); });
}, },
getBillingSummary: async (): Promise<BillingSummary> => { getBillingSummary: async (): Promise<BillingSummary> => {
const token = await getAuthToken(); const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) { if (!getConfiguredBackendRootUrl()) {
return mockBackendService.getBillingSummary(token); return mockBackendService.getBillingSummary(token);
} }
return makeRequest<BillingSummary>('/v1/billing/summary', { return makeRequest<BillingSummary>('/v1/billing/summary', {
method: 'GET', method: 'GET',
token, token,
}); });
}, },
syncRevenueCatState: async (params: { syncRevenueCatState: async (params: {
customerInfo: RevenueCatCustomerInfo; customerInfo: RevenueCatCustomerInfo;
source?: RevenueCatSyncSource; source?: RevenueCatSyncSource;
}): Promise<SyncRevenueCatStateResponse> => { }): Promise<SyncRevenueCatStateResponse> => {
const token = await getAuthToken(); const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) { if (!getConfiguredBackendRootUrl()) {
return mockBackendService.syncRevenueCatState({ return mockBackendService.syncRevenueCatState({
userId: token, userId: token,
customerInfo: params.customerInfo, customerInfo: params.customerInfo,
source: params.source, source: params.source,
}); });
} }
return makeRequest<SyncRevenueCatStateResponse>('/v1/billing/sync-revenuecat', { return makeRequest<SyncRevenueCatStateResponse>('/v1/billing/sync-revenuecat', {
method: 'POST', method: 'POST',
token, token,
body: { body: {
customerInfo: params.customerInfo, customerInfo: params.customerInfo,
source: params.source, source: params.source,
}, },
}); });
}, },
scanPlant: async (params: { scanPlant: async (params: {
idempotencyKey: string; idempotencyKey: string;
imageUri: string; imageUri: string;
language: Language; language: Language;
}): Promise<ScanPlantResponse> => { }): Promise<ScanPlantResponse> => {
const token = await getAuthToken(); const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) { if (!getConfiguredBackendRootUrl()) {
return mockBackendService.scanPlant({ return mockBackendService.scanPlant({
userId: token, userId: token,
idempotencyKey: params.idempotencyKey, idempotencyKey: params.idempotencyKey,
imageUri: params.imageUri, imageUri: params.imageUri,
@@ -193,10 +193,10 @@ export const backendApiClient = {
idempotencyKey: string; idempotencyKey: string;
query: string; query: string;
language: Language; language: Language;
}): Promise<SemanticSearchResponse> => { }): Promise<SemanticSearchResponse> => {
const token = await getAuthToken(); const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) { if (!getConfiguredBackendRootUrl()) {
return mockBackendService.semanticSearch({ return mockBackendService.semanticSearch({
userId: token, userId: token,
idempotencyKey: params.idempotencyKey, idempotencyKey: params.idempotencyKey,
query: params.query, query: params.query,
@@ -225,10 +225,10 @@ export const backendApiClient = {
careInfo: CareInfo; careInfo: CareInfo;
description?: string; description?: string;
}; };
}): Promise<HealthCheckResponse> => { }): Promise<HealthCheckResponse> => {
const token = await getAuthToken(); const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) { if (!getConfiguredBackendRootUrl()) {
return mockBackendService.healthCheck({ return mockBackendService.healthCheck({
userId: token, userId: token,
idempotencyKey: params.idempotencyKey, idempotencyKey: params.idempotencyKey,
imageUri: params.imageUri, imageUri: params.imageUri,
@@ -252,10 +252,10 @@ export const backendApiClient = {
simulatePurchase: async (params: { simulatePurchase: async (params: {
idempotencyKey: string; idempotencyKey: string;
productId: PurchaseProductId; productId: PurchaseProductId;
}): Promise<SimulatePurchaseResponse> => { }): Promise<SimulatePurchaseResponse> => {
const token = await getAuthToken(); const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) { if (!getConfiguredBackendRootUrl()) {
return mockBackendService.simulatePurchase({ return mockBackendService.simulatePurchase({
userId: token, userId: token,
idempotencyKey: params.idempotencyKey, idempotencyKey: params.idempotencyKey,
productId: params.productId, productId: params.productId,
@@ -276,10 +276,10 @@ export const backendApiClient = {
idempotencyKey: string; idempotencyKey: string;
event: SimulatedWebhookEvent; event: SimulatedWebhookEvent;
payload?: { credits?: number }; payload?: { credits?: number };
}): Promise<SimulateWebhookResponse> => { }): Promise<SimulateWebhookResponse> => {
const token = await getAuthToken(); const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) { if (!getConfiguredBackendRootUrl()) {
return mockBackendService.simulateWebhook({ return mockBackendService.simulateWebhook({
userId: token, userId: token,
idempotencyKey: params.idempotencyKey, idempotencyKey: params.idempotencyKey,
event: params.event, event: params.event,
@@ -304,8 +304,9 @@ export const isInsufficientCreditsError = (error: unknown): boolean => {
}; };
export const isNetworkError = (error: unknown): boolean => { export const isNetworkError = (error: unknown): boolean => {
return ( return error instanceof BackendApiError && error.code === 'NETWORK_ERROR';
error instanceof BackendApiError && };
(error.code === 'NETWORK_ERROR' || error.code === 'TIMEOUT')
); export const isTimeoutError = (error: unknown): boolean => {
return error instanceof BackendApiError && error.code === 'TIMEOUT';
}; };

41
split_image.py Normal file
View File

@@ -0,0 +1,41 @@
import sys
from PIL import Image
def split_image(image_path, output_prefix):
try:
img = Image.open(image_path)
width, height = img.size
target_height = width // 3
if target_height > height:
target_height = height
target_width = height * 3
left = (width - target_width) // 2
top = 0
right = left + target_width
bottom = height
else:
target_width = width
left = 0
top = (height - target_height) // 2
right = width
bottom = top + target_height
img_cropped = img.crop((left, top, right, bottom))
sq_size = target_width // 3
for i in range(3):
box = (i * sq_size, 0, (i + 1) * sq_size, sq_size)
part = img_cropped.crop(box)
part.save(f"{output_prefix}_{i+1}.png")
print("Success")
except Exception as e:
print(f"Error: {str(e)}")
if __name__ == "__main__":
if len(sys.argv) != 3:
print("Usage: split_image.py <input_img> <output_prefix>")
else:
split_image(sys.argv[1], sys.argv[2])

View File

@@ -1,5 +1,3 @@
const DEFAULT_API_BASE_URL = 'http://localhost:3000/api';
const normalizeHttpUrl = (value?: string | null): string | null => { const normalizeHttpUrl = (value?: string | null): string | null => {
const trimmed = String(value || '').trim(); const trimmed = String(value || '').trim();
if (!trimmed) return null; if (!trimmed) return null;
@@ -12,32 +10,32 @@ const normalizeHttpUrl = (value?: string | null): string | null => {
} }
}; };
export const getConfiguredApiBaseUrl = (): string => { export const getConfiguredApiBaseUrl = (): string => {
const explicitApiUrl = normalizeHttpUrl(process.env.EXPO_PUBLIC_API_URL); const explicitApiUrl = normalizeHttpUrl(process.env.EXPO_PUBLIC_API_URL);
if (explicitApiUrl) return explicitApiUrl; if (explicitApiUrl) return explicitApiUrl;
const backendBaseUrl = normalizeHttpUrl(
process.env.EXPO_PUBLIC_BACKEND_URL || process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL,
);
if (backendBaseUrl) {
return backendBaseUrl.endsWith('/api') ? backendBaseUrl : `${backendBaseUrl}/api`;
}
return DEFAULT_API_BASE_URL; const backendBaseUrl = normalizeHttpUrl(
}; process.env.EXPO_PUBLIC_BACKEND_URL || process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL,
);
export const getConfiguredBackendRootUrl = (): string => { if (backendBaseUrl) {
const explicitApiUrl = normalizeHttpUrl(process.env.EXPO_PUBLIC_API_URL); return backendBaseUrl.endsWith('/api') ? backendBaseUrl : `${backendBaseUrl}/api`;
if (explicitApiUrl) { }
return explicitApiUrl.endsWith('/api')
? explicitApiUrl.slice(0, -4).replace(/\/+$/, '') return '';
: explicitApiUrl; };
}
export const getConfiguredBackendRootUrl = (): string => {
return normalizeHttpUrl( const explicitApiUrl = normalizeHttpUrl(process.env.EXPO_PUBLIC_API_URL);
process.env.EXPO_PUBLIC_BACKEND_URL || process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL, if (explicitApiUrl) {
) || ''; return explicitApiUrl.endsWith('/api')
}; ? explicitApiUrl.slice(0, -4).replace(/\/+$/, '')
: explicitApiUrl;
}
return normalizeHttpUrl(
process.env.EXPO_PUBLIC_BACKEND_URL || process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL,
) || '';
};
export const getConfiguredAssetBaseUrl = (): string => { export const getConfiguredAssetBaseUrl = (): string => {
const apiBaseUrl = getConfiguredApiBaseUrl(); const apiBaseUrl = getConfiguredApiBaseUrl();
@@ -50,6 +48,6 @@ export const getConfiguredAssetBaseUrl = (): string => {
: pathname; : pathname;
return `${parsed.origin}${assetPath}`.replace(/\/+$/, ''); return `${parsed.origin}${assetPath}`.replace(/\/+$/, '');
} catch { } catch {
return 'http://localhost:3000'; return '';
} }
}; };

View File

@@ -145,6 +145,7 @@ export const translations = {
temp: "Temperatur", temp: "Temperatur",
// Care Values (UI Helper) // Care Values (UI Helper)
unknown: "Unbekannt",
waterModerate: "Mäßig", waterModerate: "Mäßig",
waterLittle: "Wenig", waterLittle: "Wenig",
waterEveryXDays: "Alle {0} Tage", waterEveryXDays: "Alle {0} Tage",
@@ -367,6 +368,7 @@ registerToSave: "Sign up to save",
light: "Light", light: "Light",
temp: "Temperature", temp: "Temperature",
unknown: "Unknown",
waterModerate: "Moderate", waterModerate: "Moderate",
waterLittle: "Little", waterLittle: "Little",
waterEveryXDays: "Every {0} days", waterEveryXDays: "Every {0} days",
@@ -588,6 +590,7 @@ registerToSave: "Regístrate para guardar",
light: "Luz", light: "Luz",
temp: "Temperatura", temp: "Temperatura",
unknown: "Desconocido",
waterModerate: "Moderado", waterModerate: "Moderado",
waterLittle: "Poco", waterLittle: "Poco",
waterEveryXDays: "Cada {0} días", waterEveryXDays: "Cada {0} días",