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');
|
||||
});
|
||||
});
|
||||
4
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "GreenLens",
|
||||
"slug": "greenlens",
|
||||
"version": "2.1.6",
|
||||
"version": "2.2.1",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
@@ -68,4 +68,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,68 +4,68 @@ import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import Constants from 'expo-constants';
|
||||
import Purchases, {
|
||||
PACKAGE_TYPE,
|
||||
PRODUCT_CATEGORY,
|
||||
PurchasesOffering,
|
||||
PurchasesPackage,
|
||||
PurchasesStoreProduct,
|
||||
} from 'react-native-purchases';
|
||||
import Purchases, {
|
||||
PACKAGE_TYPE,
|
||||
PRODUCT_CATEGORY,
|
||||
PurchasesOffering,
|
||||
PurchasesPackage,
|
||||
PurchasesStoreProduct,
|
||||
} from 'react-native-purchases';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { useColors } from '../../constants/Colors';
|
||||
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
|
||||
import { Language } from '../../types';
|
||||
import { PurchaseProductId } from '../../services/backend/contracts';
|
||||
|
||||
type SubscriptionProductId = 'monthly_pro' | 'yearly_pro';
|
||||
type TopupProductId = Extract<PurchaseProductId, 'topup_small' | 'topup_medium' | 'topup_large'>;
|
||||
type SubscriptionPackages = Partial<Record<SubscriptionProductId, PurchasesPackage>>;
|
||||
type TopupProducts = Partial<Record<TopupProductId, PurchasesStoreProduct>>;
|
||||
|
||||
const isMatchingPackage = (
|
||||
pkg: PurchasesPackage,
|
||||
productId: SubscriptionProductId,
|
||||
expectedPackageType: PACKAGE_TYPE,
|
||||
) => {
|
||||
return (
|
||||
pkg.product.identifier === productId
|
||||
|| pkg.identifier === productId
|
||||
|| pkg.packageType === expectedPackageType
|
||||
);
|
||||
};
|
||||
|
||||
const resolveSubscriptionPackages = (offering: PurchasesOffering | null): SubscriptionPackages => {
|
||||
if (!offering) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const availablePackages = [
|
||||
offering.monthly,
|
||||
offering.annual,
|
||||
...offering.availablePackages,
|
||||
].filter((value): value is PurchasesPackage => Boolean(value));
|
||||
|
||||
return {
|
||||
monthly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'monthly_pro', PACKAGE_TYPE.MONTHLY)),
|
||||
yearly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'yearly_pro', PACKAGE_TYPE.ANNUAL)),
|
||||
};
|
||||
};
|
||||
|
||||
const summarizeOfferingPackages = (offering: PurchasesOffering | null) => {
|
||||
if (!offering) {
|
||||
return { identifier: null, packages: [] as Array<Record<string, string | null>> };
|
||||
}
|
||||
|
||||
return {
|
||||
identifier: offering.identifier,
|
||||
packages: offering.availablePackages.map((pkg) => ({
|
||||
identifier: pkg.identifier,
|
||||
packageType: pkg.packageType,
|
||||
productIdentifier: pkg.product.identifier,
|
||||
priceString: pkg.product.priceString,
|
||||
})),
|
||||
};
|
||||
};
|
||||
import { Language } from '../../types';
|
||||
import { PurchaseProductId } from '../../services/backend/contracts';
|
||||
|
||||
type SubscriptionProductId = 'monthly_pro' | 'yearly_pro';
|
||||
type TopupProductId = Extract<PurchaseProductId, 'topup_small' | 'topup_medium' | 'topup_large'>;
|
||||
type SubscriptionPackages = Partial<Record<SubscriptionProductId, PurchasesPackage>>;
|
||||
type TopupProducts = Partial<Record<TopupProductId, PurchasesStoreProduct>>;
|
||||
|
||||
const isMatchingPackage = (
|
||||
pkg: PurchasesPackage,
|
||||
productId: SubscriptionProductId,
|
||||
expectedPackageType: PACKAGE_TYPE,
|
||||
) => {
|
||||
return (
|
||||
pkg.product.identifier === productId
|
||||
|| pkg.identifier === productId
|
||||
|| pkg.packageType === expectedPackageType
|
||||
);
|
||||
};
|
||||
|
||||
const resolveSubscriptionPackages = (offering: PurchasesOffering | null): SubscriptionPackages => {
|
||||
if (!offering) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const availablePackages = [
|
||||
offering.monthly,
|
||||
offering.annual,
|
||||
...offering.availablePackages,
|
||||
].filter((value): value is PurchasesPackage => Boolean(value));
|
||||
|
||||
return {
|
||||
monthly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'monthly_pro', PACKAGE_TYPE.MONTHLY)),
|
||||
yearly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'yearly_pro', PACKAGE_TYPE.ANNUAL)),
|
||||
};
|
||||
};
|
||||
|
||||
const summarizeOfferingPackages = (offering: PurchasesOffering | null) => {
|
||||
if (!offering) {
|
||||
return { identifier: null, packages: [] as Array<Record<string, string | null>> };
|
||||
}
|
||||
|
||||
return {
|
||||
identifier: offering.identifier,
|
||||
packages: offering.availablePackages.map((pkg) => ({
|
||||
identifier: pkg.identifier,
|
||||
packageType: pkg.packageType,
|
||||
productIdentifier: pkg.product.identifier,
|
||||
priceString: pkg.product.priceString,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const getBillingCopy = (language: Language) => {
|
||||
if (language === 'de') {
|
||||
@@ -81,10 +81,10 @@ const getBillingCopy = (language: Language) => {
|
||||
freePlanName: 'Free',
|
||||
freePlanPrice: '0 EUR / Monat',
|
||||
proPlanName: 'Pro',
|
||||
proPlanPrice: '4.99 EUR / Monat',
|
||||
proPlanPrice: '4,99 € / Monat',
|
||||
proBadgeText: 'EMPFOHLEN',
|
||||
proYearlyPlanName: 'Pro',
|
||||
proYearlyPlanPrice: '39.99 EUR / Jahr',
|
||||
proYearlyPlanPrice: '39,99 € / Jahr',
|
||||
proYearlyBadgeText: 'SPAREN',
|
||||
proBenefits: [
|
||||
'250 Credits jeden Monat',
|
||||
@@ -209,7 +209,7 @@ const getBillingCopy = (language: Language) => {
|
||||
|
||||
export default function BillingScreen() {
|
||||
const router = useRouter();
|
||||
const { isDarkMode, language, billingSummary, isLoadingBilling, simulatePurchase, simulateWebhookEvent, syncRevenueCatState, colorPalette, session } = useApp();
|
||||
const { isDarkMode, language, billingSummary, isLoadingBilling, simulatePurchase, simulateWebhookEvent, syncRevenueCatState, colorPalette, session } = useApp();
|
||||
const colors = useColors(isDarkMode, colorPalette);
|
||||
const copy = getBillingCopy(language);
|
||||
const isExpoGo = Constants.appOwnership === 'expo';
|
||||
@@ -217,8 +217,8 @@ export default function BillingScreen() {
|
||||
const [subModalVisible, setSubModalVisible] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [storeReady, setStoreReady] = useState(isExpoGo);
|
||||
const [subscriptionPackages, setSubscriptionPackages] = useState<SubscriptionPackages>({});
|
||||
const [topupProducts, setTopupProducts] = useState<TopupProducts>({});
|
||||
const [subscriptionPackages, setSubscriptionPackages] = useState<SubscriptionPackages>({});
|
||||
const [topupProducts, setTopupProducts] = useState<TopupProducts>({});
|
||||
|
||||
// Cancel Flow State
|
||||
const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none');
|
||||
@@ -243,13 +243,13 @@ export default function BillingScreen() {
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const currentOffering = offerings.current;
|
||||
const resolvedPackages = resolveSubscriptionPackages(currentOffering);
|
||||
if (!resolvedPackages.monthly_pro || !resolvedPackages.yearly_pro) {
|
||||
console.warn('[Billing] RevenueCat offering missing expected subscription packages', summarizeOfferingPackages(currentOffering));
|
||||
}
|
||||
|
||||
setSubscriptionPackages(resolvedPackages);
|
||||
const currentOffering = offerings.current;
|
||||
const resolvedPackages = resolveSubscriptionPackages(currentOffering);
|
||||
if (!resolvedPackages.monthly_pro || !resolvedPackages.yearly_pro) {
|
||||
console.warn('[Billing] RevenueCat offering missing expected subscription packages', summarizeOfferingPackages(currentOffering));
|
||||
}
|
||||
|
||||
setSubscriptionPackages(resolvedPackages);
|
||||
|
||||
setTopupProducts({
|
||||
topup_small: topups.find((product) => product.identifier === 'topup_small'),
|
||||
@@ -278,15 +278,15 @@ export default function BillingScreen() {
|
||||
const monthlyPrice = monthlyPackage?.product.priceString ?? copy.proPlanPrice;
|
||||
const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPrice;
|
||||
|
||||
const topupLabels = useMemo(() => ({
|
||||
topup_small: topupProducts.topup_small ? `25 Credits - ${topupProducts.topup_small.priceString}` : copy.topupSmall,
|
||||
topup_medium: topupProducts.topup_medium ? `120 Credits - ${topupProducts.topup_medium.priceString}` : copy.topupMedium,
|
||||
topup_large: topupProducts.topup_large ? `300 Credits - ${topupProducts.topup_large.priceString}` : copy.topupLarge,
|
||||
}), [copy.topupLarge, copy.topupMedium, copy.topupSmall, topupProducts.topup_large, topupProducts.topup_medium, topupProducts.topup_small]);
|
||||
|
||||
const openAppleSubscriptions = async () => {
|
||||
await Linking.openURL('itms-apps://apps.apple.com/account/subscriptions');
|
||||
};
|
||||
const topupLabels = useMemo(() => ({
|
||||
topup_small: topupProducts.topup_small ? `25 Credits - ${topupProducts.topup_small.priceString}` : copy.topupSmall,
|
||||
topup_medium: topupProducts.topup_medium ? `120 Credits - ${topupProducts.topup_medium.priceString}` : copy.topupMedium,
|
||||
topup_large: topupProducts.topup_large ? `300 Credits - ${topupProducts.topup_large.priceString}` : copy.topupLarge,
|
||||
}), [copy.topupLarge, copy.topupMedium, copy.topupSmall, topupProducts.topup_large, topupProducts.topup_medium, topupProducts.topup_small]);
|
||||
|
||||
const openAppleSubscriptions = async () => {
|
||||
await Linking.openURL('itms-apps://apps.apple.com/account/subscriptions');
|
||||
};
|
||||
|
||||
const handlePurchase = async (productId: PurchaseProductId) => {
|
||||
setIsUpdating(true);
|
||||
@@ -296,35 +296,35 @@ export default function BillingScreen() {
|
||||
await simulatePurchase(productId);
|
||||
} else {
|
||||
if (productId === 'monthly_pro' || productId === 'yearly_pro') {
|
||||
if (planId === 'pro') {
|
||||
await openAppleSubscriptions();
|
||||
setSubModalVisible(false);
|
||||
return;
|
||||
}
|
||||
const selectedPackage = productId === 'monthly_pro' ? monthlyPackage : yearlyPackage;
|
||||
const latestOffering = !selectedPackage
|
||||
? await Purchases.getOfferings().then((offerings) => offerings.current)
|
||||
: null;
|
||||
if (!selectedPackage) {
|
||||
console.warn('[Billing] Purchase blocked because subscription package was not resolved', {
|
||||
productId,
|
||||
offering: summarizeOfferingPackages(latestOffering),
|
||||
});
|
||||
if (planId === 'pro') {
|
||||
await openAppleSubscriptions();
|
||||
setSubModalVisible(false);
|
||||
return;
|
||||
}
|
||||
const selectedPackage = productId === 'monthly_pro' ? monthlyPackage : yearlyPackage;
|
||||
const latestOffering = !selectedPackage
|
||||
? await Purchases.getOfferings().then((offerings) => offerings.current)
|
||||
: null;
|
||||
if (!selectedPackage) {
|
||||
console.warn('[Billing] Purchase blocked because subscription package was not resolved', {
|
||||
productId,
|
||||
offering: summarizeOfferingPackages(latestOffering),
|
||||
});
|
||||
throw new Error('Abo-Paket konnte nicht geladen werden. Bitte RevenueCat Offering prüfen.');
|
||||
}
|
||||
await Purchases.purchasePackage(selectedPackage);
|
||||
// Derive plan locally from RevenueCat — backend sync via webhook comes later (Step 3)
|
||||
const customerInfo = await Purchases.getCustomerInfo();
|
||||
await syncRevenueCatState(customerInfo as any, 'subscription_purchase');
|
||||
} else {
|
||||
await syncRevenueCatState(customerInfo as any, 'subscription_purchase');
|
||||
} else {
|
||||
const selectedProduct = topupProducts[productId];
|
||||
if (!selectedProduct) {
|
||||
throw new Error('Top-up Produkt konnte nicht geladen werden. Bitte Store-Produkt IDs prüfen.');
|
||||
}
|
||||
await Purchases.purchaseStoreProduct(selectedProduct);
|
||||
const customerInfo = await Purchases.getCustomerInfo();
|
||||
await syncRevenueCatState(customerInfo as any, 'topup_purchase');
|
||||
}
|
||||
const customerInfo = await Purchases.getCustomerInfo();
|
||||
await syncRevenueCatState(customerInfo as any, 'topup_purchase');
|
||||
}
|
||||
}
|
||||
setSubModalVisible(false);
|
||||
} catch (e) {
|
||||
@@ -335,6 +335,15 @@ export default function BillingScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
// RevenueCat error code 7 = PRODUCT_ALREADY_PURCHASED — the Apple ID already
|
||||
// owns this subscription on a different GreenLens account. Silently dismiss;
|
||||
// the current account stays free. The user can restore via "Käufe wiederherstellen".
|
||||
const rcErrorCode = typeof e === 'object' && e !== null ? (e as Record<string, unknown>).code : undefined;
|
||||
if (rcErrorCode === 7) {
|
||||
setSubModalVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Payment failed', e);
|
||||
Alert.alert('Unerwarteter Fehler', msg);
|
||||
} finally {
|
||||
@@ -346,8 +355,8 @@ export default function BillingScreen() {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
if (!isExpoGo) {
|
||||
const customerInfo = await Purchases.restorePurchases();
|
||||
await syncRevenueCatState(customerInfo as any, 'restore');
|
||||
const customerInfo = await Purchases.restorePurchases();
|
||||
await syncRevenueCatState(customerInfo as any, 'restore');
|
||||
}
|
||||
Alert.alert(copy.restorePurchases, '✓');
|
||||
} catch (e) {
|
||||
@@ -358,11 +367,11 @@ export default function BillingScreen() {
|
||||
};
|
||||
|
||||
const handleDowngrade = async () => {
|
||||
if (planId === 'free') return;
|
||||
if (!isExpoGo) {
|
||||
await openAppleSubscriptions();
|
||||
return;
|
||||
}
|
||||
if (planId === 'free') return;
|
||||
if (!isExpoGo) {
|
||||
await openAppleSubscriptions();
|
||||
return;
|
||||
}
|
||||
// Expo Go / dev only: simulate cancel flow
|
||||
setCancelStep('survey');
|
||||
};
|
||||
@@ -478,11 +487,11 @@ export default function BillingScreen() {
|
||||
</View>
|
||||
|
||||
<View style={[styles.legalLinksRow, { marginTop: 16 }]}>
|
||||
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}>
|
||||
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}>
|
||||
<Text style={[styles.legalLink, { color: colors.primary }]}>Privacy Policy</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.legalSep, { color: colors.textMuted }]}> · </Text>
|
||||
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/terms')}>
|
||||
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/terms')}>
|
||||
<Text style={[styles.legalLink, { color: colors.primary }]}>Terms of Use</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -532,11 +541,11 @@ export default function BillingScreen() {
|
||||
))}
|
||||
</View>
|
||||
<View style={[styles.legalLinksRow, { marginTop: 12 }]}>
|
||||
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}>
|
||||
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}>
|
||||
<Text style={[styles.legalLink, { color: colors.primary }]}>Privacy Policy</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.legalSep, { color: colors.textMuted }]}> · </Text>
|
||||
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/terms')}>
|
||||
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/terms')}>
|
||||
<Text style={[styles.legalLink, { color: colors.primary }]}>Terms of Use</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -589,10 +598,10 @@ export default function BillingScreen() {
|
||||
styles.planOption,
|
||||
{ borderColor: colors.border },
|
||||
planId === 'pro' && { borderColor: colors.primary, backgroundColor: colors.primary + '10' }
|
||||
]}
|
||||
onPress={() => handlePurchase('monthly_pro')}
|
||||
disabled={isUpdating || !storeReady}
|
||||
>
|
||||
]}
|
||||
onPress={() => handlePurchase('monthly_pro')}
|
||||
disabled={isUpdating || !storeReady}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<View style={styles.planHeaderRow}>
|
||||
<Text style={[styles.planName, { color: colors.text }]}>{copy.proPlanName}</Text>
|
||||
@@ -647,11 +656,11 @@ export default function BillingScreen() {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.legalLinksRow}>
|
||||
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}>
|
||||
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}>
|
||||
<Text style={[styles.legalLink, { color: colors.primary }]}>Privacy Policy</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.legalSep, { color: colors.textMuted }]}> · </Text>
|
||||
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/terms')}>
|
||||
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/terms')}>
|
||||
<Text style={[styles.legalLink, { color: colors.primary }]}>Terms of Use</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as ImageManipulator from 'expo-image-manipulator';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { usePostHog } from 'posthog-react-native';
|
||||
import { useApp } from '../context/AppContext';
|
||||
@@ -14,7 +15,7 @@ import { useColors } from '../constants/Colors';
|
||||
import { PlantRecognitionService } from '../services/plantRecognitionService';
|
||||
import { IdentificationResult } from '../types';
|
||||
import { ResultCard } from '../components/ResultCard';
|
||||
import { backendApiClient, isInsufficientCreditsError, isNetworkError } from '../services/backend/backendApiClient';
|
||||
import { backendApiClient, isInsufficientCreditsError, isNetworkError, isTimeoutError } from '../services/backend/backendApiClient';
|
||||
import { isBackendApiError } from '../services/backend/contracts';
|
||||
import { createIdempotencyKey } from '../utils/idempotency';
|
||||
|
||||
@@ -33,6 +34,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
|
||||
genericErrorMessage: 'Analyse fehlgeschlagen.',
|
||||
noConnectionTitle: 'Keine Verbindung',
|
||||
noConnectionMessage: 'Keine Verbindung zum Server. Bitte prüfe deine Internetverbindung und versuche es erneut.',
|
||||
timeoutTitle: 'Scan zu langsam',
|
||||
timeoutMessage: 'Die Analyse hat zu lange gedauert. Bitte erneut versuchen.',
|
||||
retryLabel: 'Erneut versuchen',
|
||||
providerErrorMessage: 'KI-Scan gerade nicht verfügbar. Bitte versuche es erneut.',
|
||||
healthProviderErrorMessage: 'KI-Health-Check gerade nicht verfügbar. Bitte versuche es erneut.',
|
||||
@@ -55,6 +58,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
|
||||
genericErrorMessage: 'Analisis fallido.',
|
||||
noConnectionTitle: 'Sin conexión',
|
||||
noConnectionMessage: 'Sin conexión al servidor. Comprueba tu internet e inténtalo de nuevo.',
|
||||
timeoutTitle: 'Escaneo lento',
|
||||
timeoutMessage: 'El análisis tardó demasiado. Inténtalo de nuevo.',
|
||||
retryLabel: 'Reintentar',
|
||||
providerErrorMessage: 'Escaneo IA no disponible ahora. Inténtalo de nuevo.',
|
||||
healthProviderErrorMessage: 'Health-check IA no disponible ahora. Inténtalo de nuevo.',
|
||||
@@ -76,6 +81,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
|
||||
genericErrorMessage: 'Analysis failed.',
|
||||
noConnectionTitle: 'No connection',
|
||||
noConnectionMessage: 'Could not reach the server. Check your internet connection and try again.',
|
||||
timeoutTitle: 'Scan Too Slow',
|
||||
timeoutMessage: 'Analysis took too long. Please try again.',
|
||||
retryLabel: 'Try again',
|
||||
providerErrorMessage: 'AI scan is currently unavailable. Please try again.',
|
||||
healthProviderErrorMessage: 'AI health check is currently unavailable. Please try again.',
|
||||
@@ -169,6 +176,20 @@ export default function ScannerScreen() {
|
||||
};
|
||||
}, [isAnalyzing, scanLineProgress, scanPulse]);
|
||||
|
||||
const resizeForAnalysis = async (uri: string): Promise<string> => {
|
||||
if (uri.startsWith('data:')) return uri;
|
||||
try {
|
||||
const result = await ImageManipulator.manipulateAsync(
|
||||
uri,
|
||||
[{ resize: { width: 1024 } }],
|
||||
{ compress: 0.6, format: ImageManipulator.SaveFormat.JPEG, base64: true },
|
||||
);
|
||||
return result.base64 ? `data:image/jpeg;base64,${result.base64}` : result.uri;
|
||||
} catch {
|
||||
return uri;
|
||||
}
|
||||
};
|
||||
|
||||
const analyzeImage = async (imageUri: string, galleryImageUri?: string) => {
|
||||
if (isAnalyzing) return;
|
||||
|
||||
@@ -295,6 +316,15 @@ export default function ScannerScreen() {
|
||||
},
|
||||
],
|
||||
);
|
||||
} else if (isTimeoutError(error)) {
|
||||
Alert.alert(
|
||||
billingCopy.timeoutTitle,
|
||||
billingCopy.timeoutMessage,
|
||||
[
|
||||
{ text: billingCopy.dismiss, style: 'cancel' },
|
||||
{ text: billingCopy.retryLabel, onPress: () => analyzeImage(imageUri, galleryImageUri) },
|
||||
],
|
||||
);
|
||||
} else if (isNetworkError(error)) {
|
||||
Alert.alert(
|
||||
billingCopy.noConnectionTitle,
|
||||
@@ -327,7 +357,7 @@ export default function ScannerScreen() {
|
||||
const takePicture = async () => {
|
||||
if (!cameraRef.current || isAnalyzing) return;
|
||||
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
const photo = await cameraRef.current.takePictureAsync({ base64: true, quality: 0.7 });
|
||||
const photo = await cameraRef.current.takePictureAsync({ base64: true, quality: 0.5 });
|
||||
if (photo) {
|
||||
const analysisUri = photo.base64
|
||||
? `data:image/jpeg;base64,${photo.base64}`
|
||||
@@ -343,17 +373,14 @@ export default function ScannerScreen() {
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ['images'],
|
||||
quality: 0.7,
|
||||
base64: true,
|
||||
quality: 1,
|
||||
base64: false,
|
||||
});
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
const asset = result.assets[0];
|
||||
const uri = asset.base64
|
||||
? `data:image/jpeg;base64,${asset.base64}`
|
||||
: asset.uri;
|
||||
|
||||
setSelectedImage(uri);
|
||||
analyzeImage(uri, asset.uri || uri);
|
||||
const analysisUri = await resizeForAnalysis(asset.uri);
|
||||
setSelectedImage(asset.uri);
|
||||
analyzeImage(analysisUri, asset.uri);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -99,9 +99,9 @@ export const ResultCard: React.FC<ResultCardProps> = ({
|
||||
|
||||
<View style={styles.careGrid}>
|
||||
{[
|
||||
{ icon: 'water' as const, label: t.water, value: result.careInfo.waterIntervalDays <= 7 ? t.waterModerate : t.waterLittle, color: colors.info, bg: colors.infoSoft },
|
||||
{ icon: 'sunny' as const, label: t.light, value: result.careInfo.light, color: colors.warning, bg: colors.warningSoft },
|
||||
{ icon: 'thermometer' as const, label: t.temp, value: result.careInfo.temp, color: colors.danger, bg: colors.dangerSoft },
|
||||
{ icon: 'water' as const, label: t.water, value: t.waterEveryXDays.replace('{0}', result.careInfo.waterIntervalDays.toString()), color: colors.info, bg: colors.infoSoft },
|
||||
{ icon: 'sunny' as const, label: t.light, value: (result.careInfo.light && result.careInfo.light !== 'Unknown') ? result.careInfo.light : t.unknown, color: colors.warning, bg: colors.warningSoft },
|
||||
{ icon: 'thermometer' as const, label: t.temp, value: (result.careInfo.temp && result.careInfo.temp !== 'Unknown') ? result.careInfo.temp : t.unknown, color: colors.danger, bg: colors.dangerSoft },
|
||||
].map((item) => (
|
||||
<View key={item.label} style={[styles.careCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<View style={[styles.careIcon, { backgroundColor: item.bg }]}>
|
||||
@@ -118,8 +118,8 @@ export const ResultCard: React.FC<ResultCardProps> = ({
|
||||
<Text style={[styles.detailsTitle, { color: colors.textSecondary }]}>{t.detailedCare}</Text>
|
||||
{[
|
||||
{ text: t.careTextWater.replace('{0}', result.careInfo.waterIntervalDays.toString()), color: colors.success },
|
||||
{ text: t.careTextLight.replace('{0}', result.careInfo.light), color: colors.warning },
|
||||
{ text: t.careTextTemp.replace('{0}', result.careInfo.temp), color: colors.danger },
|
||||
{ text: t.careTextLight.replace('{0}', (result.careInfo.light && result.careInfo.light !== 'Unknown') ? result.careInfo.light : t.unknown), color: colors.warning },
|
||||
{ text: t.careTextTemp.replace('{0}', (result.careInfo.temp && result.careInfo.temp !== 'Unknown') ? result.careInfo.temp : t.unknown), color: colors.danger },
|
||||
].map((item, i) => (
|
||||
<View key={i} style={styles.detailRow}>
|
||||
<View style={[styles.detailDot, { backgroundColor: item.color }]} />
|
||||
|
||||
@@ -1208,14 +1208,291 @@ h3 {
|
||||
margin-bottom: var(--s2);
|
||||
}
|
||||
|
||||
.support-faq-item p {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
RESPONSIVE
|
||||
============================================= */
|
||||
@media (max-width: 1024px) {
|
||||
.support-faq-item p {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
COMPARISON PAGES
|
||||
============================================= */
|
||||
.comparison-page {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(86, 160, 116, 0.16), transparent 26%),
|
||||
linear-gradient(180deg, var(--cream) 0%, var(--white) 100%);
|
||||
}
|
||||
|
||||
.comparison-hero {
|
||||
background:
|
||||
linear-gradient(135deg, rgba(13, 22, 15, 0.96) 0%, rgba(28, 46, 33, 0.92) 45%, rgba(42, 92, 63, 0.86) 100%);
|
||||
color: var(--cream);
|
||||
padding: 11rem 0 5rem;
|
||||
}
|
||||
|
||||
.comparison-hero-grid,
|
||||
.comparison-context-grid,
|
||||
.comparison-fit-grid,
|
||||
.comparison-links-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 0.8fr;
|
||||
gap: var(--s4);
|
||||
}
|
||||
|
||||
.comparison-hero-copy h1 {
|
||||
max-width: 12ch;
|
||||
margin-bottom: var(--s3);
|
||||
}
|
||||
|
||||
.comparison-lead,
|
||||
.comparison-disclaimer,
|
||||
.comparison-context-card p,
|
||||
.comparison-thesis-copy p,
|
||||
.comparison-row-verdict,
|
||||
.comparison-faq-card p,
|
||||
.comparison-link-card p,
|
||||
.comparison-scenario-copy p {
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.comparison-lead {
|
||||
max-width: 700px;
|
||||
color: rgba(244, 241, 232, 0.86);
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.comparison-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s2);
|
||||
margin: var(--s4) 0 var(--s3);
|
||||
}
|
||||
|
||||
.comparison-disclaimer,
|
||||
.comparison-verified {
|
||||
font-size: 0.82rem;
|
||||
color: rgba(244, 241, 232, 0.72);
|
||||
}
|
||||
|
||||
.comparison-hero-card,
|
||||
.comparison-context-card,
|
||||
.comparison-pain-card,
|
||||
.comparison-thesis-card,
|
||||
.comparison-fit-card,
|
||||
.comparison-scenario-card,
|
||||
.comparison-faq-card,
|
||||
.comparison-link-card,
|
||||
.comparison-row {
|
||||
border-radius: var(--r-lg);
|
||||
box-shadow: 0 24px 60px rgba(19, 31, 22, 0.08);
|
||||
}
|
||||
|
||||
.comparison-hero-card {
|
||||
background: rgba(244, 241, 232, 0.08);
|
||||
border: 1px solid rgba(244, 241, 232, 0.12);
|
||||
padding: var(--s4);
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.comparison-hero-card h2 {
|
||||
font-size: clamp(1.55rem, 2.2vw, 2.2rem);
|
||||
margin-bottom: var(--s3);
|
||||
}
|
||||
|
||||
.comparison-card-label,
|
||||
.comparison-mini-label {
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.comparison-card-label {
|
||||
color: var(--green-light);
|
||||
margin-bottom: var(--s2);
|
||||
}
|
||||
|
||||
.comparison-mini-label {
|
||||
color: var(--accent);
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
.comparison-bullet-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.comparison-bullet-list li {
|
||||
position: relative;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.comparison-bullet-list li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0.7rem;
|
||||
left: 0;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.comparison-bullet-list--dark li::before {
|
||||
background: var(--green-mid);
|
||||
}
|
||||
|
||||
.comparison-context,
|
||||
.comparison-theses,
|
||||
.comparison-table-section,
|
||||
.comparison-fit,
|
||||
.comparison-emergency,
|
||||
.comparison-faq,
|
||||
.comparison-links {
|
||||
padding: var(--s12) 0;
|
||||
}
|
||||
|
||||
.comparison-context-card,
|
||||
.comparison-pain-card,
|
||||
.comparison-thesis-card,
|
||||
.comparison-fit-card,
|
||||
.comparison-scenario-card,
|
||||
.comparison-faq-card,
|
||||
.comparison-link-card {
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
border: 1px solid rgba(19, 31, 22, 0.08);
|
||||
padding: var(--s4);
|
||||
}
|
||||
|
||||
.comparison-context-card h2,
|
||||
.comparison-fit-card h2,
|
||||
.comparison-link-card h3 {
|
||||
margin-bottom: var(--s2);
|
||||
}
|
||||
|
||||
.comparison-context-card--accent,
|
||||
.comparison-fit-card--greenlens {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(86, 160, 116, 0.12) 0%, rgba(255, 255, 255, 0.96) 100%);
|
||||
}
|
||||
|
||||
.comparison-section-head {
|
||||
max-width: 720px;
|
||||
margin-bottom: var(--s4);
|
||||
}
|
||||
|
||||
.comparison-section-head h2 {
|
||||
color: var(--dark);
|
||||
}
|
||||
|
||||
.comparison-pain-grid,
|
||||
.comparison-scenario-grid,
|
||||
.comparison-faq-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.comparison-pain-card {
|
||||
background: var(--dark);
|
||||
color: var(--cream);
|
||||
}
|
||||
|
||||
.comparison-pain-card h3,
|
||||
.comparison-thesis-card h3,
|
||||
.comparison-scenario-card h3,
|
||||
.comparison-faq-card h3 {
|
||||
margin-bottom: var(--s2);
|
||||
}
|
||||
|
||||
.comparison-thesis-copy,
|
||||
.comparison-scenario-copy {
|
||||
display: grid;
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.comparison-table {
|
||||
display: grid;
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.comparison-table-header {
|
||||
display: grid;
|
||||
grid-template-columns: 0.75fr 1fr 1fr;
|
||||
gap: var(--s3);
|
||||
padding: 0 var(--s2);
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.comparison-row {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(19, 31, 22, 0.08);
|
||||
display: grid;
|
||||
grid-template-columns: 0.75fr 1fr 1fr;
|
||||
gap: var(--s3);
|
||||
padding: var(--s3);
|
||||
}
|
||||
|
||||
.comparison-row-title {
|
||||
font-family: var(--display);
|
||||
font-size: 1.4rem;
|
||||
color: var(--dark);
|
||||
}
|
||||
|
||||
.comparison-cell {
|
||||
padding: var(--s3);
|
||||
border-radius: var(--r-md);
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.comparison-cell--greenlens {
|
||||
background: rgba(86, 160, 116, 0.12);
|
||||
border: 1px solid rgba(86, 160, 116, 0.18);
|
||||
}
|
||||
|
||||
.comparison-cell--competitor {
|
||||
background: rgba(19, 31, 22, 0.05);
|
||||
border: 1px solid rgba(19, 31, 22, 0.08);
|
||||
}
|
||||
|
||||
.comparison-row-verdict {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 0.2rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.comparison-links-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.comparison-link-card {
|
||||
display: block;
|
||||
transition: transform var(--t), box-shadow var(--t);
|
||||
}
|
||||
|
||||
.comparison-link-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 50px rgba(19, 31, 22, 0.12);
|
||||
}
|
||||
|
||||
.comparison-link-card--support {
|
||||
background: var(--dark);
|
||||
color: var(--cream);
|
||||
}
|
||||
|
||||
.comparison-link-card--support .comparison-mini-label,
|
||||
.comparison-link-card--support p {
|
||||
color: rgba(244, 241, 232, 0.76);
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
RESPONSIVE
|
||||
============================================= */
|
||||
@media (max-width: 1024px) {
|
||||
.hero .container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -1266,13 +1543,29 @@ h3 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--s6);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.footer-inner {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--s6);
|
||||
}
|
||||
|
||||
.comparison-hero-grid,
|
||||
.comparison-context-grid,
|
||||
.comparison-fit-grid,
|
||||
.comparison-links-grid,
|
||||
.comparison-pain-grid,
|
||||
.comparison-scenario-grid,
|
||||
.comparison-faq-grid,
|
||||
.comparison-table-header,
|
||||
.comparison-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.comparison-row-title {
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.nav-links {
|
||||
display: none;
|
||||
}
|
||||
@@ -1315,8 +1608,34 @@ h3 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.support-grid,
|
||||
.support-faq-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.support-grid,
|
||||
.support-faq-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.comparison-hero {
|
||||
padding-top: 9rem;
|
||||
}
|
||||
|
||||
.comparison-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.comparison-pain-grid,
|
||||
.comparison-scenario-grid,
|
||||
.comparison-faq-grid,
|
||||
.comparison-links-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.comparison-context,
|
||||
.comparison-theses,
|
||||
.comparison-table-section,
|
||||
.comparison-fit,
|
||||
.comparison-emergency,
|
||||
.comparison-faq,
|
||||
.comparison-links {
|
||||
padding: var(--s8) 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ const CONTENT = {
|
||||
contactLabel: 'Kontakt',
|
||||
registryLabel: 'Register',
|
||||
vatLabel: 'USt-ID',
|
||||
note: 'Vor der Veroeffentlichung muessen alle rechtlichen Angaben mit den echten Firmendaten ersetzt werden.',
|
||||
},
|
||||
en: {
|
||||
title: 'Imprint',
|
||||
@@ -22,7 +21,6 @@ const CONTENT = {
|
||||
contactLabel: 'Contact',
|
||||
registryLabel: 'Registry',
|
||||
vatLabel: 'VAT ID',
|
||||
note: 'Replace all legal placeholders with your real company details before publishing the site.',
|
||||
},
|
||||
es: {
|
||||
title: 'Aviso Legal',
|
||||
@@ -32,7 +30,6 @@ const CONTENT = {
|
||||
contactLabel: 'Contacto',
|
||||
registryLabel: 'Registro',
|
||||
vatLabel: 'IVA',
|
||||
note: 'Sustituye todos los marcadores legales por tus datos reales antes de publicar el sitio.',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -47,9 +44,9 @@ export default function ImprintPage() {
|
||||
<p>
|
||||
<strong>{c.companyLabel}:</strong> {siteConfig.company.legalName}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{c.addressLabel}:</strong> {siteConfig.company.addressLine1}
|
||||
</p>
|
||||
{siteConfig.company.addressLine1 ? (
|
||||
<p><strong>{c.addressLabel}:</strong> {siteConfig.company.addressLine1}</p>
|
||||
) : null}
|
||||
{siteConfig.company.addressLine2 ? <p>{siteConfig.company.addressLine2}</p> : null}
|
||||
<p>{siteConfig.company.country}</p>
|
||||
<p>
|
||||
@@ -58,13 +55,12 @@ export default function ImprintPage() {
|
||||
<p>
|
||||
<strong>{c.contactLabel}:</strong> <a href={`mailto:${siteConfig.legalEmail}`}>{siteConfig.legalEmail}</a>
|
||||
</p>
|
||||
<p>
|
||||
<strong>{c.registryLabel}:</strong> {siteConfig.company.registry}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{c.vatLabel}:</strong> {siteConfig.company.vatId}
|
||||
</p>
|
||||
<p style={{ marginTop: '1rem', fontSize: '0.95rem', opacity: 0.8 }}>{c.note}</p>
|
||||
{siteConfig.company.registry ? (
|
||||
<p><strong>{c.registryLabel}:</strong> {siteConfig.company.registry}</p>
|
||||
) : null}
|
||||
{siteConfig.company.vatId ? (
|
||||
<p><strong>{c.vatLabel}:</strong> {siteConfig.company.vatId}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -1,72 +1,105 @@
|
||||
import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
import { LangProvider } from '@/context/LangContext'
|
||||
import { siteConfig } from '@/lib/site'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(siteConfig.domain),
|
||||
title: {
|
||||
default: 'GreenLens - Plant Identifier and Care Planner',
|
||||
template: '%s | GreenLens',
|
||||
},
|
||||
description:
|
||||
'GreenLens helps you identify plants, organize your collection, and keep up with care routines in one app.',
|
||||
keywords: [
|
||||
'plant identifier by picture',
|
||||
'plant care app',
|
||||
'watering reminders',
|
||||
'houseplant tracker',
|
||||
'plant identification',
|
||||
'plant health check',
|
||||
'Pflanzen App',
|
||||
'GreenLens',
|
||||
],
|
||||
authors: [{ name: siteConfig.name }],
|
||||
openGraph: {
|
||||
title: 'GreenLens - Plant Identifier and Care Planner',
|
||||
description: 'Identify plants, get care guidance, and manage your collection with GreenLens.',
|
||||
type: 'website',
|
||||
url: siteConfig.domain,
|
||||
},
|
||||
alternates: {
|
||||
canonical: '/',
|
||||
languages: {
|
||||
de: '/',
|
||||
en: '/',
|
||||
es: '/',
|
||||
'x-default': '/',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: siteConfig.name,
|
||||
operatingSystem: 'iOS, Android',
|
||||
applicationCategory: 'LifestyleApplication',
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: '0',
|
||||
priceCurrency: 'EUR',
|
||||
},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<LangProvider>{children}</LangProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
import type { Metadata } from 'next'
|
||||
import { cookies } from 'next/headers'
|
||||
import './globals.css'
|
||||
import { LangProvider } from '@/context/LangContext'
|
||||
import { siteConfig, hasIosStoreUrl } from '@/lib/site'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(siteConfig.domain),
|
||||
title: {
|
||||
default: 'GreenLens - Plant Identifier and Care Planner',
|
||||
template: '%s | GreenLens',
|
||||
},
|
||||
description:
|
||||
'GreenLens helps you identify plants, organize your collection, and keep up with care routines in one app.',
|
||||
keywords: [
|
||||
'plant identifier by picture',
|
||||
'plant care app',
|
||||
'watering reminders',
|
||||
'houseplant tracker',
|
||||
'plant identification',
|
||||
'plant health check',
|
||||
'Pflanzen App',
|
||||
'GreenLens',
|
||||
],
|
||||
authors: [{ name: siteConfig.name }],
|
||||
openGraph: {
|
||||
title: 'GreenLens - Plant Identifier and Care Planner',
|
||||
description: 'Identify plants, get care guidance, and manage your collection with GreenLens.',
|
||||
type: 'website',
|
||||
url: siteConfig.domain,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'GreenLens - Plant Identifier and Care Planner',
|
||||
description: 'Identify plants, get care guidance, and manage your collection with GreenLens.',
|
||||
},
|
||||
alternates: {
|
||||
canonical: '/',
|
||||
languages: {
|
||||
de: '/',
|
||||
en: '/',
|
||||
es: '/',
|
||||
'x-default': '/',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const cookieStore = await cookies()
|
||||
const lang = (cookieStore.get('lang')?.value ?? 'de') as 'de' | 'en' | 'es'
|
||||
const validLangs = ['de', 'en', 'es']
|
||||
const htmlLang = validLangs.includes(lang) ? lang : 'de'
|
||||
|
||||
return (
|
||||
<html lang={htmlLang}>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify([
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: siteConfig.name,
|
||||
operatingSystem: 'iOS, Android',
|
||||
applicationCategory: 'LifestyleApplication',
|
||||
description:
|
||||
'Identify plants, track care schedules, and manage your collection with AI-powered scans.',
|
||||
inLanguage: ['de', 'en', 'es'],
|
||||
...(hasIosStoreUrl && { downloadUrl: siteConfig.iosAppStoreUrl }),
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: '0',
|
||||
priceCurrency: 'EUR',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: siteConfig.name,
|
||||
url: siteConfig.domain,
|
||||
description:
|
||||
'GreenLens is a plant identification and care planning app for iOS and Android.',
|
||||
contactPoint: {
|
||||
'@type': 'ContactPoint',
|
||||
contactType: 'customer support',
|
||||
email: siteConfig.supportEmail,
|
||||
},
|
||||
...(hasIosStoreUrl && {
|
||||
sameAs: [siteConfig.iosAppStoreUrl],
|
||||
}),
|
||||
},
|
||||
]),
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<LangProvider>{children}</LangProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
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 },
|
||||
)
|
||||
}
|
||||
@@ -1,29 +1,116 @@
|
||||
import Navbar from '@/components/Navbar'
|
||||
import Hero from '@/components/Hero'
|
||||
import Ticker from '@/components/Ticker'
|
||||
import Features from '@/components/Features'
|
||||
import BrownLeaf from '@/components/BrownLeaf'
|
||||
import Intelligence from '@/components/Intelligence'
|
||||
import HowItWorks from '@/components/HowItWorks'
|
||||
import FAQ from '@/components/FAQ'
|
||||
import CTA from '@/components/CTA'
|
||||
import Footer from '@/components/Footer'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main>
|
||||
<Hero />
|
||||
<Ticker />
|
||||
<Features />
|
||||
<BrownLeaf />
|
||||
<Intelligence />
|
||||
<HowItWorks />
|
||||
<FAQ />
|
||||
<CTA />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
import Navbar from '@/components/Navbar'
|
||||
import Hero from '@/components/Hero'
|
||||
import Ticker from '@/components/Ticker'
|
||||
import Features from '@/components/Features'
|
||||
import BrownLeaf from '@/components/BrownLeaf'
|
||||
import Intelligence from '@/components/Intelligence'
|
||||
import HowItWorks from '@/components/HowItWorks'
|
||||
import FAQ from '@/components/FAQ'
|
||||
import CTA from '@/components/CTA'
|
||||
import Footer from '@/components/Footer'
|
||||
|
||||
const howToSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
name: 'How to identify a plant with GreenLens',
|
||||
step: [
|
||||
{
|
||||
'@type': 'HowToStep',
|
||||
position: 1,
|
||||
name: 'Photograph your plant',
|
||||
text: 'Open the app, point the camera at your plant and tap Scan.',
|
||||
},
|
||||
{
|
||||
'@type': 'HowToStep',
|
||||
position: 2,
|
||||
name: 'AI identifies instantly',
|
||||
text: 'In under a second you get the exact name, species and all key details.',
|
||||
},
|
||||
{
|
||||
'@type': 'HowToStep',
|
||||
position: 3,
|
||||
name: 'Receive care plan',
|
||||
text: 'GreenLens automatically creates a personalized care plan for your plant and location.',
|
||||
},
|
||||
{
|
||||
'@type': 'HowToStep',
|
||||
position: 4,
|
||||
name: 'Track growth',
|
||||
text: 'Document photos, track watering and get reminded of important care dates.',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const faqSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: [
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'How does GreenLens identify a plant?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'GreenLens analyzes the plant photo and combines that with app-side care guidance so you can move from scan to next steps faster.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'Is GreenLens free to use?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'GreenLens includes free functionality plus paid options such as subscriptions and credit top-ups for advanced AI features.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'Can I use GreenLens offline?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Plant identification and health checks require an internet connection. Your saved collection, care notes, and watering reminders are available offline.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'What kind of plants can I use GreenLens for?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'GreenLens covers 450+ plant species including houseplants, garden plants, and succulents. It is built for everyday plant owners who want identification and care guidance in one place.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'How do I start my plant collection in GreenLens?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Start with a scan, review the result, and save the plant to your collection to keep notes, reminders, and follow-up care in one place.',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(howToSchema) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
|
||||
/>
|
||||
<Navbar />
|
||||
<main>
|
||||
<Hero />
|
||||
<Ticker />
|
||||
<Features />
|
||||
<BrownLeaf />
|
||||
<Intelligence />
|
||||
<HowItWorks />
|
||||
<FAQ />
|
||||
<CTA />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,26 +5,44 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
|
||||
return [
|
||||
{
|
||||
url: baseUrl,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/imprint`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.3,
|
||||
},
|
||||
url: baseUrl,
|
||||
lastModified: new Date('2026-04-08'),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/support`,
|
||||
lastModified: new Date('2026-04-08'),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/vs/picturethis`,
|
||||
lastModified: new Date('2026-04-10'),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.65,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/vs/plantum`,
|
||||
lastModified: new Date('2026-04-10'),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.65,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/imprint`,
|
||||
lastModified: new Date('2026-04-08'),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.3,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/privacy`,
|
||||
lastModified: new Date(),
|
||||
lastModified: new Date('2026-04-08'),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.3,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/terms`,
|
||||
lastModified: new Date(),
|
||||
lastModified: new Date('2026-04-08'),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.3,
|
||||
},
|
||||
|
||||
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: {
|
||||
en: 'Can I use it offline?',
|
||||
de: 'Kann ich die App offline nutzen?',
|
||||
es: 'Puedo usarla sin conexion?'
|
||||
en: 'Can I use GreenLens offline?',
|
||||
de: 'Kann ich GreenLens offline nutzen?',
|
||||
es: 'Puedo usar GreenLens sin conexion?'
|
||||
},
|
||||
answer: {
|
||||
en: 'Some experiences may require a connection, especially for scan-related features. Saved information inside the app can remain available afterward.',
|
||||
de: 'Einige Funktionen benoetigen eine Verbindung, besonders scanbezogene Features. Gespeicherte Informationen in der App koennen danach weiter verfuegbar bleiben.',
|
||||
es: 'Algunas funciones requieren conexion, especialmente las relacionadas con escaneos. La informacion guardada puede seguir disponible despues.'
|
||||
en: 'Plant identification and health checks require an internet connection. Your saved collection, care notes, and watering reminders are available offline.',
|
||||
de: 'Pflanzenidentifikation und Gesundheitscheck benoetigen eine Internetverbindung. Deine gespeicherte Sammlung, Pflegenotizen und Giess-Erinnerungen sind offline verfuegbar.',
|
||||
es: 'La identificacion de plantas y el control de salud requieren conexion a internet. Tu coleccion guardada, notas de cuidado y recordatorios de riego estan disponibles sin conexion.'
|
||||
}
|
||||
},
|
||||
{
|
||||
question: {
|
||||
en: 'What kind of plants can I use it for?',
|
||||
de: 'Fuer welche Pflanzen kann ich die App nutzen?',
|
||||
es: 'Para que tipo de plantas puedo usar la app?'
|
||||
en: 'What kind of plants can I use GreenLens for?',
|
||||
de: 'Fuer welche Pflanzen kann ich GreenLens nutzen?',
|
||||
es: 'Para que tipo de plantas puedo usar GreenLens?'
|
||||
},
|
||||
answer: {
|
||||
en: 'GreenLens is built for everyday plant owners who want help with houseplants, garden plants, and general care questions.',
|
||||
de: 'GreenLens richtet sich an Pflanzenbesitzer, die Hilfe bei Zimmerpflanzen, Gartenpflanzen und allgemeinen Pflegefragen wollen.',
|
||||
es: 'GreenLens esta pensada para personas que quieren ayuda con plantas de interior, jardin y preguntas generales de cuidado.'
|
||||
en: 'GreenLens covers 450+ plant species including houseplants, garden plants, and succulents. It is built for everyday plant owners who want identification and care guidance in one place.',
|
||||
de: 'GreenLens umfasst ueber 450 Pflanzenarten, darunter Zimmerpflanzen, Gartenpflanzen und Sukkulenten. Die App richtet sich an Pflanzenbesitzer, die Identifikation und Pflege an einem Ort wollen.',
|
||||
es: 'GreenLens cubre mas de 450 especies de plantas, incluyendo plantas de interior, de jardin y suculentas. Esta pensada para quienes quieren identificacion y cuidado en un solo lugar.'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -27,14 +27,20 @@ export default function Footer() {
|
||||
{t.footer.cols.map((col, ci) => (
|
||||
<div className="footer-col" key={col.title}>
|
||||
<div className="footer-col-title">{col.title}</div>
|
||||
{col.links.map((label, li) => (
|
||||
<Link key={label} href={LINK_HREFS[ci]?.[li] ?? '/support'}>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{col.links.map((label, li) => (
|
||||
<Link key={label} href={LINK_HREFS[ci]?.[li] ?? '/support'}>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
{ci === 1 && (
|
||||
<>
|
||||
<Link href="/vs/picturethis">GreenLens vs PictureThis</Link>
|
||||
<Link href="/vs/plantum">GreenLens vs Plantum</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="footer-brand-xl" aria-hidden="true">GREENLENS</div>
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ export default function Hero() {
|
||||
<div className="hero-visual reveal-fade delay-2">
|
||||
<div className="hero-video-card hero-video-16-9">
|
||||
<video autoPlay loop muted playsInline aria-label="GreenLens App Demo">
|
||||
<source src="/GreenLensHype.mp4" type="video/mp4" />
|
||||
<source src="/greenlens.mp4" type="video/mp4" />
|
||||
</video>
|
||||
<div className="hero-video-card-overlay" />
|
||||
<div className="hero-video-badge">
|
||||
|
||||
@@ -1,27 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useState, ReactNode } from 'react'
|
||||
import { Lang, translations } from '@/lib/i18n'
|
||||
|
||||
interface LangCtx {
|
||||
lang: Lang
|
||||
setLang: (l: Lang) => void
|
||||
t: typeof translations.de
|
||||
}
|
||||
|
||||
const LangContext = createContext<LangCtx>({
|
||||
lang: 'de',
|
||||
setLang: () => {},
|
||||
t: translations.de,
|
||||
})
|
||||
|
||||
export function LangProvider({ children }: { children: ReactNode }) {
|
||||
const [lang, setLang] = useState<Lang>('de')
|
||||
return (
|
||||
<LangContext.Provider value={{ lang, setLang, t: translations[lang] }}>
|
||||
{children}
|
||||
</LangContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useLang = () => useContext(LangContext)
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||
import { Lang, translations } from '@/lib/i18n'
|
||||
|
||||
interface LangCtx {
|
||||
lang: Lang
|
||||
setLang: (l: Lang) => void
|
||||
t: typeof translations.de
|
||||
}
|
||||
|
||||
const LangContext = createContext<LangCtx>({
|
||||
lang: 'de',
|
||||
setLang: () => {},
|
||||
t: translations.de,
|
||||
})
|
||||
|
||||
function getInitialLang(): Lang {
|
||||
if (typeof document === 'undefined') return 'de'
|
||||
const match = document.cookie.match(/(?:^|;\s*)lang=([^;]+)/)
|
||||
const val = match?.[1]
|
||||
return val === 'en' || val === 'es' || val === 'de' ? val : 'de'
|
||||
}
|
||||
|
||||
export function LangProvider({ children }: { children: ReactNode }) {
|
||||
const [lang, setLangState] = useState<Lang>('de')
|
||||
|
||||
useEffect(() => {
|
||||
setLangState(getInitialLang())
|
||||
}, [])
|
||||
|
||||
const setLang = (l: Lang) => {
|
||||
document.cookie = `lang=${l};path=/;max-age=31536000;SameSite=Lax`
|
||||
// Update <html lang> for the current page visit without a full reload
|
||||
document.documentElement.lang = l
|
||||
setLangState(l)
|
||||
}
|
||||
|
||||
return (
|
||||
<LangContext.Provider value={{ lang, setLang, t: translations[lang] }}>
|
||||
{children}
|
||||
</LangContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useLang = () => useContext(LangContext)
|
||||
|
||||
@@ -41,7 +41,8 @@ services:
|
||||
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-https://greenlenspro.com/storage}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
OPENAI_SCAN_MODEL: ${OPENAI_SCAN_MODEL:-gpt-5-mini}
|
||||
OPENAI_HEALTH_MODEL: ${OPENAI_HEALTH_MODEL:-gpt-5-mini}
|
||||
OPENAI_SCAN_MODEL_PRO: ${OPENAI_SCAN_MODEL_PRO:-gpt-5.4}
|
||||
OPENAI_HEALTH_MODEL: ${OPENAI_HEALTH_MODEL:-gpt-4o-mini}
|
||||
REVENUECAT_WEBHOOK_SECRET: ${REVENUECAT_WEBHOOK_SECRET:-}
|
||||
REVENUECAT_PRO_ENTITLEMENT_ID: ${REVENUECAT_PRO_ENTITLEMENT_ID:-pro}
|
||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}
|
||||
|
||||
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',
|
||||
h2a: 'Alles, was dein',
|
||||
h2b: 'Urban Jungle braucht.',
|
||||
desc: 'Von der ersten Identifikation bis zur laufenden Pflege hilft GreenLens dir, Pflanzen besser zu verstehen und besser zu organisieren.',
|
||||
desc: 'Von der ersten Identifikation bis zur laufenden Pflege hilft GreenLens dir, Pflanzen besser zu verstehen und besser zu organisieren. Das Lexikon umfasst ueber 450 Pflanzenarten.',
|
||||
},
|
||||
cta: {
|
||||
tag: 'Download',
|
||||
@@ -107,7 +107,7 @@ export const translations = {
|
||||
tag: 'Features',
|
||||
h2a: 'Everything your',
|
||||
h2b: 'Urban Jungle needs.',
|
||||
desc: 'From first identification to ongoing care, GreenLens helps you understand plants better and stay organized.',
|
||||
desc: 'From first identification to ongoing care, GreenLens helps you understand plants better and stay organized. The lexicon covers 450+ plant species.',
|
||||
},
|
||||
cta: {
|
||||
tag: 'Download',
|
||||
@@ -173,7 +173,7 @@ export const translations = {
|
||||
tag: 'Funciones',
|
||||
h2a: 'Todo lo que tu',
|
||||
h2b: 'jardin urbano necesita.',
|
||||
desc: 'Desde la primera identificacion hasta el cuidado continuo, GreenLens te ayuda a entender mejor tus plantas y a organizarte.',
|
||||
desc: 'Desde la primera identificacion hasta el cuidado continuo, GreenLens te ayuda a entender mejor tus plantas y a organizarte. El lexico cubre mas de 450 especies de plantas.',
|
||||
},
|
||||
cta: {
|
||||
tag: 'Descarga',
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
const siteUrl = (process.env.NEXT_PUBLIC_SITE_URL || 'https://greenlenspro.com').trim()
|
||||
|
||||
export const siteConfig = {
|
||||
name: 'GreenLens',
|
||||
domain: siteUrl,
|
||||
supportEmail: 'knuth.timo@gmail.com',
|
||||
legalEmail: 'knuth.timo@gmail.com',
|
||||
iosAppStoreUrl: '',
|
||||
const siteUrl = (process.env.NEXT_PUBLIC_SITE_URL || 'https://greenlenspro.com').trim()
|
||||
|
||||
export const siteConfig = {
|
||||
name: 'GreenLens',
|
||||
domain: siteUrl,
|
||||
supportEmail: 'knuth.timo@gmail.com',
|
||||
legalEmail: 'knuth.timo@gmail.com',
|
||||
iosAppStoreUrl: 'https://apps.apple.com/de/app/greenlens-pro/id6759843546?l=en-GB',
|
||||
androidPlayStoreUrl: '',
|
||||
company: {
|
||||
legalName: 'GreenLens',
|
||||
representative: 'Tim Knuth',
|
||||
addressLine1: 'Replace with your legal business address',
|
||||
addressLine1: '',
|
||||
addressLine2: '',
|
||||
country: 'Germany',
|
||||
registry: 'Replace with your company registry details',
|
||||
vatId: 'Replace with your VAT ID or remove this line',
|
||||
registry: '',
|
||||
vatId: '',
|
||||
},
|
||||
} as const
|
||||
|
||||
|
||||
@@ -3,9 +3,6 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
turbopack: {
|
||||
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
|
||||
|
520
server/index.js
@@ -1,8 +1,8 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const dotenv = require('dotenv');
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const dotenv = require('dotenv');
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
|
||||
const loadEnvFiles = (filePaths) => {
|
||||
const mergedFileEnv = {};
|
||||
@@ -25,7 +25,7 @@ loadEnvFiles([
|
||||
path.join(__dirname, '.env.local'),
|
||||
]);
|
||||
|
||||
const { closeDatabase, getDefaultDbPath, openDatabase, get } = require('./lib/postgres');
|
||||
const { closeDatabase, getDefaultDbPath, openDatabase, get } = require('./lib/postgres');
|
||||
const { ensureAuthSchema, signUp: authSignUp, login: authLogin, issueToken, verifyJwt } = require('./lib/auth');
|
||||
const {
|
||||
PlantImportValidationError,
|
||||
@@ -34,34 +34,34 @@ const {
|
||||
getPlants,
|
||||
rebuildPlantsCatalog,
|
||||
} = require('./lib/plants');
|
||||
const {
|
||||
chargeKey,
|
||||
consumeCreditsWithIdempotency,
|
||||
endpointKey,
|
||||
ensureBillingSchema,
|
||||
const {
|
||||
chargeKey,
|
||||
consumeCreditsWithIdempotency,
|
||||
endpointKey,
|
||||
ensureBillingSchema,
|
||||
getAccountSnapshot,
|
||||
getBillingSummary,
|
||||
getEndpointResponse,
|
||||
isInsufficientCreditsError,
|
||||
simulatePurchase,
|
||||
simulateWebhook,
|
||||
syncRevenueCatCustomerInfo,
|
||||
syncRevenueCatWebhookEvent,
|
||||
storeEndpointResponse,
|
||||
} = require('./lib/billing');
|
||||
const {
|
||||
analyzePlantHealth,
|
||||
getHealthModel,
|
||||
getScanModel,
|
||||
identifyPlant,
|
||||
isInsufficientCreditsError,
|
||||
simulatePurchase,
|
||||
simulateWebhook,
|
||||
syncRevenueCatCustomerInfo,
|
||||
syncRevenueCatWebhookEvent,
|
||||
storeEndpointResponse,
|
||||
} = require('./lib/billing');
|
||||
const {
|
||||
analyzePlantHealth,
|
||||
getHealthModel,
|
||||
getScanModel,
|
||||
identifyPlant,
|
||||
isConfigured: isOpenAiConfigured,
|
||||
} = require('./lib/openai');
|
||||
const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding');
|
||||
const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage');
|
||||
} = require('./lib/openai');
|
||||
const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding');
|
||||
const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage');
|
||||
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
const plantsPublicDir = path.join(__dirname, 'public', 'plants');
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
const plantsPublicDir = path.join(__dirname, 'public', 'plants');
|
||||
|
||||
const SCAN_PRIMARY_COST = 1;
|
||||
const SCAN_REVIEW_COST = 1;
|
||||
@@ -69,6 +69,14 @@ const SEMANTIC_SEARCH_COST = 2;
|
||||
const HEALTH_CHECK_COST = 2;
|
||||
const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8;
|
||||
|
||||
let catalogCache = null;
|
||||
|
||||
const getCachedCatalogEntries = async (db) => {
|
||||
if (catalogCache) return catalogCache;
|
||||
catalogCache = await getPlants(db, { limit: 500 });
|
||||
return catalogCache;
|
||||
};
|
||||
|
||||
const DEFAULT_BOOTSTRAP_PLANTS = [
|
||||
{
|
||||
id: '1',
|
||||
@@ -100,6 +108,14 @@ const DEFAULT_BOOTSTRAP_PLANTS = [
|
||||
},
|
||||
];
|
||||
|
||||
const FULL_BOOTSTRAP_CATALOG_CANDIDATES = [
|
||||
path.join(__dirname, 'data', 'plants_dump_utf8.json'),
|
||||
path.join(__dirname, '..', 'plants_dump_utf8.json'),
|
||||
];
|
||||
const FULL_BOOTSTRAP_MANIFEST_CANDIDATES = [
|
||||
path.join(__dirname, 'public', 'plants', 'manifest.json'),
|
||||
];
|
||||
|
||||
let db;
|
||||
|
||||
const parseBoolean = (value, fallbackValue) => {
|
||||
@@ -172,7 +188,7 @@ const toPlantResult = (entry, confidence) => {
|
||||
};
|
||||
};
|
||||
|
||||
const pickCatalogFallback = (entries, imageUri, preferHighConfidence = false) => {
|
||||
const pickCatalogFallback = (entries, imageUri, preferHighConfidence = false, { silent = false } = {}) => {
|
||||
if (!Array.isArray(entries) || entries.length === 0) return null;
|
||||
const baseHash = hashString(`${imageUri || ''}|${entries.length}`);
|
||||
const index = baseHash % entries.length;
|
||||
@@ -180,11 +196,13 @@ const pickCatalogFallback = (entries, imageUri, preferHighConfidence = false) =>
|
||||
const confidence = preferHighConfidence
|
||||
? 0.22 + ((baseHash % 3) / 100)
|
||||
: 0.18 + ((baseHash % 7) / 100);
|
||||
console.warn('Using hash-based catalog fallback — OpenAI is unavailable or returned null.', {
|
||||
plant: entries[index]?.name,
|
||||
confidence,
|
||||
imageHint: (imageUri || '').slice(0, 80),
|
||||
});
|
||||
if (!silent) {
|
||||
console.warn('Using hash-based catalog fallback — OpenAI is unavailable or returned null.', {
|
||||
plant: entries[index]?.name,
|
||||
confidence,
|
||||
imageHint: (imageUri || '').slice(0, 80),
|
||||
});
|
||||
}
|
||||
return toPlantResult(entries[index], confidence);
|
||||
};
|
||||
|
||||
@@ -277,93 +295,231 @@ const ensureNonEmptyString = (value, fieldName) => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
const readJsonFromCandidates = (filePaths) => {
|
||||
for (const filePath of filePaths) {
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
|
||||
return {
|
||||
parsed: JSON.parse(raw),
|
||||
sourcePath: filePath,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse bootstrap JSON file.', {
|
||||
filePath,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const buildEntriesFromManifest = (manifest) => {
|
||||
const items = Array.isArray(manifest?.items) ? manifest.items : [];
|
||||
return items
|
||||
.filter((item) => item && typeof item.name === 'string' && typeof item.botanicalName === 'string')
|
||||
.map((item) => ({
|
||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : `${item.botanicalName}`.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||
name: item.name.trim(),
|
||||
botanicalName: item.botanicalName.trim(),
|
||||
imageUri: typeof item.localImageUri === 'string' && item.localImageUri.trim()
|
||||
? item.localImageUri.trim()
|
||||
: (typeof item.sourceUri === 'string' ? item.sourceUri.trim() : ''),
|
||||
imageStatus: item.status === 'missing' ? 'missing' : 'ok',
|
||||
description: '',
|
||||
categories: [],
|
||||
confidence: 1,
|
||||
careInfo: {
|
||||
waterIntervalDays: 7,
|
||||
light: 'Unknown',
|
||||
temp: 'Unknown',
|
||||
},
|
||||
}))
|
||||
.filter((entry) => entry.imageUri);
|
||||
};
|
||||
|
||||
const mergeBootstrapEntries = (primaryEntries, secondaryEntries) => {
|
||||
const mergedByBotanical = new Map();
|
||||
|
||||
primaryEntries.forEach((entry) => {
|
||||
const botanicalKey = typeof entry?.botanicalName === 'string'
|
||||
? entry.botanicalName.trim().toLowerCase()
|
||||
: '';
|
||||
if (!botanicalKey || mergedByBotanical.has(botanicalKey)) return;
|
||||
mergedByBotanical.set(botanicalKey, { ...entry });
|
||||
});
|
||||
|
||||
secondaryEntries.forEach((entry) => {
|
||||
const botanicalKey = typeof entry?.botanicalName === 'string'
|
||||
? entry.botanicalName.trim().toLowerCase()
|
||||
: '';
|
||||
if (!botanicalKey) return;
|
||||
|
||||
const existing = mergedByBotanical.get(botanicalKey);
|
||||
if (!existing) {
|
||||
mergedByBotanical.set(botanicalKey, { ...entry });
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldPreferLocalImage = typeof entry.imageUri === 'string' && entry.imageUri.startsWith('/plants/');
|
||||
mergedByBotanical.set(botanicalKey, {
|
||||
...existing,
|
||||
imageUri: shouldPreferLocalImage ? entry.imageUri : existing.imageUri,
|
||||
imageStatus: shouldPreferLocalImage ? entry.imageStatus || existing.imageStatus : existing.imageStatus,
|
||||
id: existing.id || entry.id,
|
||||
name: existing.name || entry.name,
|
||||
botanicalName: existing.botanicalName || entry.botanicalName,
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(mergedByBotanical.values());
|
||||
};
|
||||
|
||||
const loadFullBootstrapCatalog = () => {
|
||||
const catalogDump = readJsonFromCandidates(FULL_BOOTSTRAP_CATALOG_CANDIDATES);
|
||||
const manifestDump = readJsonFromCandidates(FULL_BOOTSTRAP_MANIFEST_CANDIDATES);
|
||||
|
||||
const catalogEntries = Array.isArray(catalogDump?.parsed) ? catalogDump.parsed : [];
|
||||
const manifestEntries = manifestDump ? buildEntriesFromManifest(manifestDump.parsed) : [];
|
||||
const mergedEntries = mergeBootstrapEntries(catalogEntries, manifestEntries);
|
||||
|
||||
if (mergedEntries.length === 0) return null;
|
||||
|
||||
return {
|
||||
entries: mergedEntries,
|
||||
sourcePath: [catalogDump?.sourcePath, manifestDump?.sourcePath].filter(Boolean).join(', '),
|
||||
};
|
||||
};
|
||||
|
||||
const isMinimalBootstrapCatalog = (entries) => {
|
||||
if (!Array.isArray(entries) || entries.length !== DEFAULT_BOOTSTRAP_PLANTS.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const botanicalNames = new Set(
|
||||
entries
|
||||
.map((entry) => (typeof entry?.botanicalName === 'string' ? entry.botanicalName.trim().toLowerCase() : ''))
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
return DEFAULT_BOOTSTRAP_PLANTS.every((entry) => botanicalNames.has(entry.botanicalName.trim().toLowerCase()));
|
||||
};
|
||||
|
||||
const seedBootstrapCatalogIfNeeded = async () => {
|
||||
const existing = await getPlants(db, { limit: 1 });
|
||||
if (existing.length > 0) return;
|
||||
const fullCatalog = loadFullBootstrapCatalog();
|
||||
const diagnostics = await getPlantDiagnostics(db);
|
||||
|
||||
if (diagnostics.totalCount > 0) {
|
||||
if (fullCatalog && diagnostics.totalCount === DEFAULT_BOOTSTRAP_PLANTS.length) {
|
||||
const existingEntries = await getPlants(db, { limit: DEFAULT_BOOTSTRAP_PLANTS.length + 1 });
|
||||
if (isMinimalBootstrapCatalog(existingEntries) && fullCatalog.entries.length > existingEntries.length) {
|
||||
await rebuildPlantsCatalog(db, fullCatalog.entries, {
|
||||
source: 'bootstrap_upgrade_from_minimal_catalog',
|
||||
preserveExistingIds: false,
|
||||
enforceUniqueImages: false,
|
||||
});
|
||||
console.log(`Upgraded minimal bootstrap catalog to full catalog (${fullCatalog.entries.length} entries).`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (fullCatalog) {
|
||||
await rebuildPlantsCatalog(db, fullCatalog.entries, {
|
||||
source: 'bootstrap_full_catalog',
|
||||
preserveExistingIds: false,
|
||||
enforceUniqueImages: false,
|
||||
});
|
||||
console.log(`Bootstrapped full plant catalog from ${fullCatalog.sourcePath} (${fullCatalog.entries.length} entries).`);
|
||||
return;
|
||||
}
|
||||
|
||||
await rebuildPlantsCatalog(db, DEFAULT_BOOTSTRAP_PLANTS, {
|
||||
source: 'bootstrap',
|
||||
source: 'bootstrap_minimal_catalog',
|
||||
preserveExistingIds: false,
|
||||
enforceUniqueImages: false,
|
||||
});
|
||||
console.warn('Full bootstrap catalog was not found. Seeded minimal fallback catalog with 2 entries.');
|
||||
};
|
||||
|
||||
app.use(cors());
|
||||
app.use('/plants', express.static(plantsPublicDir));
|
||||
|
||||
const revenueCatWebhookSecret = (process.env.REVENUECAT_WEBHOOK_SECRET || '').trim();
|
||||
|
||||
const isAuthorizedRevenueCatWebhook = (request) => {
|
||||
if (!revenueCatWebhookSecret) return true;
|
||||
const headerValue = request.header('authorization') || request.header('Authorization') || '';
|
||||
const normalized = String(headerValue).trim();
|
||||
return normalized === revenueCatWebhookSecret || normalized === `Bearer ${revenueCatWebhookSecret}`;
|
||||
};
|
||||
|
||||
app.post('/api/revenuecat/webhook', express.json({ limit: '1mb' }), async (request, response) => {
|
||||
try {
|
||||
if (!isAuthorizedRevenueCatWebhook(request)) {
|
||||
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid RevenueCat webhook secret.' });
|
||||
}
|
||||
const eventPayload = request.body?.event || request.body;
|
||||
const result = await syncRevenueCatWebhookEvent(db, eventPayload);
|
||||
response.status(200).json({ received: true, syncedAt: result.syncedAt });
|
||||
} catch (error) {
|
||||
const payload = toApiErrorPayload(error);
|
||||
response.status(payload.status).json(payload.body);
|
||||
}
|
||||
});
|
||||
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
const revenueCatWebhookSecret = (process.env.REVENUECAT_WEBHOOK_SECRET || '').trim();
|
||||
|
||||
const isAuthorizedRevenueCatWebhook = (request) => {
|
||||
if (!revenueCatWebhookSecret) return true;
|
||||
const headerValue = request.header('authorization') || request.header('Authorization') || '';
|
||||
const normalized = String(headerValue).trim();
|
||||
return normalized === revenueCatWebhookSecret || normalized === `Bearer ${revenueCatWebhookSecret}`;
|
||||
};
|
||||
|
||||
app.post('/api/revenuecat/webhook', express.json({ limit: '1mb' }), async (request, response) => {
|
||||
try {
|
||||
if (!isAuthorizedRevenueCatWebhook(request)) {
|
||||
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid RevenueCat webhook secret.' });
|
||||
}
|
||||
const eventPayload = request.body?.event || request.body;
|
||||
const result = await syncRevenueCatWebhookEvent(db, eventPayload);
|
||||
response.status(200).json({ received: true, syncedAt: result.syncedAt });
|
||||
} catch (error) {
|
||||
const payload = toApiErrorPayload(error);
|
||||
response.status(payload.status).json(payload.body);
|
||||
}
|
||||
});
|
||||
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
app.get('/', (_request, response) => {
|
||||
response.status(200).json({
|
||||
service: 'greenlns-api',
|
||||
status: 'ok',
|
||||
endpoints: [
|
||||
'GET /health',
|
||||
'GET /api/plants',
|
||||
'POST /api/plants/rebuild',
|
||||
'POST /auth/signup',
|
||||
status: 'ok',
|
||||
endpoints: [
|
||||
'GET /health',
|
||||
'GET /api/plants',
|
||||
'POST /api/plants/rebuild',
|
||||
'POST /auth/signup',
|
||||
'POST /auth/login',
|
||||
'GET /v1/billing/summary',
|
||||
'POST /v1/billing/sync-revenuecat',
|
||||
'POST /v1/scan',
|
||||
'GET /v1/billing/summary',
|
||||
'POST /v1/billing/sync-revenuecat',
|
||||
'POST /v1/scan',
|
||||
'POST /v1/search/semantic',
|
||||
'POST /v1/health-check',
|
||||
'POST /v1/billing/simulate-purchase',
|
||||
'POST /v1/billing/simulate-webhook',
|
||||
'POST /v1/upload/image',
|
||||
'POST /api/revenuecat/webhook',
|
||||
],
|
||||
});
|
||||
});
|
||||
'POST /v1/upload/image',
|
||||
'POST /api/revenuecat/webhook',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const getDatabaseHealthTarget = () => {
|
||||
const raw = getDefaultDbPath();
|
||||
if (!raw) return '';
|
||||
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
const databaseName = parsed.pathname.replace(/^\//, '');
|
||||
return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}/${databaseName}`;
|
||||
} catch {
|
||||
return 'configured';
|
||||
}
|
||||
};
|
||||
|
||||
app.get('/health', (_request, response) => {
|
||||
response.status(200).json({
|
||||
ok: true,
|
||||
uptimeSec: Math.round(process.uptime()),
|
||||
timestamp: new Date().toISOString(),
|
||||
openAiConfigured: isOpenAiConfigured(),
|
||||
dbReady: Boolean(db),
|
||||
dbPath: getDatabaseHealthTarget(),
|
||||
scanModel: getScanModel(),
|
||||
healthModel: getHealthModel(),
|
||||
});
|
||||
});
|
||||
const getDatabaseHealthTarget = () => {
|
||||
const raw = getDefaultDbPath();
|
||||
if (!raw) return '';
|
||||
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
const databaseName = parsed.pathname.replace(/^\//, '');
|
||||
return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}/${databaseName}`;
|
||||
} catch {
|
||||
return 'configured';
|
||||
}
|
||||
};
|
||||
|
||||
app.get('/health', (_request, response) => {
|
||||
response.status(200).json({
|
||||
ok: true,
|
||||
uptimeSec: Math.round(process.uptime()),
|
||||
timestamp: new Date().toISOString(),
|
||||
openAiConfigured: isOpenAiConfigured(),
|
||||
dbReady: Boolean(db),
|
||||
dbPath: getDatabaseHealthTarget(),
|
||||
scanModel: getScanModel(),
|
||||
healthModel: getHealthModel(),
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/plants', async (request, response) => {
|
||||
try {
|
||||
@@ -423,43 +579,43 @@ app.post('/api/plants/rebuild', async (request, response) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/v1/billing/summary', async (request, response) => {
|
||||
try {
|
||||
const userId = ensureRequestAuth(request);
|
||||
if (userId !== 'guest') {
|
||||
const userExists = await get(db, 'SELECT id FROM auth_users WHERE id = $1', [userId]);
|
||||
if (!userExists) {
|
||||
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'User not found.' });
|
||||
}
|
||||
app.get('/v1/billing/summary', async (request, response) => {
|
||||
try {
|
||||
const userId = ensureRequestAuth(request);
|
||||
if (userId !== 'guest') {
|
||||
const userExists = await get(db, 'SELECT id FROM auth_users WHERE id = $1', [userId]);
|
||||
if (!userExists) {
|
||||
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'User not found.' });
|
||||
}
|
||||
}
|
||||
const summary = await getBillingSummary(db, userId);
|
||||
response.status(200).json(summary);
|
||||
} catch (error) {
|
||||
const payload = toApiErrorPayload(error);
|
||||
response.status(payload.status).json(payload.body);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/v1/billing/sync-revenuecat', async (request, response) => {
|
||||
try {
|
||||
const userId = ensureRequestAuth(request);
|
||||
if (userId === 'guest') {
|
||||
return response.status(400).json({ code: 'BAD_REQUEST', message: 'Guest users cannot sync RevenueCat state.' });
|
||||
}
|
||||
const customerInfo = request.body?.customerInfo;
|
||||
const source = typeof request.body?.source === 'string' ? request.body.source : undefined;
|
||||
if (!customerInfo || typeof customerInfo !== 'object' || !customerInfo.entitlements) {
|
||||
return response.status(400).json({ code: 'BAD_REQUEST', message: 'customerInfo is required.' });
|
||||
}
|
||||
const payload = await syncRevenueCatCustomerInfo(db, userId, customerInfo, { source });
|
||||
response.status(200).json(payload);
|
||||
} catch (error) {
|
||||
const payload = toApiErrorPayload(error);
|
||||
response.status(payload.status).json(payload.body);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/v1/scan', async (request, response) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/v1/billing/sync-revenuecat', async (request, response) => {
|
||||
try {
|
||||
const userId = ensureRequestAuth(request);
|
||||
if (userId === 'guest') {
|
||||
return response.status(400).json({ code: 'BAD_REQUEST', message: 'Guest users cannot sync RevenueCat state.' });
|
||||
}
|
||||
const customerInfo = request.body?.customerInfo;
|
||||
const source = typeof request.body?.source === 'string' ? request.body.source : undefined;
|
||||
if (!customerInfo || typeof customerInfo !== 'object' || !customerInfo.entitlements) {
|
||||
return response.status(400).json({ code: 'BAD_REQUEST', message: 'customerInfo is required.' });
|
||||
}
|
||||
const payload = await syncRevenueCatCustomerInfo(db, userId, customerInfo, { source });
|
||||
response.status(200).json(payload);
|
||||
} catch (error) {
|
||||
const payload = toApiErrorPayload(error);
|
||||
response.status(payload.status).json(payload.body);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/v1/scan', async (request, response) => {
|
||||
let userId = 'unknown';
|
||||
try {
|
||||
userId = ensureRequestAuth(request);
|
||||
@@ -479,19 +635,17 @@ app.post('/v1/scan', async (request, response) => {
|
||||
let modelUsed = null;
|
||||
let modelFallbackCount = 0;
|
||||
|
||||
if (!isGuest(userId)) {
|
||||
creditsCharged += await consumeCreditsWithIdempotency(
|
||||
db,
|
||||
userId,
|
||||
chargeKey('scan-primary', userId, idempotencyKey),
|
||||
SCAN_PRIMARY_COST,
|
||||
);
|
||||
}
|
||||
const [creditResult, accountSnapshot, catalogEntries] = await Promise.all([
|
||||
isGuest(userId)
|
||||
? Promise.resolve(0)
|
||||
: consumeCreditsWithIdempotency(db, userId, chargeKey('scan-primary', userId, idempotencyKey), SCAN_PRIMARY_COST),
|
||||
getAccountSnapshot(db, userId),
|
||||
getCachedCatalogEntries(db),
|
||||
]);
|
||||
creditsCharged += creditResult;
|
||||
|
||||
const accountSnapshot = await getAccountSnapshot(db, userId);
|
||||
const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free';
|
||||
const catalogEntries = await getPlants(db, { limit: 500 });
|
||||
let result = pickCatalogFallback(catalogEntries, imageUri, false);
|
||||
let result = pickCatalogFallback(catalogEntries, imageUri, false, { silent: true });
|
||||
let usedOpenAi = false;
|
||||
|
||||
if (isOpenAiConfigured()) {
|
||||
@@ -516,7 +670,10 @@ app.post('/v1/scan', async (request, response) => {
|
||||
modelPath.push('openai-primary');
|
||||
if (grounded.grounded) modelPath.push('catalog-grounded-primary');
|
||||
} else {
|
||||
console.warn(`OpenAI primary identification returned null for user ${userId}`);
|
||||
console.warn(`OpenAI primary identification returned null for user ${userId} — using catalog fallback.`, {
|
||||
attemptedModels: openAiPrimary?.attemptedModels,
|
||||
plant: result?.name,
|
||||
});
|
||||
modelPath.push('openai-primary-failed');
|
||||
modelPath.push('catalog-primary-fallback');
|
||||
}
|
||||
@@ -565,11 +722,13 @@ app.post('/v1/scan', async (request, response) => {
|
||||
modelPath.push('openai-review');
|
||||
if (grounded.grounded) modelPath.push('catalog-grounded-review');
|
||||
} else {
|
||||
console.warn(`OpenAI review identification returned null for user ${userId}`);
|
||||
console.warn(`OpenAI review identification returned null for user ${userId}.`, {
|
||||
attemptedModels: openAiReview?.attemptedModels,
|
||||
});
|
||||
modelPath.push('openai-review-failed');
|
||||
}
|
||||
} else {
|
||||
const reviewFallback = pickCatalogFallback(catalogEntries, `${imageUri}|review`, true);
|
||||
const reviewFallback = pickCatalogFallback(catalogEntries, `${imageUri}|review`, true, { silent: true });
|
||||
if (reviewFallback) {
|
||||
result = reviewFallback;
|
||||
}
|
||||
@@ -681,9 +840,46 @@ app.post('/v1/health-check', async (request, response) => {
|
||||
});
|
||||
const analysis = analysisResponse?.analysis;
|
||||
if (!analysis) {
|
||||
const error = new Error('OpenAI health check failed. Please verify API key, model, and network access.');
|
||||
error.code = 'PROVIDER_ERROR';
|
||||
throw error;
|
||||
// All models in the chain failed (timeout, quota, network) — return a graceful
|
||||
// "unavailable" result instead of PROVIDER_ERROR so the user never sees an error alert.
|
||||
// Credits are NOT charged. Response is NOT cached so the user can retry.
|
||||
console.warn('Health check analysis was null — all models returned unusable output.', {
|
||||
attemptedModels: analysisResponse?.attemptedModels,
|
||||
modelUsed: analysisResponse?.modelUsed,
|
||||
});
|
||||
const unavailableIssue = language === 'de'
|
||||
? 'Die KI-Analyse ist gerade nicht verfügbar. Bitte versuche es in einem Moment erneut.'
|
||||
: language === 'es'
|
||||
? 'El análisis de IA no está disponible ahora. Inténtalo de nuevo en un momento.'
|
||||
: 'AI analysis is temporarily unavailable. Please try again in a moment.';
|
||||
const unavailableAction = language === 'de'
|
||||
? 'Erneut scannen wenn die Verbindung stabil ist.'
|
||||
: language === 'es'
|
||||
? 'Volver a escanear cuando la conexión sea estable.'
|
||||
: 'Try scanning again when your connection is stable.';
|
||||
const fallbackHealthCheck = {
|
||||
generatedAt: nowIso(),
|
||||
overallHealthScore: 50,
|
||||
status: 'watch',
|
||||
likelyIssues: [{
|
||||
title: language === 'de' ? 'Analyse nicht verfügbar' : language === 'es' ? 'Análisis no disponible' : 'Analysis unavailable',
|
||||
confidence: 0.1,
|
||||
details: unavailableIssue,
|
||||
}],
|
||||
actionsNow: [unavailableAction],
|
||||
plan7Days: [unavailableAction],
|
||||
creditsCharged: 0,
|
||||
imageUri,
|
||||
};
|
||||
const fallbackPayload = {
|
||||
healthCheck: fallbackHealthCheck,
|
||||
creditsCharged: 0,
|
||||
modelUsed: null,
|
||||
modelFallbackCount: Math.max((analysisResponse?.attemptedModels?.length || 0) - 1, 0),
|
||||
billing: await getBillingSummary(db, userId),
|
||||
};
|
||||
response.status(200).json(fallbackPayload);
|
||||
return;
|
||||
}
|
||||
|
||||
let creditsCharged = 0;
|
||||
@@ -812,19 +1008,19 @@ app.post('/auth/login', async (request, response) => {
|
||||
|
||||
// ─── Startup ───────────────────────────────────────────────────────────────
|
||||
|
||||
const start = async () => {
|
||||
db = await openDatabase();
|
||||
await ensurePlantSchema(db);
|
||||
await ensureBillingSchema(db);
|
||||
await ensureAuthSchema(db);
|
||||
await seedBootstrapCatalogIfNeeded();
|
||||
if (isStorageConfigured()) {
|
||||
await ensureStorageBucketWithRetry().catch((err) => console.warn('MinIO bucket setup failed:', err.message));
|
||||
}
|
||||
|
||||
const server = app.listen(port, () => {
|
||||
console.log(`GreenLens server listening at http://localhost:${port}`);
|
||||
});
|
||||
const start = async () => {
|
||||
db = await openDatabase();
|
||||
await ensurePlantSchema(db);
|
||||
await ensureBillingSchema(db);
|
||||
await ensureAuthSchema(db);
|
||||
await seedBootstrapCatalogIfNeeded();
|
||||
if (isStorageConfigured()) {
|
||||
await ensureStorageBucketWithRetry().catch((err) => console.warn('MinIO bucket setup failed:', err.message));
|
||||
}
|
||||
|
||||
const server = app.listen(port, () => {
|
||||
console.log(`GreenLens server listening at http://localhost:${port}`);
|
||||
});
|
||||
|
||||
const gracefulShutdown = async () => {
|
||||
try {
|
||||
|
||||
@@ -18,6 +18,28 @@ const AVAILABLE_PRODUCTS = ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_m
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
const asIsoDate = (value) => {
|
||||
if (value == null || value === '') return null;
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
return new Date(Number(trimmed)).toISOString();
|
||||
}
|
||||
const parsed = new Date(trimmed);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
return parsed.toISOString();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const startOfUtcMonth = (date) => {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0));
|
||||
};
|
||||
@@ -73,17 +95,19 @@ const runInTransaction = async (db, worker) => {
|
||||
|
||||
const normalizeAccountRow = (row) => {
|
||||
if (!row) return null;
|
||||
const now = new Date();
|
||||
const { cycleStartedAt: defaultCycleStartedAt, cycleEndsAt: defaultCycleEndsAt } = getCycleBounds(now);
|
||||
return {
|
||||
userId: String(row.userId),
|
||||
plan: row.plan === 'pro' ? 'pro' : 'free',
|
||||
provider: typeof row.provider === 'string' && row.provider ? row.provider : 'revenuecat',
|
||||
cycleStartedAt: String(row.cycleStartedAt),
|
||||
cycleEndsAt: String(row.cycleEndsAt),
|
||||
cycleStartedAt: asIsoDate(row.cycleStartedAt) || defaultCycleStartedAt.toISOString(),
|
||||
cycleEndsAt: asIsoDate(row.cycleEndsAt) || defaultCycleEndsAt.toISOString(),
|
||||
monthlyAllowance: Number(row.monthlyAllowance) || FREE_MONTHLY_CREDITS,
|
||||
usedThisCycle: Number(row.usedThisCycle) || 0,
|
||||
topupBalance: Number(row.topupBalance) || 0,
|
||||
renewsAt: row.renewsAt ? String(row.renewsAt) : null,
|
||||
updatedAt: row.updatedAt ? String(row.updatedAt) : nowIso(),
|
||||
renewsAt: asIsoDate(row.renewsAt),
|
||||
updatedAt: asIsoDate(row.updatedAt) || now.toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -238,25 +262,6 @@ const buildBillingSummary = (account) => {
|
||||
};
|
||||
};
|
||||
|
||||
const asIsoDate = (value) => {
|
||||
if (value == null || value === '') return null;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
return new Date(Number(trimmed)).toISOString();
|
||||
}
|
||||
const parsed = new Date(trimmed);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
return parsed.toISOString();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const isSupportedTopupProduct = (productId) => {
|
||||
return typeof productId === 'string'
|
||||
&& productId.startsWith('topup_')
|
||||
@@ -303,6 +308,18 @@ const getValidProEntitlement = (customerInfo) => {
|
||||
return proEntitlement;
|
||||
}
|
||||
|
||||
// Fallback: entitlement is active but backed by a non-subscription product (e.g. a topup
|
||||
// that was previously misconfigured to grant the pro entitlement). If the user also has a
|
||||
// supported subscription product in their purchase history, honour the entitlement anyway.
|
||||
const purchased = Array.isArray(customerInfo?.allPurchasedProductIdentifiers)
|
||||
? customerInfo.allPurchasedProductIdentifiers
|
||||
: [];
|
||||
const hasSubscription = purchased.some((id) => SUPPORTED_SUBSCRIPTION_PRODUCTS.has(id));
|
||||
if (hasSubscription) {
|
||||
console.warn('[Billing] Pro entitlement backed by unsupported product but subscription found — honouring entitlement', summarizeRevenueCatCustomerInfo(customerInfo));
|
||||
return proEntitlement;
|
||||
}
|
||||
|
||||
console.warn('[Billing] Ignoring unsupported RevenueCat pro entitlement', summarizeRevenueCatCustomerInfo(customerInfo));
|
||||
return null;
|
||||
};
|
||||
@@ -398,6 +415,19 @@ const syncRevenueCatCustomerInfo = async (db, userId, customerInfo, options = {}
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: also check active entitlements for topup products.
|
||||
// This handles cases where a topup product is misconfigured in RevenueCat
|
||||
// to grant an entitlement instead of being treated as a consumable.
|
||||
const rawActiveEntitlements = Object.values(customerInfo?.entitlements?.active || {});
|
||||
for (const entitlement of rawActiveEntitlements) {
|
||||
const productId = entitlement?.productIdentifier;
|
||||
if (isSupportedTopupProduct(productId)) {
|
||||
const purchaseDate = entitlement?.latestPurchaseDate || entitlement?.originalPurchaseDate;
|
||||
const txId = purchaseDate ? `entitlement:${productId}:${purchaseDate}` : null;
|
||||
await grantRevenueCatTopupIfNeeded(tx, account, txId, productId);
|
||||
}
|
||||
}
|
||||
|
||||
account.updatedAt = nowIso();
|
||||
await upsertAccount(tx, account);
|
||||
return {
|
||||
|
||||
@@ -107,10 +107,16 @@ const normalizeIdentifyResult = (raw, language) => {
|
||||
const waterIntervalRaw = getNumber(careInfoRaw.waterIntervalDays);
|
||||
const light = getString(careInfoRaw.light);
|
||||
const temp = getString(careInfoRaw.temp);
|
||||
if (waterIntervalRaw == null || !light || !temp) {
|
||||
if (waterIntervalRaw == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const LIGHT_DEFAULTS = { de: 'Helles indirektes Licht', es: 'Luz indirecta brillante', en: 'Bright indirect light' };
|
||||
const TEMP_DEFAULTS = { de: '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'
|
||||
? `${name} wurde per KI erkannt. Pflegehinweise sind unten aufgefuehrt.`
|
||||
: language === 'es'
|
||||
@@ -124,8 +130,8 @@ const normalizeIdentifyResult = (raw, language) => {
|
||||
description: description || fallbackDescription,
|
||||
careInfo: {
|
||||
waterIntervalDays: Math.round(clamp(waterIntervalRaw, 1, 45)),
|
||||
light,
|
||||
temp,
|
||||
light: resolvedLight,
|
||||
temp: resolvedTemp,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -137,15 +143,16 @@ const normalizeHealthAnalysis = (raw, language) => {
|
||||
const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 8);
|
||||
const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 10);
|
||||
|
||||
if (scoreRaw == null || !statusRaw || !Array.isArray(issuesRaw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = statusRaw === 'healthy' || statusRaw === 'watch' || statusRaw === 'critical'
|
||||
// Use safe defaults instead of returning null — bad/partial JSON falls through
|
||||
// to the graceful "Uncertain analysis" fallback at line 164 rather than
|
||||
// propagating null → PROVIDER_ERROR to the caller.
|
||||
const score = scoreRaw ?? 50;
|
||||
const status = (statusRaw === 'healthy' || statusRaw === 'watch' || statusRaw === 'critical')
|
||||
? statusRaw
|
||||
: 'watch';
|
||||
const issuesInput = Array.isArray(issuesRaw) ? issuesRaw : [];
|
||||
|
||||
const likelyIssues = issuesRaw
|
||||
const likelyIssues = issuesInput
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
|
||||
const title = getString(entry.title);
|
||||
@@ -168,7 +175,7 @@ const normalizeHealthAnalysis = (raw, language) => {
|
||||
? 'La IA no pudo extraer senales de salud estables.'
|
||||
: 'AI could not extract stable health signals.';
|
||||
return {
|
||||
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)),
|
||||
overallHealthScore: Math.round(clamp(score, 0, 100)),
|
||||
status,
|
||||
likelyIssues: [
|
||||
{
|
||||
@@ -191,7 +198,7 @@ const normalizeHealthAnalysis = (raw, language) => {
|
||||
}
|
||||
|
||||
return {
|
||||
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)),
|
||||
overallHealthScore: Math.round(clamp(score, 0, 100)),
|
||||
status,
|
||||
likelyIssues,
|
||||
actionsNow: actionsNowRaw,
|
||||
@@ -215,6 +222,10 @@ const buildIdentifyPrompt = (language, mode) => {
|
||||
'Rules:',
|
||||
nameLanguageInstruction,
|
||||
`- "description" and "careInfo.light" must be written in ${getLanguageLabel(language)}.`,
|
||||
`- "careInfo.light": short light requirement in ${getLanguageLabel(language)} (e.g. "bright indirect light", "full sun", "partial shade"). Must always be a real value, never "Unknown".`,
|
||||
language === 'en'
|
||||
? '- "careInfo.temp": temperature range in both Celsius and Fahrenheit (e.g. "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.',
|
||||
'- If species is uncertain, prefer genus-level naming (for example: "Calathea sp.").',
|
||||
'- "confidence" must be between 0 and 1.',
|
||||
@@ -305,10 +316,14 @@ const postChatCompletion = async ({ modelChain, messages, imageUri, temperature
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
let parsedError = {};
|
||||
try { parsedError = JSON.parse(body); } catch {}
|
||||
console.warn('OpenAI request HTTP error.', {
|
||||
status: response.status,
|
||||
model,
|
||||
endpoint: OPENAI_CHAT_COMPLETIONS_URL,
|
||||
openAiCode: parsedError?.error?.code,
|
||||
openAiMessage: parsedError?.error?.message,
|
||||
image: summarizeImageUri(imageUri),
|
||||
bodyPreview: body.slice(0, 300),
|
||||
});
|
||||
@@ -351,7 +366,7 @@ const identifyPlant = async ({ imageUri, language, mode = 'primary', plan = 'fre
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: buildIdentifyPrompt(language, mode) },
|
||||
{ type: 'image_url', image_url: { url: imageUri } },
|
||||
{ type: 'image_url', image_url: { url: imageUri, detail: 'low' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -411,7 +426,7 @@ const analyzePlantHealth = async ({ imageUri, language, plantContext }) => {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: buildHealthPrompt(language, plantContext) },
|
||||
{ type: 'image_url', image_url: { url: imageUri } },
|
||||
{ type: 'image_url', image_url: { url: imageUri, detail: 'low' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -115,8 +115,8 @@ const applyCatalogGrounding = (aiResult, catalogEntries, language = 'en') => {
|
||||
description: aiResult.description || matchedEntry.description || '',
|
||||
careInfo: {
|
||||
waterIntervalDays: Math.max(1, Number(matchedEntry.careInfo?.waterIntervalDays) || Number(aiResult.careInfo?.waterIntervalDays) || 7),
|
||||
light: matchedEntry.careInfo?.light || aiResult.careInfo?.light || 'Unknown',
|
||||
temp: matchedEntry.careInfo?.temp || aiResult.careInfo?.temp || 'Unknown',
|
||||
light: (matchedEntry.careInfo?.light && matchedEntry.careInfo.light !== 'Unknown') ? matchedEntry.careInfo.light : (aiResult.careInfo?.light || 'Unknown'),
|
||||
temp: (matchedEntry.careInfo?.temp && matchedEntry.careInfo.temp !== 'Unknown') ? matchedEntry.careInfo.temp : (aiResult.careInfo?.temp || 'Unknown'),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import {
|
||||
BackendApiError,
|
||||
BackendErrorCode,
|
||||
BillingSummary,
|
||||
HealthCheckResponse,
|
||||
PurchaseProductId,
|
||||
RevenueCatCustomerInfo,
|
||||
RevenueCatSyncSource,
|
||||
ScanPlantResponse,
|
||||
SemanticSearchResponse,
|
||||
ServiceHealthResponse,
|
||||
SimulatedWebhookEvent,
|
||||
SimulatePurchaseResponse,
|
||||
SimulateWebhookResponse,
|
||||
SyncRevenueCatStateResponse,
|
||||
} from './contracts';
|
||||
import { getAuthToken } from './userIdentityService';
|
||||
import { mockBackendService } from './mockBackendService';
|
||||
import { CareInfo, Language } from '../../types';
|
||||
import { getConfiguredBackendRootUrl } from '../../utils/backendUrl';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15000;
|
||||
import {
|
||||
BackendApiError,
|
||||
BackendErrorCode,
|
||||
BillingSummary,
|
||||
HealthCheckResponse,
|
||||
PurchaseProductId,
|
||||
RevenueCatCustomerInfo,
|
||||
RevenueCatSyncSource,
|
||||
ScanPlantResponse,
|
||||
SemanticSearchResponse,
|
||||
ServiceHealthResponse,
|
||||
SimulatedWebhookEvent,
|
||||
SimulatePurchaseResponse,
|
||||
SimulateWebhookResponse,
|
||||
SyncRevenueCatStateResponse,
|
||||
} from './contracts';
|
||||
import { getAuthToken } from './userIdentityService';
|
||||
import { mockBackendService } from './mockBackendService';
|
||||
import { CareInfo, Language } from '../../types';
|
||||
import { getConfiguredBackendRootUrl } from '../../utils/backendUrl';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 60000;
|
||||
|
||||
const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => {
|
||||
if (status === 400) return 'BAD_REQUEST';
|
||||
@@ -29,12 +29,12 @@ const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => {
|
||||
return 'PROVIDER_ERROR';
|
||||
};
|
||||
|
||||
const buildBackendUrl = (path: string): string => {
|
||||
const backendBaseUrl = getConfiguredBackendRootUrl();
|
||||
if (!backendBaseUrl) return path;
|
||||
const base = backendBaseUrl.replace(/\/$/, '');
|
||||
return `${base}${path}`;
|
||||
};
|
||||
const buildBackendUrl = (path: string): string => {
|
||||
const backendBaseUrl = getConfiguredBackendRootUrl();
|
||||
if (!backendBaseUrl) return path;
|
||||
const base = backendBaseUrl.replace(/\/$/, '');
|
||||
return `${base}${path}`;
|
||||
};
|
||||
|
||||
const parseMaybeJson = (value: string): Record<string, unknown> | null => {
|
||||
if (!value) return null;
|
||||
@@ -107,18 +107,18 @@ const makeRequest = async <T,>(
|
||||
};
|
||||
|
||||
export const backendApiClient = {
|
||||
getServiceHealth: async (): Promise<ServiceHealthResponse> => {
|
||||
if (!getConfiguredBackendRootUrl()) {
|
||||
return {
|
||||
ok: true,
|
||||
uptimeSec: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
openAiConfigured: Boolean(process.env.EXPO_PUBLIC_OPENAI_API_KEY),
|
||||
dbReady: true,
|
||||
dbPath: 'in-app-mock-backend',
|
||||
scanModel: (process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(),
|
||||
healthModel: (process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(),
|
||||
};
|
||||
getServiceHealth: async (): Promise<ServiceHealthResponse> => {
|
||||
if (!getConfiguredBackendRootUrl()) {
|
||||
return {
|
||||
ok: true,
|
||||
uptimeSec: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
openAiConfigured: Boolean(process.env.EXPO_PUBLIC_OPENAI_API_KEY),
|
||||
dbReady: true,
|
||||
dbPath: 'in-app-mock-backend',
|
||||
scanModel: (process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(),
|
||||
healthModel: (process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const token = await getAuthToken();
|
||||
@@ -128,49 +128,49 @@ export const backendApiClient = {
|
||||
});
|
||||
},
|
||||
|
||||
getBillingSummary: async (): Promise<BillingSummary> => {
|
||||
const token = await getAuthToken();
|
||||
if (!getConfiguredBackendRootUrl()) {
|
||||
return mockBackendService.getBillingSummary(token);
|
||||
}
|
||||
getBillingSummary: async (): Promise<BillingSummary> => {
|
||||
const token = await getAuthToken();
|
||||
if (!getConfiguredBackendRootUrl()) {
|
||||
return mockBackendService.getBillingSummary(token);
|
||||
}
|
||||
|
||||
return makeRequest<BillingSummary>('/v1/billing/summary', {
|
||||
method: 'GET',
|
||||
token,
|
||||
});
|
||||
},
|
||||
|
||||
syncRevenueCatState: async (params: {
|
||||
customerInfo: RevenueCatCustomerInfo;
|
||||
source?: RevenueCatSyncSource;
|
||||
}): Promise<SyncRevenueCatStateResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!getConfiguredBackendRootUrl()) {
|
||||
return mockBackendService.syncRevenueCatState({
|
||||
userId: token,
|
||||
customerInfo: params.customerInfo,
|
||||
source: params.source,
|
||||
});
|
||||
}
|
||||
|
||||
return makeRequest<SyncRevenueCatStateResponse>('/v1/billing/sync-revenuecat', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
customerInfo: params.customerInfo,
|
||||
source: params.source,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
scanPlant: async (params: {
|
||||
return makeRequest<BillingSummary>('/v1/billing/summary', {
|
||||
method: 'GET',
|
||||
token,
|
||||
});
|
||||
},
|
||||
|
||||
syncRevenueCatState: async (params: {
|
||||
customerInfo: RevenueCatCustomerInfo;
|
||||
source?: RevenueCatSyncSource;
|
||||
}): Promise<SyncRevenueCatStateResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!getConfiguredBackendRootUrl()) {
|
||||
return mockBackendService.syncRevenueCatState({
|
||||
userId: token,
|
||||
customerInfo: params.customerInfo,
|
||||
source: params.source,
|
||||
});
|
||||
}
|
||||
|
||||
return makeRequest<SyncRevenueCatStateResponse>('/v1/billing/sync-revenuecat', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
customerInfo: params.customerInfo,
|
||||
source: params.source,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
scanPlant: async (params: {
|
||||
idempotencyKey: string;
|
||||
imageUri: string;
|
||||
language: Language;
|
||||
}): Promise<ScanPlantResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!getConfiguredBackendRootUrl()) {
|
||||
return mockBackendService.scanPlant({
|
||||
}): Promise<ScanPlantResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!getConfiguredBackendRootUrl()) {
|
||||
return mockBackendService.scanPlant({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
imageUri: params.imageUri,
|
||||
@@ -193,10 +193,10 @@ export const backendApiClient = {
|
||||
idempotencyKey: string;
|
||||
query: string;
|
||||
language: Language;
|
||||
}): Promise<SemanticSearchResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!getConfiguredBackendRootUrl()) {
|
||||
return mockBackendService.semanticSearch({
|
||||
}): Promise<SemanticSearchResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!getConfiguredBackendRootUrl()) {
|
||||
return mockBackendService.semanticSearch({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
query: params.query,
|
||||
@@ -225,10 +225,10 @@ export const backendApiClient = {
|
||||
careInfo: CareInfo;
|
||||
description?: string;
|
||||
};
|
||||
}): Promise<HealthCheckResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!getConfiguredBackendRootUrl()) {
|
||||
return mockBackendService.healthCheck({
|
||||
}): Promise<HealthCheckResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!getConfiguredBackendRootUrl()) {
|
||||
return mockBackendService.healthCheck({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
imageUri: params.imageUri,
|
||||
@@ -252,10 +252,10 @@ export const backendApiClient = {
|
||||
simulatePurchase: async (params: {
|
||||
idempotencyKey: string;
|
||||
productId: PurchaseProductId;
|
||||
}): Promise<SimulatePurchaseResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!getConfiguredBackendRootUrl()) {
|
||||
return mockBackendService.simulatePurchase({
|
||||
}): Promise<SimulatePurchaseResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!getConfiguredBackendRootUrl()) {
|
||||
return mockBackendService.simulatePurchase({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
productId: params.productId,
|
||||
@@ -276,10 +276,10 @@ export const backendApiClient = {
|
||||
idempotencyKey: string;
|
||||
event: SimulatedWebhookEvent;
|
||||
payload?: { credits?: number };
|
||||
}): Promise<SimulateWebhookResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!getConfiguredBackendRootUrl()) {
|
||||
return mockBackendService.simulateWebhook({
|
||||
}): Promise<SimulateWebhookResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!getConfiguredBackendRootUrl()) {
|
||||
return mockBackendService.simulateWebhook({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
event: params.event,
|
||||
@@ -304,8 +304,9 @@ export const isInsufficientCreditsError = (error: unknown): boolean => {
|
||||
};
|
||||
|
||||
export const isNetworkError = (error: unknown): boolean => {
|
||||
return (
|
||||
error instanceof BackendApiError &&
|
||||
(error.code === 'NETWORK_ERROR' || error.code === 'TIMEOUT')
|
||||
);
|
||||
return error instanceof BackendApiError && error.code === 'NETWORK_ERROR';
|
||||
};
|
||||
|
||||
export const isTimeoutError = (error: unknown): boolean => {
|
||||
return error instanceof BackendApiError && error.code === 'TIMEOUT';
|
||||
};
|
||||
|
||||
41
split_image.py
Normal file
@@ -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 trimmed = String(value || '').trim();
|
||||
if (!trimmed) return null;
|
||||
@@ -12,32 +10,32 @@ const normalizeHttpUrl = (value?: string | null): string | null => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getConfiguredApiBaseUrl = (): string => {
|
||||
const explicitApiUrl = normalizeHttpUrl(process.env.EXPO_PUBLIC_API_URL);
|
||||
if (explicitApiUrl) return explicitApiUrl;
|
||||
|
||||
const backendBaseUrl = normalizeHttpUrl(
|
||||
process.env.EXPO_PUBLIC_BACKEND_URL || process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL,
|
||||
);
|
||||
if (backendBaseUrl) {
|
||||
return backendBaseUrl.endsWith('/api') ? backendBaseUrl : `${backendBaseUrl}/api`;
|
||||
}
|
||||
export const getConfiguredApiBaseUrl = (): string => {
|
||||
const explicitApiUrl = normalizeHttpUrl(process.env.EXPO_PUBLIC_API_URL);
|
||||
if (explicitApiUrl) return explicitApiUrl;
|
||||
|
||||
return DEFAULT_API_BASE_URL;
|
||||
};
|
||||
|
||||
export const getConfiguredBackendRootUrl = (): string => {
|
||||
const explicitApiUrl = normalizeHttpUrl(process.env.EXPO_PUBLIC_API_URL);
|
||||
if (explicitApiUrl) {
|
||||
return explicitApiUrl.endsWith('/api')
|
||||
? explicitApiUrl.slice(0, -4).replace(/\/+$/, '')
|
||||
: explicitApiUrl;
|
||||
}
|
||||
|
||||
return normalizeHttpUrl(
|
||||
process.env.EXPO_PUBLIC_BACKEND_URL || process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL,
|
||||
) || '';
|
||||
};
|
||||
const backendBaseUrl = normalizeHttpUrl(
|
||||
process.env.EXPO_PUBLIC_BACKEND_URL || process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL,
|
||||
);
|
||||
if (backendBaseUrl) {
|
||||
return backendBaseUrl.endsWith('/api') ? backendBaseUrl : `${backendBaseUrl}/api`;
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
export const getConfiguredBackendRootUrl = (): string => {
|
||||
const explicitApiUrl = normalizeHttpUrl(process.env.EXPO_PUBLIC_API_URL);
|
||||
if (explicitApiUrl) {
|
||||
return explicitApiUrl.endsWith('/api')
|
||||
? explicitApiUrl.slice(0, -4).replace(/\/+$/, '')
|
||||
: explicitApiUrl;
|
||||
}
|
||||
|
||||
return normalizeHttpUrl(
|
||||
process.env.EXPO_PUBLIC_BACKEND_URL || process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL,
|
||||
) || '';
|
||||
};
|
||||
|
||||
export const getConfiguredAssetBaseUrl = (): string => {
|
||||
const apiBaseUrl = getConfiguredApiBaseUrl();
|
||||
@@ -50,6 +48,6 @@ export const getConfiguredAssetBaseUrl = (): string => {
|
||||
: pathname;
|
||||
return `${parsed.origin}${assetPath}`.replace(/\/+$/, '');
|
||||
} catch {
|
||||
return 'http://localhost:3000';
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -145,6 +145,7 @@ export const translations = {
|
||||
temp: "Temperatur",
|
||||
|
||||
// Care Values (UI Helper)
|
||||
unknown: "Unbekannt",
|
||||
waterModerate: "Mäßig",
|
||||
waterLittle: "Wenig",
|
||||
waterEveryXDays: "Alle {0} Tage",
|
||||
@@ -367,6 +368,7 @@ registerToSave: "Sign up to save",
|
||||
light: "Light",
|
||||
temp: "Temperature",
|
||||
|
||||
unknown: "Unknown",
|
||||
waterModerate: "Moderate",
|
||||
waterLittle: "Little",
|
||||
waterEveryXDays: "Every {0} days",
|
||||
@@ -588,6 +590,7 @@ registerToSave: "Regístrate para guardar",
|
||||
light: "Luz",
|
||||
temp: "Temperatura",
|
||||
|
||||
unknown: "Desconocido",
|
||||
waterModerate: "Moderado",
|
||||
waterLittle: "Poco",
|
||||
waterEveryXDays: "Cada {0} días",
|
||||
|
||||