Compare commits

...

16 Commits

Author SHA1 Message Date
765eea05f7 feat: implement SEO-optimized landing pages, sitemap, and updated footer for enhanced site navigation and search visibility. 2026-04-12 12:24:11 +02:00
9480a65864 Merge branch 'main' of git.bizmatch.net:tknuth/Greenlens 2026-04-11 16:34:23 -05:00
b20273b88d remove ports, local folder instead of volume 2026-04-11 16:33:52 -05:00
Timo Knuth
e8d013d99a feat: initialize landing page with dynamic competitor comparison routes and structured SEO metadata 2026-04-10 16:18:01 +02:00
8a9533c520 feat: implement plant identification result card component and initialize backend server infrastructure 2026-04-08 22:13:36 +02:00
09078c3c20 feat: implement plant health check functionality with AI analysis, credit management, and UI integration 2026-04-08 21:27:50 +02:00
de8130686a feat: implement plant health scanning functionality with backend integration and UI support 2026-04-08 19:34:43 +02:00
d0a13fa4f0 feat: implement multi-language support, SEO metadata, schema markup, and legal pages 2026-04-08 11:53:38 +02:00
c3fed5226a feat: + initialize project with docker-compose infrastructure and server application logic 2026-04-08 00:18:09 +02:00
8d90d97182 feat: initialize project with docker-compose infrastructure and server application logic 2026-04-08 00:11:24 +02:00
1b40f1eb1b feat: implement billing and subscription management screen with RevenueCat integration 2026-04-04 21:33:51 +02:00
363f5f60d1 feat: implement billing system with credit tracking and RevenueCat integration 2026-04-04 12:15:16 +02:00
439f5a44c9 feat: implement billing account management and cycle synchronization logic with accompanying tests 2026-04-03 22:26:58 +02:00
995e1daf2c greenlens_net 2026-04-03 14:41:06 -05:00
2c589b8b47 Api Port 2026-04-03 20:02:13 +02:00
0eca9a101f feat: add production landing page service and remove Caddy in favor of external reverse proxy configuration 2026-04-03 19:54:32 +02:00
55 changed files with 3943 additions and 717 deletions

View File

@@ -39,7 +39,7 @@ Optional integrations:
For backend-only local infrastructure use [docker-compose.yml](/abs/path/C:/Users/a931627/Documents/apps/GreenLns/docker-compose.yml).
For the production-style self-hosted stack with landing page, Caddy, API, PostgreSQL, and MinIO use [greenlns-landing/docker-compose.yml](/abs/path/C:/Users/a931627/Documents/apps/GreenLns/greenlns-landing/docker-compose.yml).
For the production-style self-hosted stack with landing page, API, PostgreSQL, and MinIO behind an external reverse proxy use [greenlns-landing/docker-compose.yml](/abs/path/C:/Users/a931627/Documents/apps/GreenLns/greenlns-landing/docker-compose.yml).
## Server deployment
@@ -87,18 +87,23 @@ What is not built locally, but pulled as ready-made images:
- `postgres` uses `postgres:16-alpine`
- `minio` uses `minio/minio:latest`
- `caddy` uses `caddy:2.8-alpine`
So yes: `docker compose up --build -d` builds the landing page container and the API container, and it starts PostgreSQL as a container. PostgreSQL is not "built" from your code, it is started from the official Postgres image.
This starts:
- `caddy`
- `landing`
- `api`
- `postgres`
- `minio`
Host ports for an external reverse proxy:
- `3000` -> `landing`
- `3003` -> `api`
- `9000` -> `minio` S3 API
- `9001` -> `minio` console
### 3. Useful server commands
Check running containers:
@@ -173,7 +178,7 @@ There, too:
- `landing` is built from `greenlns-landing/Dockerfile`
- `api` is built from `../server/Dockerfile`
- `postgres`, `minio`, and `caddy` are started from official images
- `postgres` and `minio` are started from official images
## iOS TestFlight

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

View File

