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