diff --git a/SplitImage.ps1 b/SplitImage.ps1 new file mode 100644 index 0000000..75b9347 --- /dev/null +++ b/SplitImage.ps1 @@ -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." diff --git a/__tests__/server/billingTimestampNormalization.test.js b/__tests__/server/billingTimestampNormalization.test.js new file mode 100644 index 0000000..28d0d69 --- /dev/null +++ b/__tests__/server/billingTimestampNormalization.test.js @@ -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'); + }); +}); diff --git a/app.json b/app.json index 6598584..430e94d 100644 --- a/app.json +++ b/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 @@ } } } -} +} diff --git a/app/profile/billing.tsx b/app/profile/billing.tsx index 6f8bdb0..63d0f22 100644 --- a/app/profile/billing.tsx +++ b/app/profile/billing.tsx @@ -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; -type SubscriptionPackages = Partial>; -type TopupProducts = Partial>; - -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> }; - } - - 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; +type SubscriptionPackages = Partial>; +type TopupProducts = Partial>; + +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> }; + } + + 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({}); - const [topupProducts, setTopupProducts] = useState({}); + const [subscriptionPackages, setSubscriptionPackages] = useState({}); + const [topupProducts, setTopupProducts] = useState({}); // 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).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() { - Linking.openURL('https://greenlenspro.com/privacy')}> + Linking.openURL('https://greenlenspro.com/privacy')}> Privacy Policy · - Linking.openURL('https://greenlenspro.com/terms')}> + Linking.openURL('https://greenlenspro.com/terms')}> Terms of Use @@ -532,11 +541,11 @@ export default function BillingScreen() { ))} - Linking.openURL('https://greenlenspro.com/privacy')}> + Linking.openURL('https://greenlenspro.com/privacy')}> Privacy Policy · - Linking.openURL('https://greenlenspro.com/terms')}> + Linking.openURL('https://greenlenspro.com/terms')}> Terms of Use @@ -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} + > {copy.proPlanName} @@ -647,11 +656,11 @@ export default function BillingScreen() { - Linking.openURL('https://greenlenspro.com/privacy')}> + Linking.openURL('https://greenlenspro.com/privacy')}> Privacy Policy · - Linking.openURL('https://greenlenspro.com/terms')}> + Linking.openURL('https://greenlenspro.com/terms')}> Terms of Use diff --git a/app/scanner.tsx b/app/scanner.tsx index eae2e71..8b4af51 100644 --- a/app/scanner.tsx +++ b/app/scanner.tsx @@ -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 => { + 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); } }; diff --git a/components/ResultCard.tsx b/components/ResultCard.tsx index d555443..71f6394 100644 --- a/components/ResultCard.tsx +++ b/components/ResultCard.tsx @@ -99,9 +99,9 @@ export const ResultCard: React.FC = ({ {[ - { 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) => ( @@ -118,8 +118,8 @@ export const ResultCard: React.FC = ({ {t.detailedCare} {[ { 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) => ( diff --git a/greenlns-landing/app/globals.css b/greenlns-landing/app/globals.css index d47256c..601f42a 100644 --- a/greenlns-landing/app/globals.css +++ b/greenlns-landing/app/globals.css @@ -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; + } +} diff --git a/greenlns-landing/app/imprint/page.tsx b/greenlns-landing/app/imprint/page.tsx index b978eac..8dca5a4 100644 --- a/greenlns-landing/app/imprint/page.tsx +++ b/greenlns-landing/app/imprint/page.tsx @@ -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() {

{c.companyLabel}: {siteConfig.company.legalName}

-

- {c.addressLabel}: {siteConfig.company.addressLine1} -

+ {siteConfig.company.addressLine1 ? ( +

{c.addressLabel}: {siteConfig.company.addressLine1}

+ ) : null} {siteConfig.company.addressLine2 ?

{siteConfig.company.addressLine2}

: null}

{siteConfig.company.country}

@@ -58,13 +55,12 @@ export default function ImprintPage() {

{c.contactLabel}: {siteConfig.legalEmail}

-

- {c.registryLabel}: {siteConfig.company.registry} -

-

- {c.vatLabel}: {siteConfig.company.vatId} -

-

{c.note}

+ {siteConfig.company.registry ? ( +

{c.registryLabel}: {siteConfig.company.registry}

+ ) : null} + {siteConfig.company.vatId ? ( +

{c.vatLabel}: {siteConfig.company.vatId}

+ ) : null} ) diff --git a/greenlns-landing/app/layout.tsx b/greenlns-landing/app/layout.tsx index c88ee1d..a01ffb2 100644 --- a/greenlns-landing/app/layout.tsx +++ b/greenlns-landing/app/layout.tsx @@ -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 ( - - - - - -