@@ -7,6 +7,7 @@ import { Ionicons } from '@expo/vector-icons';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as ImagePicker from 'expo-image-picker';
import * as ImageManipulator from 'expo-image-manipulator';
import * as Haptics from 'expo-haptics';
import { usePostHog } from 'posthog-react-native';
import { useApp } from '../context/AppContext';
@@ -14,7 +15,7 @@ import { useColors } from '../constants/Colors';
import { PlantRecognitionService } from '../services/plantRecognitionService';
import { IdentificationResult } from '../types';
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 { createIdempotencyKey } from '../utils/idempotency';
@@ -33,6 +34,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
genericErrorMessage: 'Analyse fehlgeschlagen.',
noConnectionTitle: 'Keine Verbindung',
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',
providerErrorMessage: 'KI-Scan 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.',
noConnectionTitle: 'Sin conexión',
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',
providerErrorMessage: 'Escaneo 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.',
noConnectionTitle: 'No connection',
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',
providerErrorMessage: 'AI scan 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]);
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) => {
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)) {
Alert.alert(
billingCopy.noConnectionTitle,
@@ -327,7 +357,7 @@ export default function ScannerScreen() {
const takePicture = async () => {
if (!cameraRef.current || isAnalyzing) return;
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) {
const analysisUri = photo.base64
? `data:image/jpeg;base64,${photo.base64}`
@@ -343,17 +373,14 @@ export default function ScannerScreen() {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
quality: 0.7,
base64: true,
quality: 1,
base64: false,
});
if (!result.canceled && result.assets[0]) {
const asset = result.assets[0];
const uri = asset.base64
? `data:image/jpeg;base64,${asset.base64}`
: asset.uri;
setSelectedImage(uri);
analyzeImage(uri, asset.uri || uri);
const analysisUri = await resizeForAnalysis(asset.uri);
setSelectedImage(asset.uri);
analyzeImage(analysisUri, asset.uri);
}
};

View File

@@ -0,0 +1,555 @@
# GreenLens SEO + AI-SEO Roadmap
Stand: 2026-04-10
Quelle:
- Keyword-Datensatz: [keyword-research.csv](C:/Users/a931627/Documents/apps/GreenLns/keyword-research.csv)
- Landing Site: [greenlns-landing](C:/Users/a931627/Documents/apps/GreenLns/greenlns-landing)
## Executive Summary
GreenLens hat genug Nachfrage im Keyword-Set, um eine kleine, sehr fokussierte SEO-Architektur aufzubauen. Das größte Problem ist nicht fehlendes Suchvolumen, sondern fehlende Seitenabdeckung. Die aktuelle Landing Site deckt im Wesentlichen nur Homepage-, Support- und zwei Vergleichsintentionen ab, obwohl die Keyword-Liste starke Nachfrage in vier Kernbereichen zeigt:
1. Plant identification
2. Plant health / diagnosis
3. Plant care / reminders
4. German-language app intent
Zusätzlich gibt es AI-SEO-Potenzial, weil GreenLens bereits mit einem klaren Angle arbeitet:
- plant ER / triage
- next-step diagnosis
- calmer guidance instead of care overload
Dieser Angle ist für AI-Antworten gut verwertbar, wenn die Inhalte als zitierfähige Antwortblöcke statt als generische Marketing-Flächen gebaut werden.
## Phase 0: Fixes Before Content Rollout
Diese Punkte sollten vor dem großen Content-Rollout erledigt werden:
1. Root-canonical aus dem globalen Layout entfernen und pro Seite selbstreferenzierende Canonicals setzen.
2. Keine `hreflang`-Alternates auf `/` ausgeben, solange keine echten Locale-URLs existieren.
3. Für `/privacy`, `/terms` und `/imprint` eigene `metadata` ergänzen.
4. Platzhalter in den Rechtstexten ersetzen.
5. Encoding-/Mojibake-Probleme in sichtbarem Text bereinigen.
6. `Last updated` und Autoren-/Brand-Signale für neue SEO-Seiten einführen.
Ohne diese Vorarbeiten besteht das Risiko, dass neue Seiten schlechter indexiert oder im Snippet-Kontext schwächer interpretiert werden.
## Nachfragebild
### Brutto aus CSV
- Rohsumme: ca. `350.220` bis `3.502.200` Suchen / Monat
### Realistisch dedupliziert
Nach Clusterung ähnlicher Intentionen und konservativem Überlappungsabschlag:
- realistisch: ca. `178.052` bis `1.780.520` Suchen / Monat
- Planungs-Midpoint: ca. `979.286` / Monat
### Interpretation
- Das ist kein Traffic-Forecast.
- Das ist ein adressierbares Suchinteresse aus der vorhandenen Liste.
- Der größte Hebel liegt klar in `plant identifier app`.
- Der zweitgrößte Hebel liegt in diagnosis/symptom content und German app intent.
## Priorisierte Seitenarchitektur
### Wave 1: Highest ROI
#### 1. `/plant-identifier-app`
- Primärkeyword: `plant identifier app`
- Unterstützende Keywords:
- `plant identifier`
- `identify plants by photo`
- `identify plant from picture`
- `plant recognition app`
- `plant id app`
- `free plant identifier app`
- `app to identify plants`
- `ai plant identifier`
- Realistisches Seitenpotenzial: `140.842` bis `1.408.420`
- Planungs-Midpoint: `774.631`
Ziel:
- Haupt-SEO-Landingpage für die Kategorie
- AI-citable Definition und evaluation page
Title:
- `Plant Identifier App for Fast Diagnosis and Care | GreenLens`
Meta Description:
- `GreenLens is a plant identifier app that helps you identify plants by photo, diagnose common plant problems, and get the next best care step in one app.`
H1:
- `Plant Identifier App That Goes Beyond Naming the Plant`
Core outline:
- What is a plant identifier app?
- How GreenLens identifies plants by photo
- Why plant identification alone is not enough
- GreenLens vs generic plant ID apps
- FAQ
AI-SEO answer blocks:
- 40-60 word definition block directly under H1
- short “how it works” numbered list
- table: `GreenLens vs generic plant identifier apps`
- 3-5 symptom-based mini use cases
- FAQ with natural-language questions
Schema:
- `SoftwareApplication`
- `FAQPage`
- optional `HowTo`
Internal links:
- link to `/plant-disease-identifier`
- link to `/plant-care-app`
- link to `/vs/inaturalist`
- link to App Store CTA
Notes:
- This page should be the internal-link hub for the whole organic cluster.
#### 2. `/plant-disease-identifier`
- Primärkeyword: `plant disease identifier`
- Unterstützende Keywords:
- `plant health checker`
- `sick plant diagnosis`
- `plant disease app`
- `plant problem diagnosis`
- `plant health app`
- `pest identification`
- `plant diagnosis app`
- Realistisches Seitenpotenzial: `1.900` bis `19.000`
- Planungs-Midpoint: `10.450`
Ziel:
- Category page for plant diagnosis and symptom-led queries
- Strong AI-overview target because the query is informational and evaluative
Title:
- `Plant Disease Identifier for Houseplant Problems | GreenLens`
Meta Description:
- `Use GreenLens as a plant disease identifier to check common plant problems, understand symptoms, and decide on the next safe care step.`
H1:
- `Plant Disease Identifier for Real-World Plant Problems`
Core outline:
- What a plant disease identifier can and cannot do
- Common symptoms GreenLens helps interpret
- How to avoid wrong next steps
- When a symptom is likely not a disease
- FAQ
AI-SEO answer blocks:
- “What is a plant disease identifier?” answer block
- symptom matrix:
- yellow leaves
- brown leaves
- soft stems
- pest signs
- “most likely cause vs safest next step” table
- FAQ framed around beginner decisions
Schema:
- `FAQPage`
- `HowTo` for diagnosis workflow
Internal links:
- `/plant-identifier-app`
- future `/plant-leaves-turning-yellow`
- future `/brown-leaves-on-houseplants`
#### 3. `/plant-care-app`
- Primärkeyword: `plant care app`
- Unterstützende Keywords:
- `plant care`
- `plant watering reminder`
- `plant watering app`
- `plant care reminder app`
- `houseplant care app`
- `indoor plant care app`
- Realistisches Seitenpotenzial: `1.254` bis `12.540`
- Planungs-Midpoint: `6.897`
Ziel:
- Category page for ongoing care and reminder intent
- Commercial-intent support page that complements diagnosis pages
Title:
- `Plant Care App for Reminders, Routines, and Recovery | GreenLens`
Meta Description:
- `GreenLens is a plant care app for reminders, care routines, plant tracking, and symptom-based next steps when your plant starts to struggle.`
H1:
- `Plant Care App for Better Routines and Better Decisions`
Core outline:
- Why most care apps stop at reminders
- What GreenLens tracks
- Reminder logic vs real plant context
- Care routines for indoor plant owners
- FAQ
AI-SEO answer blocks:
- direct answer: what a plant care app helps with
- feature table: reminders, collection, scan, diagnosis, care notes
- short “when reminders help vs when they hurt” section
Schema:
- `SoftwareApplication`
- `FAQPage`
Internal links:
- `/plant-identifier-app`
- `/plant-disease-identifier`
- future `/plant-tracker-app`
#### 4. `/pflanzen-erkennen-app`
- Primärkeyword: `pflanzen erkennen app`
- Unterstützende Keywords:
- `pflanzenerkennung app`
- `pflanzen bestimmen app`
- `pflanzen app`
- `pflanzen scanner app`
- `pflanzen identifizieren app`
- Realistisches Seitenpotenzial: `1.640` bis `16.400`
- Planungs-Midpoint: `9.020`
Ziel:
- Separate German landing page for German app-intent queries
- Also strong AI-citation candidate for German-language questions
Title:
- `Pflanzen Erkennen App mit Diagnose und Pflegehilfe | GreenLens`
Meta Description:
- `GreenLens ist eine Pflanzen-Erkennen-App, mit der du Pflanzen per Foto bestimmen, Probleme einordnen und die nächsten Pflegeschritte klarer ableiten kannst.`
H1:
- `Pflanzen Erkennen App fuer Fotoerkennung und Pflanzenhilfe`
Core outline:
- Was ist eine Pflanzen-Erkennen-App?
- Pflanzen per Foto bestimmen
- Warum Bestimmung allein nicht reicht
- GreenLens fuer Diagnose und naechste Schritte
- FAQ
AI-SEO answer blocks:
- direkte Antwort auf Deutsch unter dem H1
- “So funktioniert es” als nummerierte Liste
- Vergleichstabelle: `GreenLens vs klassische Pflanzen-Apps`
- FAQ in natuerlicher deutscher Fragesprache
Schema:
- `SoftwareApplication`
- `FAQPage`
Internal links:
- `/plant-identifier-app`
- optional future `/zimmerpflanzen`
- support/legal pages
Notes:
- Diese Seite sollte nicht nur die Homepage uebersetzen, sondern German intent wirklich bedienen.
#### 5. `/vs/inaturalist`
- Primärkeyword: `inaturalist`
- Unterstützende Keywords:
- category fit: alternative / evaluation intent
- Realistisches Seitenpotenzial: `10.000` bis `100.000`
- Planungs-Midpoint: `55.000`
Ziel:
- Comparison page with high citation likelihood in AI answers
- Complements existing `/vs/picturethis` and `/vs/plantum`
Title:
- `GreenLens vs iNaturalist for Plant Identification and Diagnosis`
Meta Description:
- `Compare GreenLens vs iNaturalist for plant identification, plant diagnosis, next-step care guidance, and beginner-friendly decision support.`
H1:
- `GreenLens vs iNaturalist`
Core outline:
- who each product is for
- biodiversity/community app vs plant triage workflow
- identification depth vs next-step diagnosis
- beginner clarity vs expert observation workflow
- FAQ
AI-SEO answer blocks:
- fair comparison summary in first 60 words
- structured comparison table
- “choose GreenLens if / choose iNaturalist if” bullets
- explicit caveat on where iNaturalist is stronger
Schema:
- `FAQPage`
- optional `ItemList`-style structured comparison
Internal links:
- `/plant-identifier-app`
- `/plant-disease-identifier`
- existing comparison pages
### Wave 2: Strong Follow-Up Pages
Diese Seiten haben gute Ergänzungsfunktion oder Long-Tail-/AI-SEO-Wert:
1. `/best-plant-identification-app`
2. `/plant-leaves-turning-yellow`
3. `/brown-leaves-on-houseplants`
4. `/zimmerpflanzen`
5. `/identificador-de-plantas`
6. `/plant-tracker-app`
## Cannibalization Rules
Damit die Seiten sich nicht gegenseitig schwächen:
- `/plant-identifier-app`
- category page
- broad commercial + informational intent
- `/plant-disease-identifier`
- diagnosis-specific category page
- `/plant-care-app`
- routine/reminder/tracking intent
- `/pflanzen-erkennen-app`
- German-language category page
- `/vs/*`
- comparison intent only
- symptom pages
- narrow problem-specific intent only
Regel:
- Jede Seite braucht ein klar eigenes Primärkeyword.
- Das Primärkeyword muss in `title`, `H1`, intro copy, slug und interner Verlinkung konsistent sein.
- Keine zweite Seite sollte dasselbe Keyword-Set als Primärziel bekommen.
## AI-SEO Content Pattern
Alle neuen Pages sollten dieselbe Grundstruktur für AI-Citation verwenden.
### Required above-the-fold structure
1. Direct answer paragraph
2. Clear H1 matching the query
3. 3-bullet summary of when GreenLens is useful
4. Primary CTA
### Required extractable blocks
1. Definition block
- 40-60 words
- answers the primary query directly
2. Comparison block
- table or side-by-side bullets
- especially important for category and alternative pages
3. Decision block
- “Choose GreenLens if...”
- “Not the best fit if...”
4. FAQ block
- 4-6 natural-language questions
- answers should stand alone without surrounding context
5. Freshness block
- visible “Last updated”
- visible review/update cadence
### Recommended AI-citation signals
- specific numbers where they are true and defensible
- product facts in plain language
- one-sentence summary paragraphs
- balanced tone on comparison pages
- author/reviewer attribution
- sources for third-party claims
## Metadata Rules
For all new pages:
- title length target: `50-60` chars where possible
- description target: `140-160` chars
- self-referencing canonical
- Open Graph aligned to title and description
- one H1 only
Template:
```txt
Title: [Primary Keyword] + [specific benefit] | GreenLens
Meta: Clear value proposition with keyword, no fluff, no repetition
H1: Match query closely, but read naturally
```
## Schema Plan
Minimum schema for rollout:
1. Global:
- `Organization`
- `SoftwareApplication`
2. Per page:
- category pages: `FAQPage`
- workflow pages: `HowTo`
- comparison pages: `FAQPage`
Optional later:
- `Review`
- `AggregateRating`
- `BreadcrumbList`
## Internal Linking Plan
### Homepage
Homepage should link prominently to:
- `/plant-identifier-app`
- `/plant-disease-identifier`
- `/plant-care-app`
- `/pflanzen-erkennen-app`
- `/vs/inaturalist`
### Category hub logic
- `/plant-identifier-app` links to all other money pages
- `/plant-disease-identifier` links to symptom pages
- `/plant-care-app` links to tracker/reminder pages
- `/pflanzen-erkennen-app` links to German support cluster
- `/vs/*` links back into category pages
### Anchor text examples
Use varied, natural anchors:
- `plant identifier app`
- `plant disease identifier`
- `plant care app`
- `Pflanzen erkennen App`
- `compare GreenLens and iNaturalist`
Do not overuse exact-match anchors sitewide.
## AI Visibility Monitoring Plan
Test these queries monthly in:
- Google AI Overviews
- ChatGPT search
- Perplexity
### Priority queries
1. `plant identifier app`
2. `identify plants by photo`
3. `plant disease identifier`
4. `plant care app`
5. `best plant identification app`
6. `GreenLens vs PictureThis`
7. `GreenLens vs Plantum`
8. `GreenLens vs iNaturalist`
9. `pflanzen erkennen app`
10. `plant leaves turning yellow`
### Tracking sheet fields
- query
- platform
- AI answer present
- GreenLens cited
- competitor cited
- source page cited
- sentiment / framing
## Off-Site AI-SEO Presence
AI visibility will not come only from GreenLens pages. Parallel actions:
1. Expand comparison page set for major apps in the category.
2. Build review-site presence where relevant.
3. Seek mentions in plant-care roundups and app lists.
4. Create at least one referenceable “best app” style page with a balanced tone.
5. Consider one explainer asset on YouTube for plant diagnosis workflows.
## 30/60/90 Rollout
### First 30 days
1. Fix canonicals, metadata inheritance, legal placeholders, encoding issues.
2. Build:
- `/plant-identifier-app`
- `/plant-disease-identifier`
- `/plant-care-app`
3. Add homepage internal links to these pages.
### Days 31-60
1. Build `/pflanzen-erkennen-app`
2. Build `/vs/inaturalist`
3. Add page-specific schema and update sitemap
4. Start AI visibility checks on top 10 queries
### Days 61-90
1. Build symptom pages:
- `/plant-leaves-turning-yellow`
- `/brown-leaves-on-houseplants`
2. Build `/best-plant-identification-app`
3. Build `/zimmerpflanzen`
4. Review internal links and refresh snippets based on early ranking/citation behavior
## Implementation Notes for This Repo
Recommended file pattern in `greenlns-landing/app`:
- `app/plant-identifier-app/page.tsx`
- `app/plant-disease-identifier/page.tsx`
- `app/plant-care-app/page.tsx`
- `app/pflanzen-erkennen-app/page.tsx`
- `app/vs/inaturalist/page.tsx`
Recommended shared components:
- reusable FAQ component
- reusable comparison table component
- reusable page hero component for category pages
- shared page-level metadata helper
## Recommended First Build Order
If only one wave is built now:
1. `/plant-identifier-app`
2. `/plant-disease-identifier`
3. `/plant-care-app`
4. `/pflanzen-erkennen-app`
5. `/vs/inaturalist`
Reason:
- highest combined SEO + AI-SEO leverage
- strongest match to existing product positioning
- cleanest internal-link structure
- fastest path to broad category coverage

View File

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

View File

@@ -1,22 +1,4 @@
services:
caddy:
image: caddy:2.8-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
environment:
SITE_DOMAIN: ${SITE_DOMAIN:-greenlenspro.com}
volumes:
- ./greenlns-landing/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
landing:
condition: service_started
api:
condition: service_healthy
landing:
build:
context: ./greenlns-landing
@@ -26,6 +8,8 @@ services:
NODE_ENV: production
PORT: 3000
NEXT_PUBLIC_SITE_URL: ${SITE_URL:-https://greenlenspro.com}
networks:
- greenlens_net
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 15s
@@ -65,6 +49,8 @@ services:
condition: service_healthy
minio:
condition: service_healthy
networks:
- greenlens_net
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 15s
@@ -80,6 +66,8 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- greenlens_net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
@@ -92,9 +80,11 @@ services:
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-greenlns-minio}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required}
command: server /data
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
- ./minio_data:/data # <-- NEU: Lokaler Ordner statt benanntes Volume!
networks:
- greenlens_net
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
@@ -102,7 +92,8 @@ services:
retries: 5
volumes:
caddy_config:
caddy_data:
minio_data:
postgres_data:
networks:
greenlens_net:
external: true

View File

@@ -28,6 +28,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
EXPOSE 3000
CMD ["node", "server.js"]

View File

@@ -1,8 +1,7 @@
# GreenLens Landing
Self-hosted Next.js landing page for GreenLens. The production stack in this directory runs:
Self-hosted Next.js landing page for GreenLens. The production stack in this directory runs behind an external reverse proxy and includes:
- `caddy` for TLS and reverse proxy
- `landing` for the Next.js standalone app
- `api` for the Express backend from `../server`
- `postgres` for persistent app data
@@ -23,6 +22,13 @@ From `greenlns-landing/docker-compose.yml`:
docker compose up --build -d
```
Published ports for the external reverse proxy:
- `3000` for `landing`
- `3003` for `api`
- `9000` for MinIO object storage
- `9001` for the MinIO console
Required environment variables:
- `SITE_DOMAIN`

View File

@@ -1208,14 +1208,291 @@ h3 {
margin-bottom: var(--s2);
}
.support-faq-item p {
color: var(--muted);
}
/* =============================================
RESPONSIVE
============================================= */
@media (max-width: 1024px) {
.support-faq-item p {
color: var(--muted);
}
/* =============================================
COMPARISON PAGES
============================================= */
.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 {
grid-template-columns: 1fr;
}
@@ -1266,13 +1543,29 @@ h3 {
display: none;
}
.footer-inner {
grid-template-columns: 1fr 1fr;
gap: var(--s6);
}
}
@media (max-width: 768px) {
.footer-inner {
grid-template-columns: 1fr 1fr;
gap: var(--s6);
}
.comparison-hero-grid,
.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 {
display: none;
}
@@ -1315,8 +1608,34 @@ h3 {
text-align: center;
}
.support-grid,
.support-faq-list {
grid-template-columns: 1fr;
}
}
.support-grid,
.support-faq-list {
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',
registryLabel: 'Register',
vatLabel: 'USt-ID',
note: 'Vor der Veroeffentlichung muessen alle rechtlichen Angaben mit den echten Firmendaten ersetzt werden.',
},
en: {
title: 'Imprint',
@@ -22,7 +21,6 @@ const CONTENT = {
contactLabel: 'Contact',
registryLabel: 'Registry',
vatLabel: 'VAT ID',
note: 'Replace all legal placeholders with your real company details before publishing the site.',
},
es: {
title: 'Aviso Legal',
@@ -32,7 +30,6 @@ const CONTENT = {
contactLabel: 'Contacto',
registryLabel: 'Registro',
vatLabel: 'IVA',
note: 'Sustituye todos los marcadores legales por tus datos reales antes de publicar el sitio.',
},
}
@@ -47,9 +44,9 @@ export default function ImprintPage() {
<p>
<strong>{c.companyLabel}:</strong> {siteConfig.company.legalName}
</p>
<p>
<strong>{c.addressLabel}:</strong> {siteConfig.company.addressLine1}
</p>
{siteConfig.company.addressLine1 ? (
<p><strong>{c.addressLabel}:</strong> {siteConfig.company.addressLine1}</p>
) : null}
{siteConfig.company.addressLine2 ? <p>{siteConfig.company.addressLine2}</p> : null}
<p>{siteConfig.company.country}</p>
<p>
@@ -58,13 +55,12 @@ export default function ImprintPage() {
<p>
<strong>{c.contactLabel}:</strong> <a href={`mailto:${siteConfig.legalEmail}`}>{siteConfig.legalEmail}</a>
</p>
<p>
<strong>{c.registryLabel}:</strong> {siteConfig.company.registry}
</p>
<p>
<strong>{c.vatLabel}:</strong> {siteConfig.company.vatId}
</p>
<p style={{ marginTop: '1rem', fontSize: '0.95rem', opacity: 0.8 }}>{c.note}</p>
{siteConfig.company.registry ? (
<p><strong>{c.registryLabel}:</strong> {siteConfig.company.registry}</p>
) : null}
{siteConfig.company.vatId ? (
<p><strong>{c.vatLabel}:</strong> {siteConfig.company.vatId}</p>
) : null}
</div>
</main>
)

View File

@@ -1,72 +1,96 @@
import type { Metadata } from 'next'
import './globals.css'
import { LangProvider } from '@/context/LangContext'
import { siteConfig } from '@/lib/site'
export const metadata: Metadata = {
metadataBase: new URL(siteConfig.domain),
title: {
default: 'GreenLens - Plant Identifier and Care Planner',
template: '%s | GreenLens',
},
description:
'GreenLens helps you identify plants, organize your collection, and keep up with care routines in one app.',
keywords: [
'plant identifier by picture',
'plant care app',
'watering reminders',
'houseplant tracker',
'plant identification',
'plant health check',
'Pflanzen App',
'GreenLens',
],
authors: [{ name: siteConfig.name }],
openGraph: {
title: 'GreenLens - Plant Identifier and Care Planner',
description: 'Identify plants, get care guidance, and manage your collection with GreenLens.',
type: 'website',
url: siteConfig.domain,
},
alternates: {
canonical: '/',
languages: {
de: '/',
en: '/',
es: '/',
'x-default': '/',
},
},
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<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" />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: siteConfig.name,
operatingSystem: 'iOS, Android',
applicationCategory: 'LifestyleApplication',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'EUR',
},
}),
}}
/>
</head>
<body>
<LangProvider>{children}</LangProvider>
</body>
</html>
)
}
import type { Metadata } from 'next'
import { cookies } from 'next/headers'
import './globals.css'
import { LangProvider } from '@/context/LangContext'
import { siteConfig, hasIosStoreUrl } from '@/lib/site'
export const metadata: Metadata = {
metadataBase: new URL(siteConfig.domain),
title: {
default: 'GreenLens - Plant Identifier and Care Planner',
template: '%s | GreenLens',
},
description:
'GreenLens helps you identify plants, organize your collection, and keep up with care routines in one app.',
keywords: [
'plant identifier by picture',
'plant care app',
'watering reminders',
'houseplant tracker',
'plant identification',
'plant health check',
'Pflanzen App',
'GreenLens',
],
authors: [{ name: siteConfig.name }],
openGraph: {
title: 'GreenLens - Plant Identifier and Care Planner',
description: 'Identify plants, get care guidance, and manage your collection with GreenLens.',
type: 'website',
url: siteConfig.domain,
},
twitter: {
card: 'summary_large_image',
title: 'GreenLens - Plant Identifier and Care Planner',
description: 'Identify plants, get care guidance, and manage your collection with GreenLens.',
},
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies()
const lang = (cookieStore.get('lang')?.value ?? 'de') as 'de' | 'en' | 'es'
const validLangs = ['de', 'en', 'es']
const htmlLang = validLangs.includes(lang) ? lang : 'de'
return (
<html lang={htmlLang}>
<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" />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify([
{
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: siteConfig.name,
operatingSystem: 'iOS, Android',
applicationCategory: 'LifestyleApplication',
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 Hero from '@/components/Hero'
import Ticker from '@/components/Ticker'
import Features from '@/components/Features'
import BrownLeaf from '@/components/BrownLeaf'
import Intelligence from '@/components/Intelligence'
import HowItWorks from '@/components/HowItWorks'
import FAQ from '@/components/FAQ'
import CTA from '@/components/CTA'
import Footer from '@/components/Footer'
export default function Home() {
return (
<>
<Navbar />
<main>
<Hero />
<Ticker />
<Features />
<BrownLeaf />
<Intelligence />
<HowItWorks />
<FAQ />
<CTA />
</main>
<Footer />
</>
)
}
import Navbar from '@/components/Navbar'
import Hero from '@/components/Hero'
import Ticker from '@/components/Ticker'
import Features from '@/components/Features'
import BrownLeaf from '@/components/BrownLeaf'
import Intelligence from '@/components/Intelligence'
import HowItWorks from '@/components/HowItWorks'
import FAQ from '@/components/FAQ'
import CTA from '@/components/CTA'
import Footer from '@/components/Footer'
const howToSchema = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: 'How to identify a plant with GreenLens',
step: [
{
'@type': 'HowToStep',
position: 1,
name: 'Photograph your plant',
text: 'Open the app, point the camera at your plant and tap Scan.',
},
{
'@type': 'HowToStep',
position: 2,
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

@@ -0,0 +1,33 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import SeoCategoryPage from '@/components/SeoCategoryPage'
import { getSeoPageBySlug } from '@/lib/seoPages'
import { siteConfig } from '@/lib/site'
const profile = getSeoPageBySlug('pflanzen-erkennen-app')
export const metadata: Metadata = !profile
? {}
: {
title: profile.metaTitle,
description: profile.metaDescription,
alternates: { canonical: profile.canonical },
openGraph: {
title: profile.metaTitle,
description: profile.metaDescription,
url: `${siteConfig.domain}${profile.canonical}`,
type: 'website',
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: profile.metaTitle }],
},
twitter: {
card: 'summary_large_image',
title: profile.metaTitle,
description: profile.metaDescription,
images: ['/og-image.png'],
},
}
export default function Page() {
if (!profile) notFound()
return <SeoCategoryPage profile={profile} />
}

View File

@@ -0,0 +1,33 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import SeoCategoryPage from '@/components/SeoCategoryPage'
import { getSeoPageBySlug } from '@/lib/seoPages'
import { siteConfig } from '@/lib/site'
const profile = getSeoPageBySlug('plant-care-app')
export const metadata: Metadata = !profile
? {}
: {
title: profile.metaTitle,
description: profile.metaDescription,
alternates: { canonical: profile.canonical },
openGraph: {
title: profile.metaTitle,
description: profile.metaDescription,
url: `${siteConfig.domain}${profile.canonical}`,
type: 'website',
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: profile.metaTitle }],
},
twitter: {
card: 'summary_large_image',
title: profile.metaTitle,
description: profile.metaDescription,
images: ['/og-image.png'],
},
}
export default function Page() {
if (!profile) notFound()
return <SeoCategoryPage profile={profile} />
}

View File

@@ -0,0 +1,33 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import SeoCategoryPage from '@/components/SeoCategoryPage'
import { getSeoPageBySlug } from '@/lib/seoPages'
import { siteConfig } from '@/lib/site'
const profile = getSeoPageBySlug('plant-disease-identifier')
export const metadata: Metadata = !profile
? {}
: {
title: profile.metaTitle,
description: profile.metaDescription,
alternates: { canonical: profile.canonical },
openGraph: {
title: profile.metaTitle,
description: profile.metaDescription,
url: `${siteConfig.domain}${profile.canonical}`,
type: 'website',
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: profile.metaTitle }],
},
twitter: {
card: 'summary_large_image',
title: profile.metaTitle,
description: profile.metaDescription,
images: ['/og-image.png'],
},
}
export default function Page() {
if (!profile) notFound()
return <SeoCategoryPage profile={profile} />
}

View File

@@ -0,0 +1,33 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import SeoCategoryPage from '@/components/SeoCategoryPage'
import { getSeoPageBySlug } from '@/lib/seoPages'
import { siteConfig } from '@/lib/site'
const profile = getSeoPageBySlug('plant-identifier-app')
export const metadata: Metadata = !profile
? {}
: {
title: profile.metaTitle,
description: profile.metaDescription,
alternates: { canonical: profile.canonical },
openGraph: {
title: profile.metaTitle,
description: profile.metaDescription,
url: `${siteConfig.domain}${profile.canonical}`,
type: 'website',
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: profile.metaTitle }],
},
twitter: {
card: 'summary_large_image',
title: profile.metaTitle,
description: profile.metaDescription,
images: ['/og-image.png'],
},
}
export default function Page() {
if (!profile) notFound()
return <SeoCategoryPage profile={profile} />
}

View File

@@ -5,26 +5,74 @@ export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 1,
},
{
url: `${baseUrl}/imprint`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.3,
},
url: baseUrl,
lastModified: new Date('2026-04-08'),
changeFrequency: 'weekly',
priority: 1,
},
{
url: `${baseUrl}/support`,
lastModified: new Date('2026-04-08'),
changeFrequency: 'monthly',
priority: 0.5,
},
{
url: `${baseUrl}/plant-identifier-app`,
lastModified: new Date('2026-04-12'),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: `${baseUrl}/plant-disease-identifier`,
lastModified: new Date('2026-04-12'),
changeFrequency: 'monthly',
priority: 0.75,
},
{
url: `${baseUrl}/plant-care-app`,
lastModified: new Date('2026-04-12'),
changeFrequency: 'monthly',
priority: 0.75,
},
{
url: `${baseUrl}/pflanzen-erkennen-app`,
lastModified: new Date('2026-04-12'),
changeFrequency: 'monthly',
priority: 0.75,
},
{
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}/vs/inaturalist`,
lastModified: new Date('2026-04-12'),
changeFrequency: 'monthly',
priority: 0.65,
},
{
url: `${baseUrl}/imprint`,
lastModified: new Date('2026-04-08'),
changeFrequency: 'monthly',
priority: 0.3,
},
{
url: `${baseUrl}/privacy`,
lastModified: new Date(),
lastModified: new Date('2026-04-08'),
changeFrequency: 'monthly',
priority: 0.3,
},
{
url: `${baseUrl}/terms`,
lastModified: new Date(),
lastModified: new Date('2026-04-08'),
changeFrequency: 'monthly',
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: {
en: 'Can I use it offline?',
de: 'Kann ich die App offline nutzen?',
es: 'Puedo usarla sin conexion?'
en: 'Can I use GreenLens offline?',
de: 'Kann ich GreenLens offline nutzen?',
es: 'Puedo usar GreenLens sin conexion?'
},
answer: {
en: 'Some experiences may require a connection, especially for scan-related features. Saved information inside the app can remain available afterward.',
de: 'Einige Funktionen benoetigen eine Verbindung, besonders scanbezogene Features. Gespeicherte Informationen in der App koennen danach weiter verfuegbar bleiben.',
es: 'Algunas funciones requieren conexion, especialmente las relacionadas con escaneos. La informacion guardada puede seguir disponible despues.'
en: 'Plant identification and health checks require an internet connection. Your saved collection, care notes, and watering reminders are available offline.',
de: 'Pflanzenidentifikation und Gesundheitscheck benoetigen eine Internetverbindung. Deine gespeicherte Sammlung, Pflegenotizen und Giess-Erinnerungen sind offline verfuegbar.',
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: {
en: 'What kind of plants can I use it for?',
de: 'Fuer welche Pflanzen kann ich die App nutzen?',
es: 'Para que tipo de plantas puedo usar la app?'
en: 'What kind of plants can I use GreenLens for?',
de: 'Fuer welche Pflanzen kann ich GreenLens nutzen?',
es: 'Para que tipo de plantas puedo usar GreenLens?'
},
answer: {
en: 'GreenLens is built for everyday plant owners who want help with houseplants, garden plants, and general care questions.',
de: 'GreenLens richtet sich an Pflanzenbesitzer, die Hilfe bei Zimmerpflanzen, Gartenpflanzen und allgemeinen Pflegefragen wollen.',
es: 'GreenLens esta pensada para personas que quieren ayuda con plantas de interior, jardin y preguntas generales de cuidado.'
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 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 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

@@ -32,6 +32,17 @@ export default function Footer() {
{label}
</Link>
))}
{ci === 1 && (
<>
<Link href="/plant-identifier-app">Plant Identifier App</Link>
<Link href="/plant-disease-identifier">Plant Disease Identifier</Link>
<Link href="/plant-care-app">Plant Care App</Link>
<Link href="/pflanzen-erkennen-app">Pflanzen erkennen</Link>
<Link href="/vs/picturethis">GreenLens vs PictureThis</Link>
<Link href="/vs/plantum">GreenLens vs Plantum</Link>
<Link href="/vs/inaturalist">GreenLens vs iNaturalist</Link>
</>
)}
</div>
))}
</div>

