Compare commits
16 Commits
c13eb331be
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 765eea05f7 | |||
| 9480a65864 | |||
| b20273b88d | |||
|
|
e8d013d99a | ||
| 8a9533c520 | |||
| 09078c3c20 | |||
| de8130686a | |||
| d0a13fa4f0 | |||
| c3fed5226a | |||
| 8d90d97182 | |||
| 1b40f1eb1b | |||
| 363f5f60d1 | |||
| 439f5a44c9 | |||
| 995e1daf2c | |||
| 2c589b8b47 | |||
| 0eca9a101f |
13
README.md
@@ -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 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
|
## Server deployment
|
||||||
|
|
||||||
@@ -87,18 +87,23 @@ What is not built locally, but pulled as ready-made images:
|
|||||||
|
|
||||||
- `postgres` uses `postgres:16-alpine`
|
- `postgres` uses `postgres:16-alpine`
|
||||||
- `minio` uses `minio/minio:latest`
|
- `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.
|
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:
|
This starts:
|
||||||
|
|
||||||
- `caddy`
|
|
||||||
- `landing`
|
- `landing`
|
||||||
- `api`
|
- `api`
|
||||||
- `postgres`
|
- `postgres`
|
||||||
- `minio`
|
- `minio`
|
||||||
|
|
||||||
|
Host ports for an external reverse proxy:
|
||||||
|
|
||||||
|
- `3000` -> `landing`
|
||||||
|
- `3003` -> `api`
|
||||||
|
- `9000` -> `minio` S3 API
|
||||||
|
- `9001` -> `minio` console
|
||||||
|
|
||||||
### 3. Useful server commands
|
### 3. Useful server commands
|
||||||
|
|
||||||
Check running containers:
|
Check running containers:
|
||||||
@@ -173,7 +178,7 @@ There, too:
|
|||||||
|
|
||||||
- `landing` is built from `greenlns-landing/Dockerfile`
|
- `landing` is built from `greenlns-landing/Dockerfile`
|
||||||
- `api` is built from `../server/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
|
## iOS TestFlight
|
||||||
|
|
||||||
|
|||||||
49
SplitImage.ps1
Normal 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."
|
||||||
45
__tests__/server/billingTimestampNormalization.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
2
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "GreenLens",
|
"name": "GreenLens",
|
||||||
"slug": "greenlens",
|
"slug": "greenlens",
|
||||||
"version": "2.1.6",
|
"version": "2.2.1",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
|
|||||||
@@ -81,10 +81,10 @@ const getBillingCopy = (language: Language) => {
|
|||||||
freePlanName: 'Free',
|
freePlanName: 'Free',
|
||||||
freePlanPrice: '0 EUR / Monat',
|
freePlanPrice: '0 EUR / Monat',
|
||||||
proPlanName: 'Pro',
|
proPlanName: 'Pro',
|
||||||
proPlanPrice: '4.99 EUR / Monat',
|
proPlanPrice: '4,99 € / Monat',
|
||||||
proBadgeText: 'EMPFOHLEN',
|
proBadgeText: 'EMPFOHLEN',
|
||||||
proYearlyPlanName: 'Pro',
|
proYearlyPlanName: 'Pro',
|
||||||
proYearlyPlanPrice: '39.99 EUR / Jahr',
|
proYearlyPlanPrice: '39,99 € / Jahr',
|
||||||
proYearlyBadgeText: 'SPAREN',
|
proYearlyBadgeText: 'SPAREN',
|
||||||
proBenefits: [
|
proBenefits: [
|
||||||
'250 Credits jeden Monat',
|
'250 Credits jeden Monat',
|
||||||
@@ -335,6 +335,15 @@ export default function BillingScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RevenueCat error code 7 = PRODUCT_ALREADY_PURCHASED — the Apple ID already
|
||||||
|
// owns this subscription on a different GreenLens account. Silently dismiss;
|
||||||
|
// the current account stays free. The user can restore via "Käufe wiederherstellen".
|
||||||
|
const rcErrorCode = typeof e === 'object' && e !== null ? (e as Record<string, unknown>).code : undefined;
|
||||||
|
if (rcErrorCode === 7) {
|
||||||
|
setSubModalVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.error('Payment failed', e);
|
console.error('Payment failed', e);
|
||||||
Alert.alert('Unerwarteter Fehler', msg);
|
Alert.alert('Unerwarteter Fehler', msg);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import * as ImageManipulator from 'expo-image-manipulator';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import { usePostHog } from 'posthog-react-native';
|
import { usePostHog } from 'posthog-react-native';
|
||||||
import { useApp } from '../context/AppContext';
|
import { useApp } from '../context/AppContext';
|
||||||
@@ -14,7 +15,7 @@ import { useColors } from '../constants/Colors';
|
|||||||
import { PlantRecognitionService } from '../services/plantRecognitionService';
|
import { PlantRecognitionService } from '../services/plantRecognitionService';
|
||||||
import { IdentificationResult } from '../types';
|
import { IdentificationResult } from '../types';
|
||||||
import { ResultCard } from '../components/ResultCard';
|
import { ResultCard } from '../components/ResultCard';
|
||||||
import { backendApiClient, isInsufficientCreditsError, isNetworkError } from '../services/backend/backendApiClient';
|
import { backendApiClient, isInsufficientCreditsError, isNetworkError, isTimeoutError } from '../services/backend/backendApiClient';
|
||||||
import { isBackendApiError } from '../services/backend/contracts';
|
import { isBackendApiError } from '../services/backend/contracts';
|
||||||
import { createIdempotencyKey } from '../utils/idempotency';
|
import { createIdempotencyKey } from '../utils/idempotency';
|
||||||
|
|
||||||
@@ -33,6 +34,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
|
|||||||
genericErrorMessage: 'Analyse fehlgeschlagen.',
|
genericErrorMessage: 'Analyse fehlgeschlagen.',
|
||||||
noConnectionTitle: 'Keine Verbindung',
|
noConnectionTitle: 'Keine Verbindung',
|
||||||
noConnectionMessage: 'Keine Verbindung zum Server. Bitte prüfe deine Internetverbindung und versuche es erneut.',
|
noConnectionMessage: 'Keine Verbindung zum Server. Bitte prüfe deine Internetverbindung und versuche es erneut.',
|
||||||
|
timeoutTitle: 'Scan zu langsam',
|
||||||
|
timeoutMessage: 'Die Analyse hat zu lange gedauert. Bitte erneut versuchen.',
|
||||||
retryLabel: 'Erneut versuchen',
|
retryLabel: 'Erneut versuchen',
|
||||||
providerErrorMessage: 'KI-Scan gerade nicht verfügbar. Bitte versuche es erneut.',
|
providerErrorMessage: 'KI-Scan gerade nicht verfügbar. Bitte versuche es erneut.',
|
||||||
healthProviderErrorMessage: 'KI-Health-Check gerade nicht verfügbar. Bitte versuche es erneut.',
|
healthProviderErrorMessage: 'KI-Health-Check gerade nicht verfügbar. Bitte versuche es erneut.',
|
||||||
@@ -55,6 +58,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
|
|||||||
genericErrorMessage: 'Analisis fallido.',
|
genericErrorMessage: 'Analisis fallido.',
|
||||||
noConnectionTitle: 'Sin conexión',
|
noConnectionTitle: 'Sin conexión',
|
||||||
noConnectionMessage: 'Sin conexión al servidor. Comprueba tu internet e inténtalo de nuevo.',
|
noConnectionMessage: 'Sin conexión al servidor. Comprueba tu internet e inténtalo de nuevo.',
|
||||||
|
timeoutTitle: 'Escaneo lento',
|
||||||
|
timeoutMessage: 'El análisis tardó demasiado. Inténtalo de nuevo.',
|
||||||
retryLabel: 'Reintentar',
|
retryLabel: 'Reintentar',
|
||||||
providerErrorMessage: 'Escaneo IA no disponible ahora. Inténtalo de nuevo.',
|
providerErrorMessage: 'Escaneo IA no disponible ahora. Inténtalo de nuevo.',
|
||||||
healthProviderErrorMessage: 'Health-check IA no disponible ahora. Inténtalo de nuevo.',
|
healthProviderErrorMessage: 'Health-check IA no disponible ahora. Inténtalo de nuevo.',
|
||||||
@@ -76,6 +81,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
|
|||||||
genericErrorMessage: 'Analysis failed.',
|
genericErrorMessage: 'Analysis failed.',
|
||||||
noConnectionTitle: 'No connection',
|
noConnectionTitle: 'No connection',
|
||||||
noConnectionMessage: 'Could not reach the server. Check your internet connection and try again.',
|
noConnectionMessage: 'Could not reach the server. Check your internet connection and try again.',
|
||||||
|
timeoutTitle: 'Scan Too Slow',
|
||||||
|
timeoutMessage: 'Analysis took too long. Please try again.',
|
||||||
retryLabel: 'Try again',
|
retryLabel: 'Try again',
|
||||||
providerErrorMessage: 'AI scan is currently unavailable. Please try again.',
|
providerErrorMessage: 'AI scan is currently unavailable. Please try again.',
|
||||||
healthProviderErrorMessage: 'AI health check is currently unavailable. Please try again.',
|
healthProviderErrorMessage: 'AI health check is currently unavailable. Please try again.',
|
||||||
@@ -169,6 +176,20 @@ export default function ScannerScreen() {
|
|||||||
};
|
};
|
||||||
}, [isAnalyzing, scanLineProgress, scanPulse]);
|
}, [isAnalyzing, scanLineProgress, scanPulse]);
|
||||||
|
|
||||||
|
const resizeForAnalysis = async (uri: string): Promise<string> => {
|
||||||
|
if (uri.startsWith('data:')) return uri;
|
||||||
|
try {
|
||||||
|
const result = await ImageManipulator.manipulateAsync(
|
||||||
|
uri,
|
||||||
|
[{ resize: { width: 1024 } }],
|
||||||
|
{ compress: 0.6, format: ImageManipulator.SaveFormat.JPEG, base64: true },
|
||||||
|
);
|
||||||
|
return result.base64 ? `data:image/jpeg;base64,${result.base64}` : result.uri;
|
||||||
|
} catch {
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const analyzeImage = async (imageUri: string, galleryImageUri?: string) => {
|
const analyzeImage = async (imageUri: string, galleryImageUri?: string) => {
|
||||||
if (isAnalyzing) return;
|
if (isAnalyzing) return;
|
||||||
|
|
||||||
@@ -295,6 +316,15 @@ export default function ScannerScreen() {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
} else if (isTimeoutError(error)) {
|
||||||
|
Alert.alert(
|
||||||
|
billingCopy.timeoutTitle,
|
||||||
|
billingCopy.timeoutMessage,
|
||||||
|
[
|
||||||
|
{ text: billingCopy.dismiss, style: 'cancel' },
|
||||||
|
{ text: billingCopy.retryLabel, onPress: () => analyzeImage(imageUri, galleryImageUri) },
|
||||||
|
],
|
||||||
|
);
|
||||||
} else if (isNetworkError(error)) {
|
} else if (isNetworkError(error)) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
billingCopy.noConnectionTitle,
|
billingCopy.noConnectionTitle,
|
||||||
@@ -327,7 +357,7 @@ export default function ScannerScreen() {
|
|||||||
const takePicture = async () => {
|
const takePicture = async () => {
|
||||||
if (!cameraRef.current || isAnalyzing) return;
|
if (!cameraRef.current || isAnalyzing) return;
|
||||||
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||||
const photo = await cameraRef.current.takePictureAsync({ base64: true, quality: 0.7 });
|
const photo = await cameraRef.current.takePictureAsync({ base64: true, quality: 0.5 });
|
||||||
if (photo) {
|
if (photo) {
|
||||||
const analysisUri = photo.base64
|
const analysisUri = photo.base64
|
||||||
? `data:image/jpeg;base64,${photo.base64}`
|
? `data:image/jpeg;base64,${photo.base64}`
|
||||||
@@ -343,17 +373,14 @@ export default function ScannerScreen() {
|
|||||||
|
|
||||||
const result = await ImagePicker.launchImageLibraryAsync({
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
mediaTypes: ['images'],
|
mediaTypes: ['images'],
|
||||||
quality: 0.7,
|
quality: 1,
|
||||||
base64: true,
|
base64: false,
|
||||||
});
|
});
|
||||||
if (!result.canceled && result.assets[0]) {
|
if (!result.canceled && result.assets[0]) {
|
||||||
const asset = result.assets[0];
|
const asset = result.assets[0];
|
||||||
const uri = asset.base64
|
const analysisUri = await resizeForAnalysis(asset.uri);
|
||||||
? `data:image/jpeg;base64,${asset.base64}`
|
setSelectedImage(asset.uri);
|
||||||
: asset.uri;
|
analyzeImage(analysisUri, asset.uri);
|
||||||
|
|
||||||
setSelectedImage(uri);
|
|
||||||
analyzeImage(uri, asset.uri || uri);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
555
audits/seo-ai-seo-roadmap.md
Normal 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
|
||||||
@@ -99,9 +99,9 @@ export const ResultCard: React.FC<ResultCardProps> = ({
|
|||||||
|
|
||||||
<View style={styles.careGrid}>
|
<View style={styles.careGrid}>
|
||||||
{[
|
{[
|
||||||
{ icon: 'water' as const, label: t.water, value: result.careInfo.waterIntervalDays <= 7 ? t.waterModerate : t.waterLittle, color: colors.info, bg: colors.infoSoft },
|
{ icon: 'water' as const, label: t.water, value: t.waterEveryXDays.replace('{0}', result.careInfo.waterIntervalDays.toString()), color: colors.info, bg: colors.infoSoft },
|
||||||
{ icon: 'sunny' as const, label: t.light, value: result.careInfo.light, color: colors.warning, bg: colors.warningSoft },
|
{ icon: 'sunny' as const, label: t.light, value: (result.careInfo.light && result.careInfo.light !== 'Unknown') ? result.careInfo.light : t.unknown, color: colors.warning, bg: colors.warningSoft },
|
||||||
{ icon: 'thermometer' as const, label: t.temp, value: result.careInfo.temp, color: colors.danger, bg: colors.dangerSoft },
|
{ icon: 'thermometer' as const, label: t.temp, value: (result.careInfo.temp && result.careInfo.temp !== 'Unknown') ? result.careInfo.temp : t.unknown, color: colors.danger, bg: colors.dangerSoft },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<View key={item.label} style={[styles.careCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
<View key={item.label} style={[styles.careCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||||
<View style={[styles.careIcon, { backgroundColor: item.bg }]}>
|
<View style={[styles.careIcon, { backgroundColor: item.bg }]}>
|
||||||
@@ -118,8 +118,8 @@ export const ResultCard: React.FC<ResultCardProps> = ({
|
|||||||
<Text style={[styles.detailsTitle, { color: colors.textSecondary }]}>{t.detailedCare}</Text>
|
<Text style={[styles.detailsTitle, { color: colors.textSecondary }]}>{t.detailedCare}</Text>
|
||||||
{[
|
{[
|
||||||
{ text: t.careTextWater.replace('{0}', result.careInfo.waterIntervalDays.toString()), color: colors.success },
|
{ text: t.careTextWater.replace('{0}', result.careInfo.waterIntervalDays.toString()), color: colors.success },
|
||||||
{ text: t.careTextLight.replace('{0}', result.careInfo.light), color: colors.warning },
|
{ text: t.careTextLight.replace('{0}', (result.careInfo.light && result.careInfo.light !== 'Unknown') ? result.careInfo.light : t.unknown), color: colors.warning },
|
||||||
{ text: t.careTextTemp.replace('{0}', result.careInfo.temp), color: colors.danger },
|
{ text: t.careTextTemp.replace('{0}', (result.careInfo.temp && result.careInfo.temp !== 'Unknown') ? result.careInfo.temp : t.unknown), color: colors.danger },
|
||||||
].map((item, i) => (
|
].map((item, i) => (
|
||||||
<View key={i} style={styles.detailRow}>
|
<View key={i} style={styles.detailRow}>
|
||||||
<View style={[styles.detailDot, { backgroundColor: item.color }]} />
|
<View style={[styles.detailDot, { backgroundColor: item.color }]} />
|
||||||
|
|||||||
@@ -1,22 +1,4 @@
|
|||||||
services:
|
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:
|
landing:
|
||||||
build:
|
build:
|
||||||
context: ./greenlns-landing
|
context: ./greenlns-landing
|
||||||
@@ -26,6 +8,8 @@ services:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
NEXT_PUBLIC_SITE_URL: ${SITE_URL:-https://greenlenspro.com}
|
NEXT_PUBLIC_SITE_URL: ${SITE_URL:-https://greenlenspro.com}
|
||||||
|
networks:
|
||||||
|
- greenlens_net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
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
|
interval: 15s
|
||||||
@@ -65,6 +49,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
minio:
|
minio:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- greenlens_net
|
||||||
healthcheck:
|
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))"]
|
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
|
interval: 15s
|
||||||
@@ -80,6 +66,8 @@ services:
|
|||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- greenlens_net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -92,9 +80,11 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-greenlns-minio}
|
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-greenlns-minio}
|
||||||
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required}
|
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required}
|
||||||
command: server /data
|
command: server /data --console-address ":9001"
|
||||||
volumes:
|
volumes:
|
||||||
- minio_data:/data
|
- ./minio_data:/data # <-- NEU: Lokaler Ordner statt benanntes Volume!
|
||||||
|
networks:
|
||||||
|
- greenlens_net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -102,7 +92,8 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
caddy_config:
|
|
||||||
caddy_data:
|
|
||||||
minio_data:
|
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
greenlens_net:
|
||||||
|
external: true
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
# GreenLens Landing
|
# 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
|
- `landing` for the Next.js standalone app
|
||||||
- `api` for the Express backend from `../server`
|
- `api` for the Express backend from `../server`
|
||||||
- `postgres` for persistent app data
|
- `postgres` for persistent app data
|
||||||
@@ -23,6 +22,13 @@ From `greenlns-landing/docker-compose.yml`:
|
|||||||
docker compose up --build -d
|
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:
|
Required environment variables:
|
||||||
|
|
||||||
- `SITE_DOMAIN`
|
- `SITE_DOMAIN`
|
||||||
|
|||||||
@@ -1212,6 +1212,283 @@ h3 {
|
|||||||
color: var(--muted);
|
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
|
RESPONSIVE
|
||||||
============================================= */
|
============================================= */
|
||||||
@@ -1270,6 +1547,22 @@ h3 {
|
|||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: var(--s6);
|
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) {
|
@media (max-width: 768px) {
|
||||||
@@ -1319,4 +1612,30 @@ h3 {
|
|||||||
.support-faq-list {
|
.support-faq-list {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comparison-hero {
|
||||||
|
padding-top: 9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-pain-grid,
|
||||||
|
.comparison-scenario-grid,
|
||||||
|
.comparison-faq-grid,
|
||||||
|
.comparison-links-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-context,
|
||||||
|
.comparison-theses,
|
||||||
|
.comparison-table-section,
|
||||||
|
.comparison-fit,
|
||||||
|
.comparison-emergency,
|
||||||
|
.comparison-faq,
|
||||||
|
.comparison-links {
|
||||||
|
padding: var(--s8) 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ const CONTENT = {
|
|||||||
contactLabel: 'Kontakt',
|
contactLabel: 'Kontakt',
|
||||||
registryLabel: 'Register',
|
registryLabel: 'Register',
|
||||||
vatLabel: 'USt-ID',
|
vatLabel: 'USt-ID',
|
||||||
note: 'Vor der Veroeffentlichung muessen alle rechtlichen Angaben mit den echten Firmendaten ersetzt werden.',
|
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
title: 'Imprint',
|
title: 'Imprint',
|
||||||
@@ -22,7 +21,6 @@ const CONTENT = {
|
|||||||
contactLabel: 'Contact',
|
contactLabel: 'Contact',
|
||||||
registryLabel: 'Registry',
|
registryLabel: 'Registry',
|
||||||
vatLabel: 'VAT ID',
|
vatLabel: 'VAT ID',
|
||||||
note: 'Replace all legal placeholders with your real company details before publishing the site.',
|
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
title: 'Aviso Legal',
|
title: 'Aviso Legal',
|
||||||
@@ -32,7 +30,6 @@ const CONTENT = {
|
|||||||
contactLabel: 'Contacto',
|
contactLabel: 'Contacto',
|
||||||
registryLabel: 'Registro',
|
registryLabel: 'Registro',
|
||||||
vatLabel: 'IVA',
|
vatLabel: 'IVA',
|
||||||
note: 'Sustituye todos los marcadores legales por tus datos reales antes de publicar el sitio.',
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,9 +44,9 @@ export default function ImprintPage() {
|
|||||||
<p>
|
<p>
|
||||||
<strong>{c.companyLabel}:</strong> {siteConfig.company.legalName}
|
<strong>{c.companyLabel}:</strong> {siteConfig.company.legalName}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
{siteConfig.company.addressLine1 ? (
|
||||||
<strong>{c.addressLabel}:</strong> {siteConfig.company.addressLine1}
|
<p><strong>{c.addressLabel}:</strong> {siteConfig.company.addressLine1}</p>
|
||||||
</p>
|
) : null}
|
||||||
{siteConfig.company.addressLine2 ? <p>{siteConfig.company.addressLine2}</p> : null}
|
{siteConfig.company.addressLine2 ? <p>{siteConfig.company.addressLine2}</p> : null}
|
||||||
<p>{siteConfig.company.country}</p>
|
<p>{siteConfig.company.country}</p>
|
||||||
<p>
|
<p>
|
||||||
@@ -58,13 +55,12 @@ export default function ImprintPage() {
|
|||||||
<p>
|
<p>
|
||||||
<strong>{c.contactLabel}:</strong> <a href={`mailto:${siteConfig.legalEmail}`}>{siteConfig.legalEmail}</a>
|
<strong>{c.contactLabel}:</strong> <a href={`mailto:${siteConfig.legalEmail}`}>{siteConfig.legalEmail}</a>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
{siteConfig.company.registry ? (
|
||||||
<strong>{c.registryLabel}:</strong> {siteConfig.company.registry}
|
<p><strong>{c.registryLabel}:</strong> {siteConfig.company.registry}</p>
|
||||||
</p>
|
) : null}
|
||||||
<p>
|
{siteConfig.company.vatId ? (
|
||||||
<strong>{c.vatLabel}:</strong> {siteConfig.company.vatId}
|
<p><strong>{c.vatLabel}:</strong> {siteConfig.company.vatId}</p>
|
||||||
</p>
|
) : null}
|
||||||
<p style={{ marginTop: '1rem', fontSize: '0.95rem', opacity: 0.8 }}>{c.note}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
import { LangProvider } from '@/context/LangContext'
|
import { LangProvider } from '@/context/LangContext'
|
||||||
import { siteConfig } from '@/lib/site'
|
import { siteConfig, hasIosStoreUrl } from '@/lib/site'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL(siteConfig.domain),
|
metadataBase: new URL(siteConfig.domain),
|
||||||
@@ -28,20 +29,21 @@ export const metadata: Metadata = {
|
|||||||
type: 'website',
|
type: 'website',
|
||||||
url: siteConfig.domain,
|
url: siteConfig.domain,
|
||||||
},
|
},
|
||||||
alternates: {
|
twitter: {
|
||||||
canonical: '/',
|
card: 'summary_large_image',
|
||||||
languages: {
|
title: 'GreenLens - Plant Identifier and Care Planner',
|
||||||
de: '/',
|
description: 'Identify plants, get care guidance, and manage your collection with GreenLens.',
|
||||||
en: '/',
|
|
||||||
es: '/',
|
|
||||||
'x-default': '/',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
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 (
|
return (
|
||||||
<html lang="de">
|
<html lang={htmlLang}>
|
||||||
<head>
|
<head>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||||
@@ -49,18 +51,40 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: JSON.stringify({
|
__html: JSON.stringify([
|
||||||
'@context': 'https://schema.org',
|
{
|
||||||
'@type': 'SoftwareApplication',
|
'@context': 'https://schema.org',
|
||||||
name: siteConfig.name,
|
'@type': 'SoftwareApplication',
|
||||||
operatingSystem: 'iOS, Android',
|
name: siteConfig.name,
|
||||||
applicationCategory: 'LifestyleApplication',
|
operatingSystem: 'iOS, Android',
|
||||||
offers: {
|
applicationCategory: 'LifestyleApplication',
|
||||||
'@type': 'Offer',
|
description:
|
||||||
price: '0',
|
'Identify plants, track care schedules, and manage your collection with AI-powered scans.',
|
||||||
priceCurrency: 'EUR',
|
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>
|
</head>
|
||||||
|
|||||||
83
greenlns-landing/app/opengraph-image.tsx
Normal 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 & 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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,9 +9,96 @@ import FAQ from '@/components/FAQ'
|
|||||||
import CTA from '@/components/CTA'
|
import CTA from '@/components/CTA'
|
||||||
import Footer from '@/components/Footer'
|
import Footer from '@/components/Footer'
|
||||||
|
|
||||||
|
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() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(howToSchema) }}
|
||||||
|
/>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
|
||||||
|
/>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<main>
|
<main>
|
||||||
<Hero />
|
<Hero />
|
||||||
|
|||||||
33
greenlns-landing/app/pflanzen-erkennen-app/page.tsx
Normal 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} />
|
||||||
|
}
|
||||||
33
greenlns-landing/app/plant-care-app/page.tsx
Normal 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} />
|
||||||
|
}
|
||||||
33
greenlns-landing/app/plant-disease-identifier/page.tsx
Normal 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} />
|
||||||
|
}
|
||||||
33
greenlns-landing/app/plant-identifier-app/page.tsx
Normal 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} />
|
||||||
|
}
|
||||||
@@ -6,25 +6,73 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
url: baseUrl,
|
url: baseUrl,
|
||||||
lastModified: new Date(),
|
lastModified: new Date('2026-04-08'),
|
||||||
changeFrequency: 'weekly',
|
changeFrequency: 'weekly',
|
||||||
priority: 1,
|
priority: 1,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/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`,
|
url: `${baseUrl}/imprint`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date('2026-04-08'),
|
||||||
changeFrequency: 'monthly',
|
changeFrequency: 'monthly',
|
||||||
priority: 0.3,
|
priority: 0.3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${baseUrl}/privacy`,
|
url: `${baseUrl}/privacy`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date('2026-04-08'),
|
||||||
changeFrequency: 'monthly',
|
changeFrequency: 'monthly',
|
||||||
priority: 0.3,
|
priority: 0.3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${baseUrl}/terms`,
|
url: `${baseUrl}/terms`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date('2026-04-08'),
|
||||||
changeFrequency: 'monthly',
|
changeFrequency: 'monthly',
|
||||||
priority: 0.3,
|
priority: 0.3,
|
||||||
},
|
},
|
||||||
|
|||||||
70
greenlns-landing/app/vs/[competitor]/page.tsx
Normal 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)} />
|
||||||
|
}
|
||||||
236
greenlns-landing/components/ComparisonPage.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -31,26 +31,26 @@ const faqs = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: {
|
question: {
|
||||||
en: 'Can I use it offline?',
|
en: 'Can I use GreenLens offline?',
|
||||||
de: 'Kann ich die App offline nutzen?',
|
de: 'Kann ich GreenLens offline nutzen?',
|
||||||
es: 'Puedo usarla sin conexion?'
|
es: 'Puedo usar GreenLens sin conexion?'
|
||||||
},
|
},
|
||||||
answer: {
|
answer: {
|
||||||
en: 'Some experiences may require a connection, especially for scan-related features. Saved information inside the app can remain available afterward.',
|
en: 'Plant identification and health checks require an internet connection. Your saved collection, care notes, and watering reminders are available offline.',
|
||||||
de: 'Einige Funktionen benoetigen eine Verbindung, besonders scanbezogene Features. Gespeicherte Informationen in der App koennen danach weiter verfuegbar bleiben.',
|
de: 'Pflanzenidentifikation und Gesundheitscheck benoetigen eine Internetverbindung. Deine gespeicherte Sammlung, Pflegenotizen und Giess-Erinnerungen sind offline verfuegbar.',
|
||||||
es: 'Algunas funciones requieren conexion, especialmente las relacionadas con escaneos. La informacion guardada puede seguir disponible despues.'
|
es: 'La identificacion de plantas y el control de salud requieren conexion a internet. Tu coleccion guardada, notas de cuidado y recordatorios de riego estan disponibles sin conexion.'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: {
|
question: {
|
||||||
en: 'What kind of plants can I use it for?',
|
en: 'What kind of plants can I use GreenLens for?',
|
||||||
de: 'Fuer welche Pflanzen kann ich die App nutzen?',
|
de: 'Fuer welche Pflanzen kann ich GreenLens nutzen?',
|
||||||
es: 'Para que tipo de plantas puedo usar la app?'
|
es: 'Para que tipo de plantas puedo usar GreenLens?'
|
||||||
},
|
},
|
||||||
answer: {
|
answer: {
|
||||||
en: 'GreenLens is built for everyday plant owners who want help with houseplants, garden plants, and general care questions.',
|
en: 'GreenLens covers 450+ plant species including houseplants, garden plants, and succulents. It is built for everyday plant owners who want identification and care guidance in one place.',
|
||||||
de: 'GreenLens richtet sich an Pflanzenbesitzer, die Hilfe bei Zimmerpflanzen, Gartenpflanzen und allgemeinen Pflegefragen wollen.',
|
de: 'GreenLens umfasst ueber 450 Pflanzenarten, darunter Zimmerpflanzen, Gartenpflanzen und Sukkulenten. Die App richtet sich an Pflanzenbesitzer, die Identifikation und Pflege an einem Ort wollen.',
|
||||||
es: 'GreenLens esta pensada para personas que quieren ayuda con plantas de interior, jardin y preguntas generales de cuidado.'
|
es: 'GreenLens cubre mas de 450 especies de plantas, incluyendo plantas de interior, de jardin y suculentas. Esta pensada para quienes quieren identificacion y cuidado en un solo lugar.'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,6 +32,17 @@ export default function Footer() {
|
|||||||
{label}
|
{label}
|
||||||
</Link>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export default function Hero() {
|
|||||||
<div className="hero-visual reveal-fade delay-2">
|
<div className="hero-visual reveal-fade delay-2">
|
||||||
<div className="hero-video-card hero-video-16-9">
|
<div className="hero-video-card hero-video-16-9">
|
||||||
<video autoPlay loop muted playsInline aria-label="GreenLens App Demo">
|
<video autoPlay loop muted playsInline aria-label="GreenLens App Demo">
|
||||||
<source src="/GreenLensHype.mp4" type="video/mp4" />
|
<source src="/greenlens.mp4" type="video/mp4" />
|
||||||
</video>
|
</video>
|
||||||
<div className="hero-video-card-overlay" />
|
<div className="hero-video-card-overlay" />
|
||||||
<div className="hero-video-badge">
|
<div className="hero-video-badge">
|
||||||
|
|||||||
177
greenlns-landing/components/SeoCategoryPage.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { createContext, useContext, useState, ReactNode } from 'react'
|
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||||
import { Lang, translations } from '@/lib/i18n'
|
import { Lang, translations } from '@/lib/i18n'
|
||||||
|
|
||||||
interface LangCtx {
|
interface LangCtx {
|
||||||
@@ -15,8 +15,27 @@ const LangContext = createContext<LangCtx>({
|
|||||||
t: translations.de,
|
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 }) {
|
export function LangProvider({ children }: { children: ReactNode }) {
|
||||||
const [lang, setLang] = useState<Lang>('de')
|
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 (
|
return (
|
||||||
<LangContext.Provider value={{ lang, setLang, t: translations[lang] }}>
|
<LangContext.Provider value={{ lang, setLang, t: translations[lang] }}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,27 +1,11 @@
|
|||||||
services:
|
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:
|
landing:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
@@ -37,6 +21,8 @@ services:
|
|||||||
context: ../server
|
context: ../server
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3003:3000"
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
@@ -55,7 +41,8 @@ services:
|
|||||||
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-https://greenlenspro.com/storage}
|
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-https://greenlenspro.com/storage}
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
OPENAI_SCAN_MODEL: ${OPENAI_SCAN_MODEL:-gpt-5-mini}
|
OPENAI_SCAN_MODEL: ${OPENAI_SCAN_MODEL:-gpt-5-mini}
|
||||||
OPENAI_HEALTH_MODEL: ${OPENAI_HEALTH_MODEL:-gpt-5-mini}
|
OPENAI_SCAN_MODEL_PRO: ${OPENAI_SCAN_MODEL_PRO:-gpt-5.4}
|
||||||
|
OPENAI_HEALTH_MODEL: ${OPENAI_HEALTH_MODEL:-gpt-4o-mini}
|
||||||
REVENUECAT_WEBHOOK_SECRET: ${REVENUECAT_WEBHOOK_SECRET:-}
|
REVENUECAT_WEBHOOK_SECRET: ${REVENUECAT_WEBHOOK_SECRET:-}
|
||||||
REVENUECAT_PRO_ENTITLEMENT_ID: ${REVENUECAT_PRO_ENTITLEMENT_ID:-pro}
|
REVENUECAT_PRO_ENTITLEMENT_ID: ${REVENUECAT_PRO_ENTITLEMENT_ID:-pro}
|
||||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}
|
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}
|
||||||
@@ -89,10 +76,13 @@ services:
|
|||||||
minio:
|
minio:
|
||||||
image: minio/minio:latest
|
image: minio/minio:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-greenlns-minio}
|
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-greenlns-minio}
|
||||||
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required}
|
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required}
|
||||||
command: server /data
|
command: server /data --console-address ":9001"
|
||||||
volumes:
|
volumes:
|
||||||
- minio_data:/data
|
- minio_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -102,7 +92,5 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
caddy_config:
|
|
||||||
caddy_data:
|
|
||||||
postgres_data:
|
postgres_data:
|
||||||
minio_data:
|
minio_data:
|
||||||
|
|||||||
544
greenlns-landing/lib/competitors.ts
Normal 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])
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ export const translations = {
|
|||||||
tag: 'Features',
|
tag: 'Features',
|
||||||
h2a: 'Alles, was dein',
|
h2a: 'Alles, was dein',
|
||||||
h2b: 'Urban Jungle braucht.',
|
h2b: 'Urban Jungle braucht.',
|
||||||
desc: 'Von der ersten Identifikation bis zur laufenden Pflege hilft GreenLens dir, Pflanzen besser zu verstehen und besser zu organisieren.',
|
desc: 'Von der ersten Identifikation bis zur laufenden Pflege hilft GreenLens dir, Pflanzen besser zu verstehen und besser zu organisieren. Das Lexikon umfasst ueber 450 Pflanzenarten.',
|
||||||
},
|
},
|
||||||
cta: {
|
cta: {
|
||||||
tag: 'Download',
|
tag: 'Download',
|
||||||
@@ -107,7 +107,7 @@ export const translations = {
|
|||||||
tag: 'Features',
|
tag: 'Features',
|
||||||
h2a: 'Everything your',
|
h2a: 'Everything your',
|
||||||
h2b: 'Urban Jungle needs.',
|
h2b: 'Urban Jungle needs.',
|
||||||
desc: 'From first identification to ongoing care, GreenLens helps you understand plants better and stay organized.',
|
desc: 'From first identification to ongoing care, GreenLens helps you understand plants better and stay organized. The lexicon covers 450+ plant species.',
|
||||||
},
|
},
|
||||||
cta: {
|
cta: {
|
||||||
tag: 'Download',
|
tag: 'Download',
|
||||||
@@ -173,7 +173,7 @@ export const translations = {
|
|||||||
tag: 'Funciones',
|
tag: 'Funciones',
|
||||||
h2a: 'Todo lo que tu',
|
h2a: 'Todo lo que tu',
|
||||||
h2b: 'jardin urbano necesita.',
|
h2b: 'jardin urbano necesita.',
|
||||||
desc: 'Desde la primera identificacion hasta el cuidado continuo, GreenLens te ayuda a entender mejor tus plantas y a organizarte.',
|
desc: 'Desde la primera identificacion hasta el cuidado continuo, GreenLens te ayuda a entender mejor tus plantas y a organizarte. El lexico cubre mas de 450 especies de plantas.',
|
||||||
},
|
},
|
||||||
cta: {
|
cta: {
|
||||||
tag: 'Descarga',
|
tag: 'Descarga',
|
||||||
|
|||||||
439
greenlns-landing/lib/seoPages.ts
Normal 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]
|
||||||
|
}
|
||||||
@@ -5,16 +5,16 @@ export const siteConfig = {
|
|||||||
domain: siteUrl,
|
domain: siteUrl,
|
||||||
supportEmail: 'knuth.timo@gmail.com',
|
supportEmail: 'knuth.timo@gmail.com',
|
||||||
legalEmail: 'knuth.timo@gmail.com',
|
legalEmail: 'knuth.timo@gmail.com',
|
||||||
iosAppStoreUrl: '',
|
iosAppStoreUrl: 'https://apps.apple.com/de/app/greenlens-pro/id6759843546?l=en-GB',
|
||||||
androidPlayStoreUrl: '',
|
androidPlayStoreUrl: '',
|
||||||
company: {
|
company: {
|
||||||
legalName: 'GreenLens',
|
legalName: 'GreenLens',
|
||||||
representative: 'Tim Knuth',
|
representative: 'Tim Knuth',
|
||||||
addressLine1: 'Replace with your legal business address',
|
addressLine1: '',
|
||||||
addressLine2: '',
|
addressLine2: '',
|
||||||
country: 'Germany',
|
country: 'Germany',
|
||||||
registry: 'Replace with your company registry details',
|
registry: '',
|
||||||
vatId: 'Replace with your VAT ID or remove this line',
|
vatId: '',
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
images: {
|
|
||||||
unoptimized: true,
|
|
||||||
},
|
|
||||||
turbopack: {
|
turbopack: {
|
||||||
root: path.join(__dirname),
|
root: path.join(__dirname),
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
greenlns-landing/public/og-image.png
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
instagram_assets/A_breathtakingly_beautiful,_202604081648.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
BIN
instagram_assets/A_breathtakingly_beautiful,_202604081649.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
instagram_assets/ig_ai_nature_1775657670807.png
Normal file
|
After Width: | Height: | Size: 709 KiB |
BIN
instagram_assets/ig_indoor_jungle_1775657655433.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
instagram_assets/ig_minimal_leaf_1775657635980.png
Normal file
|
After Width: | Height: | Size: 514 KiB |
BIN
instagram_assets/ig_plant_flatlay_1775657605357.png
Normal file
|
After Width: | Height: | Size: 985 KiB |
BIN
instagram_assets/ig_repotting_1775657686423.png
Normal file
|
After Width: | Height: | Size: 854 KiB |
BIN
instagram_assets/ig_scan_lifestyle_1775657589736.png
Normal file
|
After Width: | Height: | Size: 812 KiB |
BIN
instagram_assets/ig_sick_leaf_1775657620084.png
Normal file
|
After Width: | Height: | Size: 731 KiB |
85
keyword-research.csv
Normal 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
|
||||||
|
248
server/index.js
@@ -69,6 +69,14 @@ const SEMANTIC_SEARCH_COST = 2;
|
|||||||
const HEALTH_CHECK_COST = 2;
|
const HEALTH_CHECK_COST = 2;
|
||||||
const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8;
|
const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8;
|
||||||
|
|
||||||
|
let catalogCache = null;
|
||||||
|
|
||||||
|
const getCachedCatalogEntries = async (db) => {
|
||||||
|
if (catalogCache) return catalogCache;
|
||||||
|
catalogCache = await getPlants(db, { limit: 500 });
|
||||||
|
return catalogCache;
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_BOOTSTRAP_PLANTS = [
|
const DEFAULT_BOOTSTRAP_PLANTS = [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
@@ -100,6 +108,14 @@ const DEFAULT_BOOTSTRAP_PLANTS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const FULL_BOOTSTRAP_CATALOG_CANDIDATES = [
|
||||||
|
path.join(__dirname, 'data', 'plants_dump_utf8.json'),
|
||||||
|
path.join(__dirname, '..', 'plants_dump_utf8.json'),
|
||||||
|
];
|
||||||
|
const FULL_BOOTSTRAP_MANIFEST_CANDIDATES = [
|
||||||
|
path.join(__dirname, 'public', 'plants', 'manifest.json'),
|
||||||
|
];
|
||||||
|
|
||||||
let db;
|
let db;
|
||||||
|
|
||||||
const parseBoolean = (value, fallbackValue) => {
|
const parseBoolean = (value, fallbackValue) => {
|
||||||
@@ -172,7 +188,7 @@ const toPlantResult = (entry, confidence) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const pickCatalogFallback = (entries, imageUri, preferHighConfidence = false) => {
|
const pickCatalogFallback = (entries, imageUri, preferHighConfidence = false, { silent = false } = {}) => {
|
||||||
if (!Array.isArray(entries) || entries.length === 0) return null;
|
if (!Array.isArray(entries) || entries.length === 0) return null;
|
||||||
const baseHash = hashString(`${imageUri || ''}|${entries.length}`);
|
const baseHash = hashString(`${imageUri || ''}|${entries.length}`);
|
||||||
const index = baseHash % entries.length;
|
const index = baseHash % entries.length;
|
||||||
@@ -180,11 +196,13 @@ const pickCatalogFallback = (entries, imageUri, preferHighConfidence = false) =>
|
|||||||
const confidence = preferHighConfidence
|
const confidence = preferHighConfidence
|
||||||
? 0.22 + ((baseHash % 3) / 100)
|
? 0.22 + ((baseHash % 3) / 100)
|
||||||
: 0.18 + ((baseHash % 7) / 100);
|
: 0.18 + ((baseHash % 7) / 100);
|
||||||
console.warn('Using hash-based catalog fallback — OpenAI is unavailable or returned null.', {
|
if (!silent) {
|
||||||
plant: entries[index]?.name,
|
console.warn('Using hash-based catalog fallback — OpenAI is unavailable or returned null.', {
|
||||||
confidence,
|
plant: entries[index]?.name,
|
||||||
imageHint: (imageUri || '').slice(0, 80),
|
confidence,
|
||||||
});
|
imageHint: (imageUri || '').slice(0, 80),
|
||||||
|
});
|
||||||
|
}
|
||||||
return toPlantResult(entries[index], confidence);
|
return toPlantResult(entries[index], confidence);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -277,15 +295,153 @@ const ensureNonEmptyString = (value, fieldName) => {
|
|||||||
throw error;
|
throw error;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readJsonFromCandidates = (filePaths) => {
|
||||||
|
for (const filePath of filePaths) {
|
||||||
|
if (!fs.existsSync(filePath)) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
|
||||||
|
return {
|
||||||
|
parsed: JSON.parse(raw),
|
||||||
|
sourcePath: filePath,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse bootstrap JSON file.', {
|
||||||
|
filePath,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildEntriesFromManifest = (manifest) => {
|
||||||
|
const items = Array.isArray(manifest?.items) ? manifest.items : [];
|
||||||
|
return items
|
||||||
|
.filter((item) => item && typeof item.name === 'string' && typeof item.botanicalName === 'string')
|
||||||
|
.map((item) => ({
|
||||||
|
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : `${item.botanicalName}`.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||||
|
name: item.name.trim(),
|
||||||
|
botanicalName: item.botanicalName.trim(),
|
||||||
|
imageUri: typeof item.localImageUri === 'string' && item.localImageUri.trim()
|
||||||
|
? item.localImageUri.trim()
|
||||||
|
: (typeof item.sourceUri === 'string' ? item.sourceUri.trim() : ''),
|
||||||
|
imageStatus: item.status === 'missing' ? 'missing' : 'ok',
|
||||||
|
description: '',
|
||||||
|
categories: [],
|
||||||
|
confidence: 1,
|
||||||
|
careInfo: {
|
||||||
|
waterIntervalDays: 7,
|
||||||
|
light: 'Unknown',
|
||||||
|
temp: 'Unknown',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.imageUri);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeBootstrapEntries = (primaryEntries, secondaryEntries) => {
|
||||||
|
const mergedByBotanical = new Map();
|
||||||
|
|
||||||
|
primaryEntries.forEach((entry) => {
|
||||||
|
const botanicalKey = typeof entry?.botanicalName === 'string'
|
||||||
|
? entry.botanicalName.trim().toLowerCase()
|
||||||
|
: '';
|
||||||
|
if (!botanicalKey || mergedByBotanical.has(botanicalKey)) return;
|
||||||
|
mergedByBotanical.set(botanicalKey, { ...entry });
|
||||||
|
});
|
||||||
|
|
||||||
|
secondaryEntries.forEach((entry) => {
|
||||||
|
const botanicalKey = typeof entry?.botanicalName === 'string'
|
||||||
|
? entry.botanicalName.trim().toLowerCase()
|
||||||
|
: '';
|
||||||
|
if (!botanicalKey) return;
|
||||||
|
|
||||||
|
const existing = mergedByBotanical.get(botanicalKey);
|
||||||
|
if (!existing) {
|
||||||
|
mergedByBotanical.set(botanicalKey, { ...entry });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldPreferLocalImage = typeof entry.imageUri === 'string' && entry.imageUri.startsWith('/plants/');
|
||||||
|
mergedByBotanical.set(botanicalKey, {
|
||||||
|
...existing,
|
||||||
|
imageUri: shouldPreferLocalImage ? entry.imageUri : existing.imageUri,
|
||||||
|
imageStatus: shouldPreferLocalImage ? entry.imageStatus || existing.imageStatus : existing.imageStatus,
|
||||||
|
id: existing.id || entry.id,
|
||||||
|
name: existing.name || entry.name,
|
||||||
|
botanicalName: existing.botanicalName || entry.botanicalName,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(mergedByBotanical.values());
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadFullBootstrapCatalog = () => {
|
||||||
|
const catalogDump = readJsonFromCandidates(FULL_BOOTSTRAP_CATALOG_CANDIDATES);
|
||||||
|
const manifestDump = readJsonFromCandidates(FULL_BOOTSTRAP_MANIFEST_CANDIDATES);
|
||||||
|
|
||||||
|
const catalogEntries = Array.isArray(catalogDump?.parsed) ? catalogDump.parsed : [];
|
||||||
|
const manifestEntries = manifestDump ? buildEntriesFromManifest(manifestDump.parsed) : [];
|
||||||
|
const mergedEntries = mergeBootstrapEntries(catalogEntries, manifestEntries);
|
||||||
|
|
||||||
|
if (mergedEntries.length === 0) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries: mergedEntries,
|
||||||
|
sourcePath: [catalogDump?.sourcePath, manifestDump?.sourcePath].filter(Boolean).join(', '),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMinimalBootstrapCatalog = (entries) => {
|
||||||
|
if (!Array.isArray(entries) || entries.length !== DEFAULT_BOOTSTRAP_PLANTS.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const botanicalNames = new Set(
|
||||||
|
entries
|
||||||
|
.map((entry) => (typeof entry?.botanicalName === 'string' ? entry.botanicalName.trim().toLowerCase() : ''))
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
|
||||||
|
return DEFAULT_BOOTSTRAP_PLANTS.every((entry) => botanicalNames.has(entry.botanicalName.trim().toLowerCase()));
|
||||||
|
};
|
||||||
|
|
||||||
const seedBootstrapCatalogIfNeeded = async () => {
|
const seedBootstrapCatalogIfNeeded = async () => {
|
||||||
const existing = await getPlants(db, { limit: 1 });
|
const fullCatalog = loadFullBootstrapCatalog();
|
||||||
if (existing.length > 0) return;
|
const diagnostics = await getPlantDiagnostics(db);
|
||||||
|
|
||||||
|
if (diagnostics.totalCount > 0) {
|
||||||
|
if (fullCatalog && diagnostics.totalCount === DEFAULT_BOOTSTRAP_PLANTS.length) {
|
||||||
|
const existingEntries = await getPlants(db, { limit: DEFAULT_BOOTSTRAP_PLANTS.length + 1 });
|
||||||
|
if (isMinimalBootstrapCatalog(existingEntries) && fullCatalog.entries.length > existingEntries.length) {
|
||||||
|
await rebuildPlantsCatalog(db, fullCatalog.entries, {
|
||||||
|
source: 'bootstrap_upgrade_from_minimal_catalog',
|
||||||
|
preserveExistingIds: false,
|
||||||
|
enforceUniqueImages: false,
|
||||||
|
});
|
||||||
|
console.log(`Upgraded minimal bootstrap catalog to full catalog (${fullCatalog.entries.length} entries).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullCatalog) {
|
||||||
|
await rebuildPlantsCatalog(db, fullCatalog.entries, {
|
||||||
|
source: 'bootstrap_full_catalog',
|
||||||
|
preserveExistingIds: false,
|
||||||
|
enforceUniqueImages: false,
|
||||||
|
});
|
||||||
|
console.log(`Bootstrapped full plant catalog from ${fullCatalog.sourcePath} (${fullCatalog.entries.length} entries).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await rebuildPlantsCatalog(db, DEFAULT_BOOTSTRAP_PLANTS, {
|
await rebuildPlantsCatalog(db, DEFAULT_BOOTSTRAP_PLANTS, {
|
||||||
source: 'bootstrap',
|
source: 'bootstrap_minimal_catalog',
|
||||||
preserveExistingIds: false,
|
preserveExistingIds: false,
|
||||||
enforceUniqueImages: false,
|
enforceUniqueImages: false,
|
||||||
});
|
});
|
||||||
|
console.warn('Full bootstrap catalog was not found. Seeded minimal fallback catalog with 2 entries.');
|
||||||
};
|
};
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
@@ -479,19 +635,17 @@ app.post('/v1/scan', async (request, response) => {
|
|||||||
let modelUsed = null;
|
let modelUsed = null;
|
||||||
let modelFallbackCount = 0;
|
let modelFallbackCount = 0;
|
||||||
|
|
||||||
if (!isGuest(userId)) {
|
const [creditResult, accountSnapshot, catalogEntries] = await Promise.all([
|
||||||
creditsCharged += await consumeCreditsWithIdempotency(
|
isGuest(userId)
|
||||||
db,
|
? Promise.resolve(0)
|
||||||
userId,
|
: consumeCreditsWithIdempotency(db, userId, chargeKey('scan-primary', userId, idempotencyKey), SCAN_PRIMARY_COST),
|
||||||
chargeKey('scan-primary', userId, idempotencyKey),
|
getAccountSnapshot(db, userId),
|
||||||
SCAN_PRIMARY_COST,
|
getCachedCatalogEntries(db),
|
||||||
);
|
]);
|
||||||
}
|
creditsCharged += creditResult;
|
||||||
|
|
||||||
const accountSnapshot = await getAccountSnapshot(db, userId);
|
|
||||||
const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free';
|
const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free';
|
||||||
const catalogEntries = await getPlants(db, { limit: 500 });
|
let result = pickCatalogFallback(catalogEntries, imageUri, false, { silent: true });
|
||||||
let result = pickCatalogFallback(catalogEntries, imageUri, false);
|
|
||||||
let usedOpenAi = false;
|
let usedOpenAi = false;
|
||||||
|
|
||||||
if (isOpenAiConfigured()) {
|
if (isOpenAiConfigured()) {
|
||||||
@@ -516,7 +670,10 @@ app.post('/v1/scan', async (request, response) => {
|
|||||||
modelPath.push('openai-primary');
|
modelPath.push('openai-primary');
|
||||||
if (grounded.grounded) modelPath.push('catalog-grounded-primary');
|
if (grounded.grounded) modelPath.push('catalog-grounded-primary');
|
||||||
} else {
|
} else {
|
||||||
console.warn(`OpenAI primary identification returned null for user ${userId}`);
|
console.warn(`OpenAI primary identification returned null for user ${userId} — using catalog fallback.`, {
|
||||||
|
attemptedModels: openAiPrimary?.attemptedModels,
|
||||||
|
plant: result?.name,
|
||||||
|
});
|
||||||
modelPath.push('openai-primary-failed');
|
modelPath.push('openai-primary-failed');
|
||||||
modelPath.push('catalog-primary-fallback');
|
modelPath.push('catalog-primary-fallback');
|
||||||
}
|
}
|
||||||
@@ -565,11 +722,13 @@ app.post('/v1/scan', async (request, response) => {
|
|||||||
modelPath.push('openai-review');
|
modelPath.push('openai-review');
|
||||||
if (grounded.grounded) modelPath.push('catalog-grounded-review');
|
if (grounded.grounded) modelPath.push('catalog-grounded-review');
|
||||||
} else {
|
} else {
|
||||||
console.warn(`OpenAI review identification returned null for user ${userId}`);
|
console.warn(`OpenAI review identification returned null for user ${userId}.`, {
|
||||||
|
attemptedModels: openAiReview?.attemptedModels,
|
||||||
|
});
|
||||||
modelPath.push('openai-review-failed');
|
modelPath.push('openai-review-failed');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const reviewFallback = pickCatalogFallback(catalogEntries, `${imageUri}|review`, true);
|
const reviewFallback = pickCatalogFallback(catalogEntries, `${imageUri}|review`, true, { silent: true });
|
||||||
if (reviewFallback) {
|
if (reviewFallback) {
|
||||||
result = reviewFallback;
|
result = reviewFallback;
|
||||||
}
|
}
|
||||||
@@ -681,9 +840,46 @@ app.post('/v1/health-check', async (request, response) => {
|
|||||||
});
|
});
|
||||||
const analysis = analysisResponse?.analysis;
|
const analysis = analysisResponse?.analysis;
|
||||||
if (!analysis) {
|
if (!analysis) {
|
||||||
const error = new Error('OpenAI health check failed. Please verify API key, model, and network access.');
|
// All models in the chain failed (timeout, quota, network) — return a graceful
|
||||||
error.code = 'PROVIDER_ERROR';
|
// "unavailable" result instead of PROVIDER_ERROR so the user never sees an error alert.
|
||||||
throw error;
|
// Credits are NOT charged. Response is NOT cached so the user can retry.
|
||||||
|
console.warn('Health check analysis was null — all models returned unusable output.', {
|
||||||
|
attemptedModels: analysisResponse?.attemptedModels,
|
||||||
|
modelUsed: analysisResponse?.modelUsed,
|
||||||
|
});
|
||||||
|
const unavailableIssue = language === 'de'
|
||||||
|
? 'Die KI-Analyse ist gerade nicht verfügbar. Bitte versuche es in einem Moment erneut.'
|
||||||
|
: language === 'es'
|
||||||
|
? 'El análisis de IA no está disponible ahora. Inténtalo de nuevo en un momento.'
|
||||||
|
: 'AI analysis is temporarily unavailable. Please try again in a moment.';
|
||||||
|
const unavailableAction = language === 'de'
|
||||||
|
? 'Erneut scannen wenn die Verbindung stabil ist.'
|
||||||
|
: language === 'es'
|
||||||
|
? 'Volver a escanear cuando la conexión sea estable.'
|
||||||
|
: 'Try scanning again when your connection is stable.';
|
||||||
|
const fallbackHealthCheck = {
|
||||||
|
generatedAt: nowIso(),
|
||||||
|
overallHealthScore: 50,
|
||||||
|
status: 'watch',
|
||||||
|
likelyIssues: [{
|
||||||
|
title: language === 'de' ? 'Analyse nicht verfügbar' : language === 'es' ? 'Análisis no disponible' : 'Analysis unavailable',
|
||||||
|
confidence: 0.1,
|
||||||
|
details: unavailableIssue,
|
||||||
|
}],
|
||||||
|
actionsNow: [unavailableAction],
|
||||||
|
plan7Days: [unavailableAction],
|
||||||
|
creditsCharged: 0,
|
||||||
|
imageUri,
|
||||||
|
};
|
||||||
|
const fallbackPayload = {
|
||||||
|
healthCheck: fallbackHealthCheck,
|
||||||
|
creditsCharged: 0,
|
||||||
|
modelUsed: null,
|
||||||
|
modelFallbackCount: Math.max((analysisResponse?.attemptedModels?.length || 0) - 1, 0),
|
||||||
|
billing: await getBillingSummary(db, userId),
|
||||||
|
};
|
||||||
|
response.status(200).json(fallbackPayload);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let creditsCharged = 0;
|
let creditsCharged = 0;
|
||||||
|
|||||||
@@ -18,6 +18,28 @@ const AVAILABLE_PRODUCTS = ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_m
|
|||||||
|
|
||||||
const nowIso = () => new Date().toISOString();
|
const nowIso = () => new Date().toISOString();
|
||||||
|
|
||||||
|
const asIsoDate = (value) => {
|
||||||
|
if (value == null || value === '') return null;
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
||||||
|
}
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return new Date(value).toISOString();
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
if (/^\d+$/.test(trimmed)) {
|
||||||
|
return new Date(Number(trimmed)).toISOString();
|
||||||
|
}
|
||||||
|
const parsed = new Date(trimmed);
|
||||||
|
if (!Number.isNaN(parsed.getTime())) {
|
||||||
|
return parsed.toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const startOfUtcMonth = (date) => {
|
const startOfUtcMonth = (date) => {
|
||||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0));
|
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0));
|
||||||
};
|
};
|
||||||
@@ -73,17 +95,19 @@ const runInTransaction = async (db, worker) => {
|
|||||||
|
|
||||||
const normalizeAccountRow = (row) => {
|
const normalizeAccountRow = (row) => {
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
const now = new Date();
|
||||||
|
const { cycleStartedAt: defaultCycleStartedAt, cycleEndsAt: defaultCycleEndsAt } = getCycleBounds(now);
|
||||||
return {
|
return {
|
||||||
userId: String(row.userId),
|
userId: String(row.userId),
|
||||||
plan: row.plan === 'pro' ? 'pro' : 'free',
|
plan: row.plan === 'pro' ? 'pro' : 'free',
|
||||||
provider: typeof row.provider === 'string' && row.provider ? row.provider : 'revenuecat',
|
provider: typeof row.provider === 'string' && row.provider ? row.provider : 'revenuecat',
|
||||||
cycleStartedAt: String(row.cycleStartedAt),
|
cycleStartedAt: asIsoDate(row.cycleStartedAt) || defaultCycleStartedAt.toISOString(),
|
||||||
cycleEndsAt: String(row.cycleEndsAt),
|
cycleEndsAt: asIsoDate(row.cycleEndsAt) || defaultCycleEndsAt.toISOString(),
|
||||||
monthlyAllowance: Number(row.monthlyAllowance) || FREE_MONTHLY_CREDITS,
|
monthlyAllowance: Number(row.monthlyAllowance) || FREE_MONTHLY_CREDITS,
|
||||||
usedThisCycle: Number(row.usedThisCycle) || 0,
|
usedThisCycle: Number(row.usedThisCycle) || 0,
|
||||||
topupBalance: Number(row.topupBalance) || 0,
|
topupBalance: Number(row.topupBalance) || 0,
|
||||||
renewsAt: row.renewsAt ? String(row.renewsAt) : null,
|
renewsAt: asIsoDate(row.renewsAt),
|
||||||
updatedAt: row.updatedAt ? String(row.updatedAt) : nowIso(),
|
updatedAt: asIsoDate(row.updatedAt) || now.toISOString(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -238,25 +262,6 @@ const buildBillingSummary = (account) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const asIsoDate = (value) => {
|
|
||||||
if (value == null || value === '') return null;
|
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
||||||
return new Date(value).toISOString();
|
|
||||||
}
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
if (/^\d+$/.test(trimmed)) {
|
|
||||||
return new Date(Number(trimmed)).toISOString();
|
|
||||||
}
|
|
||||||
const parsed = new Date(trimmed);
|
|
||||||
if (!Number.isNaN(parsed.getTime())) {
|
|
||||||
return parsed.toISOString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSupportedTopupProduct = (productId) => {
|
const isSupportedTopupProduct = (productId) => {
|
||||||
return typeof productId === 'string'
|
return typeof productId === 'string'
|
||||||
&& productId.startsWith('topup_')
|
&& productId.startsWith('topup_')
|
||||||
@@ -303,6 +308,18 @@ const getValidProEntitlement = (customerInfo) => {
|
|||||||
return proEntitlement;
|
return proEntitlement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: entitlement is active but backed by a non-subscription product (e.g. a topup
|
||||||
|
// that was previously misconfigured to grant the pro entitlement). If the user also has a
|
||||||
|
// supported subscription product in their purchase history, honour the entitlement anyway.
|
||||||
|
const purchased = Array.isArray(customerInfo?.allPurchasedProductIdentifiers)
|
||||||
|
? customerInfo.allPurchasedProductIdentifiers
|
||||||
|
: [];
|
||||||
|
const hasSubscription = purchased.some((id) => SUPPORTED_SUBSCRIPTION_PRODUCTS.has(id));
|
||||||
|
if (hasSubscription) {
|
||||||
|
console.warn('[Billing] Pro entitlement backed by unsupported product but subscription found — honouring entitlement', summarizeRevenueCatCustomerInfo(customerInfo));
|
||||||
|
return proEntitlement;
|
||||||
|
}
|
||||||
|
|
||||||
console.warn('[Billing] Ignoring unsupported RevenueCat pro entitlement', summarizeRevenueCatCustomerInfo(customerInfo));
|
console.warn('[Billing] Ignoring unsupported RevenueCat pro entitlement', summarizeRevenueCatCustomerInfo(customerInfo));
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
@@ -398,6 +415,19 @@ const syncRevenueCatCustomerInfo = async (db, userId, customerInfo, options = {}
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: also check active entitlements for topup products.
|
||||||
|
// This handles cases where a topup product is misconfigured in RevenueCat
|
||||||
|
// to grant an entitlement instead of being treated as a consumable.
|
||||||
|
const rawActiveEntitlements = Object.values(customerInfo?.entitlements?.active || {});
|
||||||
|
for (const entitlement of rawActiveEntitlements) {
|
||||||
|
const productId = entitlement?.productIdentifier;
|
||||||
|
if (isSupportedTopupProduct(productId)) {
|
||||||
|
const purchaseDate = entitlement?.latestPurchaseDate || entitlement?.originalPurchaseDate;
|
||||||
|
const txId = purchaseDate ? `entitlement:${productId}:${purchaseDate}` : null;
|
||||||
|
await grantRevenueCatTopupIfNeeded(tx, account, txId, productId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
account.updatedAt = nowIso();
|
account.updatedAt = nowIso();
|
||||||
await upsertAccount(tx, account);
|
await upsertAccount(tx, account);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -107,10 +107,16 @@ const normalizeIdentifyResult = (raw, language) => {
|
|||||||
const waterIntervalRaw = getNumber(careInfoRaw.waterIntervalDays);
|
const waterIntervalRaw = getNumber(careInfoRaw.waterIntervalDays);
|
||||||
const light = getString(careInfoRaw.light);
|
const light = getString(careInfoRaw.light);
|
||||||
const temp = getString(careInfoRaw.temp);
|
const temp = getString(careInfoRaw.temp);
|
||||||
if (waterIntervalRaw == null || !light || !temp) {
|
if (waterIntervalRaw == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LIGHT_DEFAULTS = { de: 'Helles indirektes Licht', es: 'Luz indirecta brillante', en: 'Bright indirect light' };
|
||||||
|
const TEMP_DEFAULTS = { de: '15–25 °C', es: '15–25 °C', en: '59–77 °F (15–25 °C)' };
|
||||||
|
const lang = language || 'en';
|
||||||
|
const resolvedLight = (light && light !== 'Unknown') ? light : (LIGHT_DEFAULTS[lang] || LIGHT_DEFAULTS.en);
|
||||||
|
const resolvedTemp = (temp && temp !== 'Unknown') ? temp : (TEMP_DEFAULTS[lang] || TEMP_DEFAULTS.en);
|
||||||
|
|
||||||
const fallbackDescription = language === 'de'
|
const fallbackDescription = language === 'de'
|
||||||
? `${name} wurde per KI erkannt. Pflegehinweise sind unten aufgefuehrt.`
|
? `${name} wurde per KI erkannt. Pflegehinweise sind unten aufgefuehrt.`
|
||||||
: language === 'es'
|
: language === 'es'
|
||||||
@@ -124,8 +130,8 @@ const normalizeIdentifyResult = (raw, language) => {
|
|||||||
description: description || fallbackDescription,
|
description: description || fallbackDescription,
|
||||||
careInfo: {
|
careInfo: {
|
||||||
waterIntervalDays: Math.round(clamp(waterIntervalRaw, 1, 45)),
|
waterIntervalDays: Math.round(clamp(waterIntervalRaw, 1, 45)),
|
||||||
light,
|
light: resolvedLight,
|
||||||
temp,
|
temp: resolvedTemp,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -137,15 +143,16 @@ const normalizeHealthAnalysis = (raw, language) => {
|
|||||||
const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 8);
|
const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 8);
|
||||||
const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 10);
|
const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 10);
|
||||||
|
|
||||||
if (scoreRaw == null || !statusRaw || !Array.isArray(issuesRaw)) {
|
// Use safe defaults instead of returning null — bad/partial JSON falls through
|
||||||
return null;
|
// to the graceful "Uncertain analysis" fallback at line 164 rather than
|
||||||
}
|
// propagating null → PROVIDER_ERROR to the caller.
|
||||||
|
const score = scoreRaw ?? 50;
|
||||||
const status = statusRaw === 'healthy' || statusRaw === 'watch' || statusRaw === 'critical'
|
const status = (statusRaw === 'healthy' || statusRaw === 'watch' || statusRaw === 'critical')
|
||||||
? statusRaw
|
? statusRaw
|
||||||
: 'watch';
|
: 'watch';
|
||||||
|
const issuesInput = Array.isArray(issuesRaw) ? issuesRaw : [];
|
||||||
|
|
||||||
const likelyIssues = issuesRaw
|
const likelyIssues = issuesInput
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
|
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
|
||||||
const title = getString(entry.title);
|
const title = getString(entry.title);
|
||||||
@@ -168,7 +175,7 @@ const normalizeHealthAnalysis = (raw, language) => {
|
|||||||
? 'La IA no pudo extraer senales de salud estables.'
|
? 'La IA no pudo extraer senales de salud estables.'
|
||||||
: 'AI could not extract stable health signals.';
|
: 'AI could not extract stable health signals.';
|
||||||
return {
|
return {
|
||||||
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)),
|
overallHealthScore: Math.round(clamp(score, 0, 100)),
|
||||||
status,
|
status,
|
||||||
likelyIssues: [
|
likelyIssues: [
|
||||||
{
|
{
|
||||||
@@ -191,7 +198,7 @@ const normalizeHealthAnalysis = (raw, language) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)),
|
overallHealthScore: Math.round(clamp(score, 0, 100)),
|
||||||
status,
|
status,
|
||||||
likelyIssues,
|
likelyIssues,
|
||||||
actionsNow: actionsNowRaw,
|
actionsNow: actionsNowRaw,
|
||||||
@@ -215,6 +222,10 @@ const buildIdentifyPrompt = (language, mode) => {
|
|||||||
'Rules:',
|
'Rules:',
|
||||||
nameLanguageInstruction,
|
nameLanguageInstruction,
|
||||||
`- "description" and "careInfo.light" must be written in ${getLanguageLabel(language)}.`,
|
`- "description" and "careInfo.light" must be written in ${getLanguageLabel(language)}.`,
|
||||||
|
`- "careInfo.light": short light requirement in ${getLanguageLabel(language)} (e.g. "bright indirect light", "full sun", "partial shade"). Must always be a real value, never "Unknown".`,
|
||||||
|
language === 'en'
|
||||||
|
? '- "careInfo.temp": temperature range in both Celsius and Fahrenheit (e.g. "18–24 °C (64–75 °F)"). Must always be a real plant-specific value, never "Unknown".'
|
||||||
|
: '- "careInfo.temp": temperature range in Celsius (e.g. "18–24 °C"). Must always be a real plant-specific value, never "Unknown".',
|
||||||
'- "botanicalName" must use accepted Latin scientific naming and must not be invented or misspelled.',
|
'- "botanicalName" must use accepted Latin scientific naming and must not be invented or misspelled.',
|
||||||
'- If species is uncertain, prefer genus-level naming (for example: "Calathea sp.").',
|
'- If species is uncertain, prefer genus-level naming (for example: "Calathea sp.").',
|
||||||
'- "confidence" must be between 0 and 1.',
|
'- "confidence" must be between 0 and 1.',
|
||||||
@@ -305,10 +316,14 @@ const postChatCompletion = async ({ modelChain, messages, imageUri, temperature
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
|
let parsedError = {};
|
||||||
|
try { parsedError = JSON.parse(body); } catch {}
|
||||||
console.warn('OpenAI request HTTP error.', {
|
console.warn('OpenAI request HTTP error.', {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
model,
|
model,
|
||||||
endpoint: OPENAI_CHAT_COMPLETIONS_URL,
|
endpoint: OPENAI_CHAT_COMPLETIONS_URL,
|
||||||
|
openAiCode: parsedError?.error?.code,
|
||||||
|
openAiMessage: parsedError?.error?.message,
|
||||||
image: summarizeImageUri(imageUri),
|
image: summarizeImageUri(imageUri),
|
||||||
bodyPreview: body.slice(0, 300),
|
bodyPreview: body.slice(0, 300),
|
||||||
});
|
});
|
||||||
@@ -351,7 +366,7 @@ const identifyPlant = async ({ imageUri, language, mode = 'primary', plan = 'fre
|
|||||||
role: 'user',
|
role: 'user',
|
||||||
content: [
|
content: [
|
||||||
{ type: 'text', text: buildIdentifyPrompt(language, mode) },
|
{ type: 'text', text: buildIdentifyPrompt(language, mode) },
|
||||||
{ type: 'image_url', image_url: { url: imageUri } },
|
{ type: 'image_url', image_url: { url: imageUri, detail: 'low' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -411,7 +426,7 @@ const analyzePlantHealth = async ({ imageUri, language, plantContext }) => {
|
|||||||
role: 'user',
|
role: 'user',
|
||||||
content: [
|
content: [
|
||||||
{ type: 'text', text: buildHealthPrompt(language, plantContext) },
|
{ type: 'text', text: buildHealthPrompt(language, plantContext) },
|
||||||
{ type: 'image_url', image_url: { url: imageUri } },
|
{ type: 'image_url', image_url: { url: imageUri, detail: 'low' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -115,8 +115,8 @@ const applyCatalogGrounding = (aiResult, catalogEntries, language = 'en') => {
|
|||||||
description: aiResult.description || matchedEntry.description || '',
|
description: aiResult.description || matchedEntry.description || '',
|
||||||
careInfo: {
|
careInfo: {
|
||||||
waterIntervalDays: Math.max(1, Number(matchedEntry.careInfo?.waterIntervalDays) || Number(aiResult.careInfo?.waterIntervalDays) || 7),
|
waterIntervalDays: Math.max(1, Number(matchedEntry.careInfo?.waterIntervalDays) || Number(aiResult.careInfo?.waterIntervalDays) || 7),
|
||||||
light: matchedEntry.careInfo?.light || aiResult.careInfo?.light || 'Unknown',
|
light: (matchedEntry.careInfo?.light && matchedEntry.careInfo.light !== 'Unknown') ? matchedEntry.careInfo.light : (aiResult.careInfo?.light || 'Unknown'),
|
||||||
temp: matchedEntry.careInfo?.temp || aiResult.careInfo?.temp || 'Unknown',
|
temp: (matchedEntry.careInfo?.temp && matchedEntry.careInfo.temp !== 'Unknown') ? matchedEntry.careInfo.temp : (aiResult.careInfo?.temp || 'Unknown'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { mockBackendService } from './mockBackendService';
|
|||||||
import { CareInfo, Language } from '../../types';
|
import { CareInfo, Language } from '../../types';
|
||||||
import { getConfiguredBackendRootUrl } from '../../utils/backendUrl';
|
import { getConfiguredBackendRootUrl } from '../../utils/backendUrl';
|
||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 15000;
|
const REQUEST_TIMEOUT_MS = 60000;
|
||||||
|
|
||||||
const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => {
|
const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => {
|
||||||
if (status === 400) return 'BAD_REQUEST';
|
if (status === 400) return 'BAD_REQUEST';
|
||||||
@@ -304,8 +304,9 @@ export const isInsufficientCreditsError = (error: unknown): boolean => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const isNetworkError = (error: unknown): boolean => {
|
export const isNetworkError = (error: unknown): boolean => {
|
||||||
return (
|
return error instanceof BackendApiError && error.code === 'NETWORK_ERROR';
|
||||||
error instanceof BackendApiError &&
|
};
|
||||||
(error.code === 'NETWORK_ERROR' || error.code === 'TIMEOUT')
|
|
||||||
);
|
export const isTimeoutError = (error: unknown): boolean => {
|
||||||
|
return error instanceof BackendApiError && error.code === 'TIMEOUT';
|
||||||
};
|
};
|
||||||
|
|||||||
41
split_image.py
Normal file
@@ -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])
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
const DEFAULT_API_BASE_URL = 'http://localhost:3000/api';
|
|
||||||
|
|
||||||
const normalizeHttpUrl = (value?: string | null): string | null => {
|
const normalizeHttpUrl = (value?: string | null): string | null => {
|
||||||
const trimmed = String(value || '').trim();
|
const trimmed = String(value || '').trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
@@ -23,7 +21,7 @@ export const getConfiguredApiBaseUrl = (): string => {
|
|||||||
return backendBaseUrl.endsWith('/api') ? backendBaseUrl : `${backendBaseUrl}/api`;
|
return backendBaseUrl.endsWith('/api') ? backendBaseUrl : `${backendBaseUrl}/api`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return DEFAULT_API_BASE_URL;
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getConfiguredBackendRootUrl = (): string => {
|
export const getConfiguredBackendRootUrl = (): string => {
|
||||||
@@ -50,6 +48,6 @@ export const getConfiguredAssetBaseUrl = (): string => {
|
|||||||
: pathname;
|
: pathname;
|
||||||
return `${parsed.origin}${assetPath}`.replace(/\/+$/, '');
|
return `${parsed.origin}${assetPath}`.replace(/\/+$/, '');
|
||||||
} catch {
|
} catch {
|
||||||
return 'http://localhost:3000';
|
return '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ export const translations = {
|
|||||||
temp: "Temperatur",
|
temp: "Temperatur",
|
||||||
|
|
||||||
// Care Values (UI Helper)
|
// Care Values (UI Helper)
|
||||||
|
unknown: "Unbekannt",
|
||||||
waterModerate: "Mäßig",
|
waterModerate: "Mäßig",
|
||||||
waterLittle: "Wenig",
|
waterLittle: "Wenig",
|
||||||
waterEveryXDays: "Alle {0} Tage",
|
waterEveryXDays: "Alle {0} Tage",
|
||||||
@@ -367,6 +368,7 @@ registerToSave: "Sign up to save",
|
|||||||
light: "Light",
|
light: "Light",
|
||||||
temp: "Temperature",
|
temp: "Temperature",
|
||||||
|
|
||||||
|
unknown: "Unknown",
|
||||||
waterModerate: "Moderate",
|
waterModerate: "Moderate",
|
||||||
waterLittle: "Little",
|
waterLittle: "Little",
|
||||||
waterEveryXDays: "Every {0} days",
|
waterEveryXDays: "Every {0} days",
|
||||||
@@ -588,6 +590,7 @@ registerToSave: "Regístrate para guardar",
|
|||||||
light: "Luz",
|
light: "Luz",
|
||||||
temp: "Temperatura",
|
temp: "Temperatura",
|
||||||
|
|
||||||
|
unknown: "Desconocido",
|
||||||
waterModerate: "Moderado",
|
waterModerate: "Moderado",
|
||||||
waterLittle: "Poco",
|
waterLittle: "Poco",
|
||||||
waterEveryXDays: "Cada {0} días",
|
waterEveryXDays: "Cada {0} días",
|
||||||
|
|||||||