View File

@@ -109,7 +109,7 @@ export default function Hero() {
<div className="hero-visual reveal-fade delay-2">
<div className="hero-video-card hero-video-16-9">
<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>
<div className="hero-video-card-overlay" />
<div className="hero-video-badge">

View File

@@ -0,0 +1,177 @@
import Link from 'next/link'
import Navbar from '@/components/Navbar'
import CTA from '@/components/CTA'
import Footer from '@/components/Footer'
import type { SeoPageProfile } from '@/lib/seoPages'
import { siteConfig, hasIosStoreUrl } from '@/lib/site'
interface SeoCategoryPageProps {
profile: SeoPageProfile
}
export default function SeoCategoryPage({ profile }: SeoCategoryPageProps) {
const faqSchema = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: profile.faqs.map((item) => ({
'@type': 'Question',
name: item.question,
acceptedAnswer: {
'@type': 'Answer',
text: item.answer,
},
})),
}
const appSchema = profile.includeAppSchema
? {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: siteConfig.name,
operatingSystem: 'iOS, Android',
applicationCategory: 'LifestyleApplication',
description: profile.directAnswer,
...(hasIosStoreUrl && { downloadUrl: siteConfig.iosAppStoreUrl }),
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'EUR',
},
}
: null
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
/>
{appSchema && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(appSchema) }}
/>
)}
<Navbar />
<main className="comparison-page">
{/* Hero */}
<section className="comparison-hero">
<div className="container comparison-hero-grid">
<div className="comparison-hero-copy">
<p className="tag">GreenLens</p>
<h1>{profile.h1}</h1>
<p className="comparison-lead">{profile.tagline}</p>
<p>{profile.directAnswer}</p>
<div className="comparison-actions">
<a href="#cta" className="btn-primary">Try GreenLens</a>
<a href="#feature-table" className="btn-outline">See full comparison</a>
</div>
</div>
<aside className="comparison-hero-card">
<p className="comparison-card-label">Definition</p>
<p>{profile.definitionBlock}</p>
<p className="comparison-verified">Last updated: {profile.lastUpdated}</p>
</aside>
</div>
</section>
{/* Feature table */}
<section className="comparison-table-section" id="feature-table">
<div className="container">
<div className="comparison-section-head">
<p className="tag">At a glance</p>
<h2>{profile.featureTable.title}</h2>
</div>
<div className="comparison-table">
<div className="comparison-table-header">
<span>Feature</span>
<span>GreenLens</span>
<span>{profile.featureTable.alternativeLabel}</span>
</div>
{profile.featureTable.rows.map((row) => (
<article key={row.feature} className="comparison-row">
<div className="comparison-row-title">{row.feature}</div>
<div className="comparison-cell comparison-cell--greenlens">{row.greenlens}</div>
<div className="comparison-cell comparison-cell--competitor">{row.alternative}</div>
</article>
))}
</div>
</div>
</section>
{/* Fit cards */}
<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:</h2>
<ul className="comparison-bullet-list comparison-bullet-list--dark">
{profile.greenLensIf.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</article>
<article className="comparison-fit-card">
<p className="tag">Not the best fit</p>
<h2>GreenLens is not the right tool if:</h2>
<ul className="comparison-bullet-list comparison-bullet-list--dark">
{profile.notBestIf.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</article>
</div>
</section>
{/* FAQ */}
<section className="comparison-faq">
<div className="container">
<div className="comparison-section-head">
<p className="tag">FAQ</p>
<h2>Common questions answered directly.</h2>
</div>
<div className="comparison-faq-grid">
{profile.faqs.map((item) => (
<article key={item.question} className="comparison-faq-card">
<h3>{item.question}</h3>
<p>{item.answer}</p>
</article>
))}
</div>
</div>
</section>
{/* Related links */}
{profile.relatedLinks.length > 0 && (
<section className="comparison-links">
<div className="container comparison-links-grid">
{profile.relatedLinks.map((link) => (
<Link key={link.href} href={link.href} className="comparison-link-card">
<p className="comparison-mini-label">Related</p>
<h3>{link.label}</h3>
<p>{link.description}</p>
</Link>
))}
<Link href="/support" className="comparison-link-card comparison-link-card--support">
<p className="comparison-mini-label">Need help?</p>
<h3>Talk to GreenLens support</h3>
<p>
Questions about scans, care plans, billing, or features? Use the support page.
</p>
</Link>
</div>
</section>
)}
<CTA />
</main>
<Footer />
</>
)
}

View File

@@ -1,27 +1,46 @@
'use client'
import { createContext, useContext, useState, ReactNode } from 'react'
import { Lang, translations } from '@/lib/i18n'
interface LangCtx {
lang: Lang
setLang: (l: Lang) => void
t: typeof translations.de
}
const LangContext = createContext<LangCtx>({
lang: 'de',
setLang: () => {},
t: translations.de,
})
export function LangProvider({ children }: { children: ReactNode }) {
const [lang, setLang] = useState<Lang>('de')
return (
<LangContext.Provider value={{ lang, setLang, t: translations[lang] }}>
{children}
</LangContext.Provider>
)
}
export const useLang = () => useContext(LangContext)
'use client'
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import { Lang, translations } from '@/lib/i18n'
interface LangCtx {
lang: Lang
setLang: (l: Lang) => void
t: typeof translations.de
}
const LangContext = createContext<LangCtx>({
lang: 'de',
setLang: () => {},
t: translations.de,
})
function getInitialLang(): Lang {
if (typeof document === 'undefined') return 'de'
const match = document.cookie.match(/(?:^|;\s*)lang=([^;]+)/)
const val = match?.[1]
return val === 'en' || val === 'es' || val === 'de' ? val : 'de'
}
export function LangProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Lang>('de')
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

@@ -1,27 +1,11 @@
services:
caddy:
image: caddy:2.8-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
environment:
SITE_DOMAIN: ${SITE_DOMAIN:-greenlenspro.com}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
landing:
condition: service_started
api:
condition: service_healthy
landing:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "3000:3000"
environment:
NODE_ENV: production
PORT: 3000
@@ -37,6 +21,8 @@ services:
context: ../server
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "3003:3000"
environment:
NODE_ENV: production
PORT: 3000
@@ -55,7 +41,8 @@ services:
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-https://greenlenspro.com/storage}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
OPENAI_SCAN_MODEL: ${OPENAI_SCAN_MODEL:-gpt-5-mini}
OPENAI_HEALTH_MODEL: ${OPENAI_HEALTH_MODEL:-gpt-5-mini}
OPENAI_SCAN_MODEL_PRO: ${OPENAI_SCAN_MODEL_PRO:-gpt-5.4}
OPENAI_HEALTH_MODEL: ${OPENAI_HEALTH_MODEL:-gpt-4o-mini}
REVENUECAT_WEBHOOK_SECRET: ${REVENUECAT_WEBHOOK_SECRET:-}
REVENUECAT_PRO_ENTITLEMENT_ID: ${REVENUECAT_PRO_ENTITLEMENT_ID:-pro}
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}
@@ -89,10 +76,13 @@ services:
minio:
image: minio/minio:latest
restart: unless-stopped
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-greenlns-minio}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required}
command: server /data
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
healthcheck:
@@ -102,7 +92,5 @@ services:
retries: 5
volumes:
caddy_config:
caddy_data:
postgres_data:
minio_data:

View File

@@ -0,0 +1,544 @@
export type CompetitorSlug = 'picturethis' | 'plantum' | 'inaturalist'
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.',
},
],
},
inaturalist: {
slug: 'inaturalist',
name: 'iNaturalist',
metaTitle: 'GreenLens vs iNaturalist',
metaDescription:
'Compare GreenLens vs iNaturalist for plant identification, care guidance, and disease triage. See which app fits your situation — owned plant care or biodiversity discovery.',
heroSummary:
'iNaturalist is one of the most respected citizen science platforms in the world. GreenLens is built for a different job: helping you decide what to do next when a plant you own is struggling. These two tools solve different problems, and the right choice depends entirely on what you are trying to accomplish.',
heroVerdict: [
'Choose GreenLens if you own the plant and need the next actionable step — care guidance, symptom triage, or rescue decisions.',
'Choose iNaturalist if your goal is species discovery, community identification, or contributing to biodiversity research.',
'The comparison only becomes interesting when someone wants both. For plant owners in triage mode, GreenLens is the faster path.',
],
disclaimer:
'iNaturalist is a non-profit platform. This comparison is based on public features and user-reported use cases. GreenLens and iNaturalist serve different primary audiences.',
lastVerified: 'April 2026',
competitorSnapshot:
'iNaturalist is a global biodiversity mapping platform backed by the California Academy of Sciences and National Geographic. It is primarily a citizen science tool: users upload observations, receive community-sourced identifications, and contribute to scientific datasets. Its plant identification is broad and accurate, but the platform is not designed around plant ownership, care routines, or emergency triage.',
greenLensPositioning:
'GreenLens is an owned-plant companion. It assumes you are already responsible for the plant and need help deciding what to do next — whether that is diagnosis, care scheduling, or understanding why a leaf is turning yellow.',
whyPeopleCompare: [
'They found iNaturalist useful for ID but now need care guidance the platform does not provide.',
'They want a free identification option and are evaluating whether iNaturalist is enough.',
'They are confused by whether a citizen science app can replace a dedicated plant care app.',
],
theses: [
{
title: 'Community ID vs owned-plant triage',
greenlens:
'GreenLens assumes you own the plant and need a next step. Diagnosis, care context, and actionable decisions are built into the core workflow.',
competitor:
'iNaturalist crowdsources identifications from a global community. The platform is optimized for observation accuracy, not for what you should do once you know the species.',
},
{
title: 'App-first speed vs community dependence',
greenlens:
'GreenLens returns AI-driven results instantly, without waiting for community votes or reviews.',
competitor:
'iNaturalist offers instant AI suggestions at upload, but expert community confirmation — the step that makes an observation Research Grade — can take hours or days. That process works well for research; it is slow for a plant that looks wrong right now.',
},
{
title: 'Care integration vs observation logging',
greenlens:
'GreenLens connects identification to care plans, watering reminders, and health checks in one flow.',
competitor:
'iNaturalist focuses on observation logging and scientific accuracy. There is no care guidance, no reminder system, and no disease triage built in.',
},
],
categories: [
{
title: 'Plant identification',
greenlens:
'AI-powered scan results in seconds. Accurate enough for the 450+ common species most plant owners encounter.',
competitor:
'Strong and often highly accurate, especially for unusual or rare species. Community input adds credibility over time.',
whyItMatters:
'If you need ID for a common houseplant right now, both work. For rare or regionally specific species, iNaturalist has a deeper expert pool.',
},
{
title: 'Care guidance after identification',
greenlens:
'Automatic care plan, watering schedule, and contextual next-step recommendations after every scan.',
competitor:
'No care guidance. iNaturalist tells you what the plant is, not what to do with it.',
whyItMatters:
'For plant owners, the ID is only step one. Everything that follows is missing from iNaturalist.',
},
{
title: 'Disease and symptom triage',
greenlens:
'Health check feature analyzes symptoms and narrows down the most likely cause with a clear next action.',
competitor:
'No disease or symptom support. iNaturalist is not designed for diagnostic or rescue workflows.',
whyItMatters:
'If your plant looks sick, iNaturalist gives you a name. GreenLens gives you something to do about it.',
},
{
title: 'Collection and reminders',
greenlens:
'Save plants to your collection, track care history, and receive context-aware watering and fertilizing reminders.',
competitor:
'Observation history exists for logging, but there is no personal collection management or care reminder system.',
whyItMatters:
'Ongoing care requires memory. GreenLens maintains that context; iNaturalist does not.',
},
{
title: 'Citizen science and community',
greenlens:
'No community science layer. GreenLens is a private, individual tool for plant owners.',
competitor:
'This is the core iNaturalist strength. Millions of observations, expert community, and real scientific impact.',
whyItMatters:
'If contributing to biodiversity data or reaching expert naturalists matters to you, iNaturalist is the clear choice.',
},
{
title: 'Offline and rare species depth',
greenlens:
'Identification and health checks require connectivity. Saved collection and care notes are available offline.',
competitor:
'Community data is vast and includes rare, regional, and unusual species that mainstream apps often miss.',
whyItMatters:
'For unusual plants or fieldwork in low-connectivity areas, iNaturalist has meaningful advantages.',
},
],
greenLensBestFor: [
'Plant owners who need care guidance, not just a species name.',
'Anyone dealing with a struggling plant and looking for a concrete next step.',
'People who want reminders, care history, and health checks in one app.',
],
competitorBestFor: [
'Nature enthusiasts who want to document and share biodiversity observations.',
'Users who need rare or unusual species identified by a global expert community.',
'Anyone contributing to citizen science or academic research projects.',
],
emergencyScenarios: [
{
symptom: 'Yellowing leaves on a houseplant',
greenlens:
'GreenLens prompts a health check, asks about recent changes, and surfaces the most likely cause with a recommended next action.',
competitor:
'iNaturalist can confirm the species, but there is no diagnostic flow. You would need to search for care information elsewhere.',
},
{
symptom: 'Unknown plant in the garden — what is it?',
greenlens:
'GreenLens identifies it and immediately adds it to your collection with care guidance.',
competitor:
'iNaturalist is excellent here: fast community confirmation, high accuracy, and a permanent observation record.',
},
{
symptom: 'Soft stems after repotting',
greenlens:
'GreenLens connects the symptom to a likely overwatering or root disturbance scenario and recommends the safest next step.',
competitor:
'Not designed for this use case. iNaturalist has no triage or post-repotting recovery guidance.',
},
],
faqs: [
{
question: 'Is iNaturalist free compared to GreenLens?',
answer:
'iNaturalist is fully free and non-profit. GreenLens includes free functionality with optional paid features for advanced AI scans and unlimited health checks. If cost is the only factor, iNaturalist wins. If you need care guidance alongside identification, GreenLens is the more complete tool.',
},
{
question: 'Can iNaturalist identify plant diseases?',
answer:
'iNaturalist can identify what the plant is, and community members may occasionally comment on visible symptoms. But it has no built-in disease diagnosis, health check workflow, or triage guidance. GreenLens is built specifically for that use case.',
},
{
question: 'Why would someone use GreenLens instead of a free app like iNaturalist?',
answer:
'iNaturalist solves the identification problem well. GreenLens solves what comes after: care scheduling, symptom analysis, collection tracking, and rescue decisions. They are complementary tools for different moments.',
},
{
question: 'Is iNaturalist accurate enough to replace a paid plant app?',
answer:
'For identification alone, iNaturalist is often accurate and sometimes more detailed than paid apps. The gap opens up when you need ongoing care, reminders, or help diagnosing a problem. iNaturalist does not address those needs at all.',
},
],
},
}
export const competitorOrder: CompetitorSlug[] = ['picturethis', 'plantum', 'inaturalist']
export function getCompetitorBySlug(slug: string): CompetitorProfile | undefined {
if (slug === 'picturethis' || slug === 'plantum' || slug === 'inaturalist') {
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',
h2a: 'Alles, was dein',
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: {
tag: 'Download',
@@ -107,7 +107,7 @@ export const translations = {
tag: 'Features',
h2a: 'Everything your',
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: {
tag: 'Download',
@@ -173,7 +173,7 @@ export const translations = {
tag: 'Funciones',
h2a: 'Todo lo que tu',
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: {
tag: 'Descarga',

View File

@@ -0,0 +1,439 @@
export interface SeoFeatureRow {
feature: string
greenlens: string
alternative: string
}
export interface SeoFaq {
question: string
answer: string
}
export interface SeoRelatedLink {
href: string
label: string
description: string
}
export interface SeoPageProfile {
slug: string
metaTitle: string
metaDescription: string
canonical: string
h1: string
tagline: string
directAnswer: string
definitionBlock: string
lastUpdated: string
includeAppSchema: boolean
featureTable: {
title: string
alternativeLabel: string
rows: SeoFeatureRow[]
}
greenLensIf: string[]
notBestIf: string[]
faqs: SeoFaq[]
relatedLinks: SeoRelatedLink[]
}
const seoPageProfiles: Record<string, SeoPageProfile> = {
'plant-identifier-app': {
slug: 'plant-identifier-app',
metaTitle: 'Plant Identifier App — GreenLens',
metaDescription:
'GreenLens is a plant identifier app that goes beyond the name. Scan any plant, get the species instantly, and move straight to care guidance, health checks, and rescue decisions.',
canonical: '/plant-identifier-app',
h1: 'Plant Identifier App',
tagline: 'Identify any plant in seconds — then know exactly what to do next.',
directAnswer:
'GreenLens is a plant identifier app for iOS and Android. Point your camera at any plant, tap Scan, and receive the species name, care requirements, and next-step guidance in under a second.',
definitionBlock:
'A plant identifier app uses your phone camera and AI to match a photo against a plant database and return the species name, common names, and care profile. GreenLens extends this with health diagnostics and care scheduling so identification leads directly to action.',
lastUpdated: 'April 2026',
includeAppSchema: true,
featureTable: {
title: 'What separates GreenLens from a basic plant ID app',
alternativeLabel: 'Basic plant ID apps',
rows: [
{
feature: 'Instant plant identification',
greenlens: 'AI scan returns species name, common names, and plant profile in under a second.',
alternative: 'Most apps return a species name and stop there.',
},
{
feature: 'Care guidance after ID',
greenlens: 'Automatic care plan, watering schedule, and light requirements attached to every scan.',
alternative: 'Usually absent or linked to a generic external reference.',
},
{
feature: 'Health check and diagnosis',
greenlens: 'Dedicated health scan analyzes visible symptoms and recommends the safest next action.',
alternative: 'Rarely included. Most ID apps do not address plant emergencies.',
},
{
feature: 'Plant collection',
greenlens: 'Save scanned plants to a personal collection with notes, photos, and care history.',
alternative: 'Scan history only, without ongoing care context.',
},
{
feature: 'Reminders',
greenlens: 'Context-aware care reminders that adapt to your plant and environment.',
alternative: 'Generic calendar reminders not tied to plant condition.',
},
],
},
greenLensIf: [
'You want to know the plant name and immediately understand how to care for it.',
'You are dealing with a plant that looks wrong and need a next step beyond the ID.',
'You want one app for identification, health checks, and ongoing care reminders.',
],
notBestIf: [
'Your main goal is cataloguing rare species for biodiversity research — iNaturalist has a deeper expert community for that.',
'You only need occasional identification and have no interest in ongoing care tracking.',
],
faqs: [
{
question: 'How accurate is GreenLens for plant identification?',
answer:
'GreenLens accurately identifies 450+ plant species including the most common houseplants, garden plants, and succulents. For rare or highly regional species, community platforms such as iNaturalist may have a broader expert pool. For everyday owned plants, GreenLens is fast and reliable.',
},
{
question: 'Does GreenLens work offline?',
answer:
'Scanning and health checks require an internet connection. Your saved plant collection, care notes, and watering reminders are accessible offline.',
},
{
question: 'Is GreenLens free to use as a plant identifier?',
answer:
'GreenLens includes free plant identification. Advanced AI health checks and unlimited scans are available through paid credits or a subscription.',
},
{
question: 'What is the difference between a plant identifier app and a plant care app?',
answer:
'A plant identifier app tells you what the plant is. A plant care app helps you keep it alive. GreenLens is both: it identifies the plant and then provides the care plan, health diagnostics, and reminders you need to act on that information.',
},
{
question: 'Can GreenLens identify plants from photos taken earlier?',
answer:
'Yes. You can upload a photo from your gallery in addition to taking a new scan. The AI analysis works on any clear image of the plant.',
},
],
relatedLinks: [
{
href: '/plant-disease-identifier',
label: 'Plant Disease Identifier',
description: 'Diagnose symptoms and get a concrete next action when your plant looks wrong.',
},
{
href: '/plant-care-app',
label: 'Plant Care App',
description: 'Reminders, care history, and context-aware guidance for every plant you own.',
},
{
href: '/pflanzen-erkennen-app',
label: 'Pflanzen erkennen App',
description: 'Pflanzenerkennung per Foto — direkt mit Pflegeplan und Diagnose.',
},
{
href: '/vs/inaturalist',
label: 'GreenLens vs iNaturalist',
description: 'Compare GreenLens and iNaturalist for plant ID, care, and emergency triage.',
},
],
},
'plant-disease-identifier': {
slug: 'plant-disease-identifier',
metaTitle: 'Plant Disease Identifier — GreenLens',
metaDescription:
'Use GreenLens to identify plant diseases from visible symptoms. Get a concrete next action — not a list of possibilities — when your plant shows yellow leaves, soft stems, or sudden decline.',
canonical: '/plant-disease-identifier',
h1: 'Plant Disease Identifier',
tagline: 'Describe the symptom. Get the most likely cause and a clear next step.',
directAnswer:
'GreenLens identifies plant diseases by analyzing visible symptoms — yellow leaves, brown tips, soft stems, spots, or wilting — and returns the most probable cause with a specific next action, not a list of generic possibilities.',
definitionBlock:
'A plant disease identifier analyzes the visual signs a plant shows — discoloration, texture changes, leaf drop, stem softness — and matches them to known causes such as overwatering, root rot, fungal infection, or nutrient deficiency. GreenLens focuses on the next actionable step rather than an exhaustive diagnosis report.',
lastUpdated: 'April 2026',
includeAppSchema: false,
featureTable: {
title: 'What GreenLens can and cannot do for plant disease',
alternativeLabel: 'Typical disease apps',
rows: [
{
feature: 'Symptom-based analysis',
greenlens: 'Health check scan analyzes the visible symptom pattern and surfaces the most likely cause.',
alternative: 'Often returns a broad list of possible diseases without prioritization.',
},
{
feature: 'Next-step recommendation',
greenlens: 'Recommends one clear action: check the soil, stop fertilizing, isolate the plant, or adjust watering.',
alternative: 'Generic advice such as "improve drainage" or "reduce humidity" without sequencing.',
},
{
feature: 'Context from recent care history',
greenlens: 'Connects symptoms to recent events like repotting, environment changes, or watering frequency.',
alternative: 'Analyzes the photo in isolation without accounting for recent changes.',
},
{
feature: 'Lab-level diagnosis',
greenlens: 'Not designed for professional pathology or agricultural-scale disease tracking.',
alternative: 'Specialized agronomic tools cover industrial and laboratory-grade diagnosis.',
},
{
feature: 'Overcare prevention',
greenlens: 'Specifically designed to stop the most common mistake: adding more care to an already stressed plant.',
alternative: 'Most apps give more tasks, not fewer.',
},
],
},
greenLensIf: [
'Your plant has visible symptoms and you need to know the safest next move.',
'You want to avoid making a stressed plant worse by applying the wrong fix.',
'You have already identified the plant and now need help with the health issue.',
],
notBestIf: [
'You need a laboratory-verified pathology report for commercial or academic use.',
'You are managing a large-scale agricultural operation — specialized agronomic tools are more appropriate.',
],
faqs: [
{
question: 'What plant diseases can GreenLens identify?',
answer:
'GreenLens identifies common disease and stress patterns including overwatering symptoms, root rot signs, underwatering, fungal leaf spots, sunburn, nutrient deficiency indicators, and pest-related damage. It is designed for everyday houseplant and garden scenarios, not rare agricultural pathogens.',
},
{
question: 'Why do my plant leaves keep turning yellow even after I fixed the watering?',
answer:
'Yellow leaves after a care adjustment often indicate root damage from previous overwatering, a nutrient imbalance, or a lighting issue. GreenLens health checks ask about recent care history to narrow down which factor is most likely before recommending the next step.',
},
{
question: 'Can GreenLens tell me if my plant has root rot?',
answer:
'GreenLens can identify the visible signs associated with root rot — soft lower stems, yellowing, wilting despite moist soil, and foul smell — and recommend the appropriate response. It cannot physically inspect the roots, so its analysis is based on the symptom pattern you describe.',
},
{
question: 'Is GreenLens accurate enough to replace a plant professional?',
answer:
'For common household plant diseases and stress patterns, GreenLens is reliable and fast. For rare diseases, serious infestations, or plants with high commercial value, consulting a professional horticulturalist or plant pathologist remains the safer option.',
},
{
question: 'What should I do if GreenLens cannot identify the disease?',
answer:
'Take a sharp, well-lit photo of the affected area, note any recent care changes, and try again. If the result is still uncertain, use the support page to submit details — or consult a local plant specialist for hands-on assessment.',
},
],
relatedLinks: [
{
href: '/plant-identifier-app',
label: 'Plant Identifier App',
description: 'Start with identifying the plant before diagnosing the disease.',
},
{
href: '/plant-care-app',
label: 'Plant Care App',
description: 'Build the care routine that prevents disease in the first place.',
},
],
},
'plant-care-app': {
slug: 'plant-care-app',
metaTitle: 'Plant Care App — GreenLens',
metaDescription:
'GreenLens is a plant care app that goes beyond simple watering reminders. It connects care decisions to what your plant actually needs — not to a generic calendar.',
canonical: '/plant-care-app',
h1: 'Plant Care App',
tagline: 'Care reminders that know your plant — not just your calendar.',
directAnswer:
'GreenLens is a plant care app that combines identification, care scheduling, and health diagnostics in one place. Instead of generic watering timers, it connects care recommendations to the specific plant, its environment, and recent changes.',
definitionBlock:
'A plant care app helps you track watering, fertilizing, and maintenance schedules for each plant you own. GreenLens extends this with AI-based care plans derived from the scan result, context-aware reminders, and health check capability so care decisions stay grounded in what the plant actually shows.',
lastUpdated: 'April 2026',
includeAppSchema: true,
featureTable: {
title: 'GreenLens vs a basic reminder app',
alternativeLabel: 'Basic reminder apps',
rows: [
{
feature: 'Care scheduling',
greenlens: 'Reminders derived from the identified plant profile, adjusted to your home environment.',
alternative: 'Manual timers set by the user without plant-specific context.',
},
{
feature: 'Watering guidance',
greenlens: 'Considers soil feel, recent weather, and season — not just elapsed days.',
alternative: 'Fixed interval (e.g. every 7 days) regardless of plant condition.',
},
{
feature: 'Health connection',
greenlens: 'Care history links directly to health check results so you can see if routine care is causing problems.',
alternative: 'Reminders and diagnosis are separate with no shared context.',
},
{
feature: 'Plant collection',
greenlens: 'Per-plant care profiles with notes, photo history, and individual reminder schedules.',
alternative: 'Single shared reminder list with no per-plant differentiation.',
},
{
feature: 'Care plan on first scan',
greenlens: 'Identification automatically generates a starter care plan — no manual setup required.',
alternative: 'User must research and configure every parameter manually.',
},
],
},
greenLensIf: [
'You want care reminders that are based on the actual plant, not a generic schedule.',
'You have multiple plants and need per-plant care profiles in one place.',
'You want to connect care history to health diagnosis when something goes wrong.',
],
notBestIf: [
'You only need a simple universal timer without any plant-specific context.',
'You are managing a professional nursery or large-scale growing operation — commercial tools are more appropriate.',
],
faqs: [
{
question: 'How is GreenLens different from a basic watering reminder app?',
answer:
'A basic watering reminder fires every N days regardless of what the plant looks like. GreenLens connects care recommendations to the specific species, your environment, and what changed recently. If a plant is already stressed, the reminder approach adjusts rather than pushing a routine that makes things worse.',
},
{
question: 'Can GreenLens remind me to fertilize and repot as well as water?',
answer:
'Yes. GreenLens care plans include watering, fertilizing, and repotting schedules tailored to the identified species. Each reminder type can be adjusted individually per plant.',
},
{
question: 'How many plants can I track in GreenLens?',
answer:
'GreenLens supports a personal collection of multiple plants. Free and paid tiers differ on the number of advanced AI health checks available, but collection management and basic care reminders are included in the free version.',
},
{
question: 'Will GreenLens tell me if I am overwatering?',
answer:
'Yes. The health check feature is specifically designed to catch overwatering before it becomes root rot. If you scan a plant showing soft stems or yellowing and the care history shows recent watering, GreenLens will flag the likely connection.',
},
{
question: 'Does GreenLens work for outdoor plants as well as houseplants?',
answer:
'GreenLens covers both indoor and outdoor plants. Care plan recommendations account for the plant type, so outdoor and garden plants receive contextually different guidance than tropical houseplants.',
},
],
relatedLinks: [
{
href: '/plant-identifier-app',
label: 'Plant Identifier App',
description: 'Identify the plant first — then the care plan generates automatically.',
},
{
href: '/plant-disease-identifier',
label: 'Plant Disease Identifier',
description: 'When the care routine is not enough and the plant starts showing symptoms.',
},
],
},
'pflanzen-erkennen-app': {
slug: 'pflanzen-erkennen-app',
metaTitle: 'Pflanzen erkennen App — GreenLens',
metaDescription:
'GreenLens erkennt Pflanzen per Foto in Sekunden und liefert sofort einen Pflegeplan, Gießerinnerungen und Gesundheitsdiagnosen — alles in einer App.',
canonical: '/pflanzen-erkennen-app',
h1: 'Pflanzen erkennen App',
tagline: 'Pflanze fotografieren — Name, Pflegeanleitung und Diagnose in einer Sekunde.',
directAnswer:
'GreenLens ist eine Pflanzenerkennungs-App für iOS und Android. Einfach die Kamera auf eine Pflanze richten, scannen — und sofort erscheinen Artname, Pflegebedarf und nächste Handlungsempfehlung.',
definitionBlock:
'Eine Pflanzen-App erkennt Pflanzen anhand von Fotos und liefert den Artnamen sowie Pflegeinformationen. GreenLens geht weiter: Jeder Scan erzeugt automatisch einen Pflegeplan, und ein separater Gesundheitscheck analysiert Symptome wie gelbe Blätter oder weiche Stiele.',
lastUpdated: 'April 2026',
includeAppSchema: true,
featureTable: {
title: 'GreenLens im Vergleich zu einfachen Erkennungs-Apps',
alternativeLabel: 'Einfache Erkennungs-Apps',
rows: [
{
feature: 'Pflanzenerkennung per Foto',
greenlens: 'KI-gestützter Scan liefert Artname, Trivialname und Pflanzenportrait in unter einer Sekunde.',
alternative: 'Artname wird ausgegeben — ohne weitere Informationen oder nächste Schritte.',
},
{
feature: 'Automatischer Pflegeplan',
greenlens: 'Gießen, Düngen und Umtopfen werden nach dem Scan direkt als individueller Plan erstellt.',
alternative: 'Pflege muss manuell recherchiert und eingetragen werden.',
},
{
feature: 'Gesundheitscheck',
greenlens: 'Eigener Scan für Symptome wie gelbe Blätter, weiche Stiele oder plötzlichen Rückgang — mit klarer Handlungsempfehlung.',
alternative: 'Kaum vorhanden. Die meisten Erkennungs-Apps bieten keine Diagnose.',
},
{
feature: 'Pflanzensammlung',
greenlens: 'Eigene Sammlung mit Pflegeverläufen, Fotos und Erinnerungen pro Pflanze.',
alternative: 'Nur Scan-Verlauf, kein dauerhafter Pflegekontext.',
},
{
feature: 'Mehrsprachigkeit',
greenlens: 'Vollständig auf Deutsch, Englisch und Spanisch verfügbar.',
alternative: 'Häufig nur Englisch oder mit unvollständiger Übersetzung.',
},
],
},
greenLensIf: [
'Du willst eine Pflanze sofort bestimmen und direkt wissen, wie du sie pflegst.',
'Du hast eine Pflanze, die krank aussieht, und brauchst einen konkreten nächsten Schritt.',
'Du möchtest Pflanzen sammeln, Pflegeerinnerungen setzen und Gesundheitsprobleme diagnostizieren — in einer App.',
],
notBestIf: [
'Du möchtest seltene Wildpflanzen für die Citizen Science dokumentieren — dafür ist iNaturalist besser geeignet.',
'Du benötigst nur gelegentliche Bestimmung ohne Pflege- oder Diagnosefunktionen.',
],
faqs: [
{
question: 'Wie genau erkennt GreenLens Pflanzen?',
answer:
'GreenLens erkennt über 450 Pflanzenarten zuverlässig — darunter die häufigsten Zimmerpflanzen, Gartenpflanzen und Sukkulenten. Bei seltenen oder regional spezifischen Arten kann die Community-Plattform iNaturalist mehr Expertenwissen bieten. Für Alltagspflanzen ist GreenLens schnell und treffsicher.',
},
{
question: 'Funktioniert GreenLens auch ohne Internetverbindung?',
answer:
'Scans und Gesundheitschecks benötigen eine Internetverbindung. Die gespeicherte Pflanzensammlung, Pflegenotizen und Gießerinnerungen sind jedoch offline verfügbar.',
},
{
question: 'Ist GreenLens kostenlos nutzbar?',
answer:
'GreenLens enthält kostenlose Pflanzenerkennung. Erweiterte KI-Gesundheitschecks und unbegrenzte Scans sind über kostenpflichtige Credits oder ein Abonnement verfügbar.',
},
{
question: 'Kann GreenLens auch Pflanzenkrankheiten erkennen?',
answer:
'Ja. Der Gesundheitscheck analysiert sichtbare Symptome wie gelbe Blätter, braune Spitzen, weiche Stiele oder Flecken und liefert die wahrscheinlichste Ursache sowie einen konkreten nächsten Schritt — zum Beispiel Gießen einstellen, Standort prüfen oder isolieren.',
},
{
question: 'Welche Sprachen unterstützt GreenLens?',
answer:
'GreenLens ist vollständig auf Deutsch, Englisch und Spanisch verfügbar. Die Sprache kann in der App jederzeit gewechselt werden.',
},
],
relatedLinks: [
{
href: '/plant-identifier-app',
label: 'Plant Identifier App (English)',
description: 'The English version of this page for plant identification and care.',
},
{
href: '/plant-disease-identifier',
label: 'Plant Disease Identifier',
description: 'Symptom-based diagnosis when your plant starts showing problems.',
},
{
href: '/plant-care-app',
label: 'Plant Care App',
description: 'Reminders and care tracking for every plant in your collection.',
},
],
},
}
export function getSeoPageBySlug(slug: string): SeoPageProfile | undefined {
return seoPageProfiles[slug]
}

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ server {
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
proxy_pass http://app:3000;
proxy_pass http://app:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';

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

View File

@@ -18,6 +18,28 @@ const AVAILABLE_PRODUCTS = ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_m
const nowIso = () => new Date().toISOString();
const asIsoDate = (value) => {
if (value == null || value === '') return null;
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.toISOString();
}
if (typeof value === 'number' && Number.isFinite(value)) {
return new Date(value).toISOString();
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return null;
if (/^\d+$/.test(trimmed)) {
return new Date(Number(trimmed)).toISOString();
}
const parsed = new Date(trimmed);
if (!Number.isNaN(parsed.getTime())) {
return parsed.toISOString();
}
}
return null;
};
const startOfUtcMonth = (date) => {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0));
};
@@ -73,17 +95,19 @@ const runInTransaction = async (db, worker) => {
const normalizeAccountRow = (row) => {
if (!row) return null;
const now = new Date();
const { cycleStartedAt: defaultCycleStartedAt, cycleEndsAt: defaultCycleEndsAt } = getCycleBounds(now);
return {
userId: String(row.userId),
plan: row.plan === 'pro' ? 'pro' : 'free',
provider: typeof row.provider === 'string' && row.provider ? row.provider : 'revenuecat',
cycleStartedAt: String(row.cycleStartedAt),
cycleEndsAt: String(row.cycleEndsAt),
cycleStartedAt: asIsoDate(row.cycleStartedAt) || defaultCycleStartedAt.toISOString(),
cycleEndsAt: asIsoDate(row.cycleEndsAt) || defaultCycleEndsAt.toISOString(),
monthlyAllowance: Number(row.monthlyAllowance) || FREE_MONTHLY_CREDITS,
usedThisCycle: Number(row.usedThisCycle) || 0,
topupBalance: Number(row.topupBalance) || 0,
renewsAt: row.renewsAt ? String(row.renewsAt) : null,
updatedAt: row.updatedAt ? String(row.updatedAt) : nowIso(),
renewsAt: asIsoDate(row.renewsAt),
updatedAt: asIsoDate(row.updatedAt) || now.toISOString(),
};
};
@@ -238,25 +262,6 @@ const buildBillingSummary = (account) => {
};
};
const asIsoDate = (value) => {
if (value == null || value === '') return null;
if (typeof value === 'number' && Number.isFinite(value)) {
return new Date(value).toISOString();
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return null;
if (/^\d+$/.test(trimmed)) {
return new Date(Number(trimmed)).toISOString();
}
const parsed = new Date(trimmed);
if (!Number.isNaN(parsed.getTime())) {
return parsed.toISOString();
}
}
return null;
};
const isSupportedTopupProduct = (productId) => {
return typeof productId === 'string'
&& productId.startsWith('topup_')
@@ -303,6 +308,18 @@ const getValidProEntitlement = (customerInfo) => {
return proEntitlement;
}
// Fallback: entitlement is active but backed by a non-subscription product (e.g. a topup
// that was previously misconfigured to grant the pro entitlement). If the user also has a
// supported subscription product in their purchase history, honour the entitlement anyway.
const purchased = Array.isArray(customerInfo?.allPurchasedProductIdentifiers)
? customerInfo.allPurchasedProductIdentifiers
: [];
const hasSubscription = purchased.some((id) => SUPPORTED_SUBSCRIPTION_PRODUCTS.has(id));
if (hasSubscription) {
console.warn('[Billing] Pro entitlement backed by unsupported product but subscription found — honouring entitlement', summarizeRevenueCatCustomerInfo(customerInfo));
return proEntitlement;
}
console.warn('[Billing] Ignoring unsupported RevenueCat pro entitlement', summarizeRevenueCatCustomerInfo(customerInfo));
return null;
};
@@ -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();
await upsertAccount(tx, account);
return {

View File

@@ -107,10 +107,16 @@ const normalizeIdentifyResult = (raw, language) => {
const waterIntervalRaw = getNumber(careInfoRaw.waterIntervalDays);
const light = getString(careInfoRaw.light);
const temp = getString(careInfoRaw.temp);
if (waterIntervalRaw == null || !light || !temp) {
if (waterIntervalRaw == 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'
? `${name} wurde per KI erkannt. Pflegehinweise sind unten aufgefuehrt.`
: language === 'es'
@@ -124,8 +130,8 @@ const normalizeIdentifyResult = (raw, language) => {
description: description || fallbackDescription,
careInfo: {
waterIntervalDays: Math.round(clamp(waterIntervalRaw, 1, 45)),
light,
temp,
light: resolvedLight,
temp: resolvedTemp,
},
};
};
@@ -137,15 +143,16 @@ const normalizeHealthAnalysis = (raw, language) => {
const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 8);
const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 10);
if (scoreRaw == null || !statusRaw || !Array.isArray(issuesRaw)) {
return null;
}
const status = statusRaw === 'healthy' || statusRaw === 'watch' || statusRaw === 'critical'
// Use safe defaults instead of returning null — bad/partial JSON falls through
// to the graceful "Uncertain analysis" fallback at line 164 rather than
// propagating null → PROVIDER_ERROR to the caller.
const score = scoreRaw ?? 50;
const status = (statusRaw === 'healthy' || statusRaw === 'watch' || statusRaw === 'critical')
? statusRaw
: 'watch';
const issuesInput = Array.isArray(issuesRaw) ? issuesRaw : [];
const likelyIssues = issuesRaw
const likelyIssues = issuesInput
.map((entry) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
const title = getString(entry.title);
@@ -168,7 +175,7 @@ const normalizeHealthAnalysis = (raw, language) => {
? 'La IA no pudo extraer senales de salud estables.'
: 'AI could not extract stable health signals.';
return {
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)),
overallHealthScore: Math.round(clamp(score, 0, 100)),
status,
likelyIssues: [
{
@@ -191,7 +198,7 @@ const normalizeHealthAnalysis = (raw, language) => {
}
return {
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)),
overallHealthScore: Math.round(clamp(score, 0, 100)),
status,
likelyIssues,
actionsNow: actionsNowRaw,
@@ -215,6 +222,10 @@ const buildIdentifyPrompt = (language, mode) => {
'Rules:',
nameLanguageInstruction,
`- "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.',
'- If species is uncertain, prefer genus-level naming (for example: "Calathea sp.").',
'- "confidence" must be between 0 and 1.',
@@ -305,10 +316,14 @@ const postChatCompletion = async ({ modelChain, messages, imageUri, temperature
if (!response.ok) {
const body = await response.text();
let parsedError = {};
try { parsedError = JSON.parse(body); } catch {}
console.warn('OpenAI request HTTP error.', {
status: response.status,
model,
endpoint: OPENAI_CHAT_COMPLETIONS_URL,
openAiCode: parsedError?.error?.code,
openAiMessage: parsedError?.error?.message,
image: summarizeImageUri(imageUri),
bodyPreview: body.slice(0, 300),
});
@@ -351,7 +366,7 @@ const identifyPlant = async ({ imageUri, language, mode = 'primary', plan = 'fre
role: 'user',
content: [
{ 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',
content: [
{ 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 || '',
careInfo: {
waterIntervalDays: Math.max(1, Number(matchedEntry.careInfo?.waterIntervalDays) || Number(aiResult.careInfo?.waterIntervalDays) || 7),
light: matchedEntry.careInfo?.light || aiResult.careInfo?.light || 'Unknown',
temp: matchedEntry.careInfo?.temp || aiResult.careInfo?.temp || 'Unknown',
light: (matchedEntry.careInfo?.light && matchedEntry.careInfo.light !== 'Unknown') ? matchedEntry.careInfo.light : (aiResult.careInfo?.light || 'Unknown'),
temp: (matchedEntry.careInfo?.temp && matchedEntry.careInfo.temp !== 'Unknown') ? matchedEntry.careInfo.temp : (aiResult.careInfo?.temp || 'Unknown'),
},
},
};

View File

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

View File

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