feat: implement multi-language support, SEO metadata, schema markup, and legal pages

This commit is contained in:
2026-04-08 11:53:38 +02:00
parent c3fed5226a
commit d0a13fa4f0
7 changed files with 279 additions and 165 deletions

View File

@@ -12,7 +12,6 @@ const CONTENT = {
contactLabel: 'Kontakt',
registryLabel: 'Register',
vatLabel: 'USt-ID',
note: 'Vor der Veroeffentlichung muessen alle rechtlichen Angaben mit den echten Firmendaten ersetzt werden.',
},
en: {
title: 'Imprint',
@@ -22,7 +21,6 @@ const CONTENT = {
contactLabel: 'Contact',
registryLabel: 'Registry',
vatLabel: 'VAT ID',
note: 'Replace all legal placeholders with your real company details before publishing the site.',
},
es: {
title: 'Aviso Legal',
@@ -32,7 +30,6 @@ const CONTENT = {
contactLabel: 'Contacto',
registryLabel: 'Registro',
vatLabel: 'IVA',
note: 'Sustituye todos los marcadores legales por tus datos reales antes de publicar el sitio.',
},
}
@@ -47,9 +44,9 @@ export default function ImprintPage() {
<p>
<strong>{c.companyLabel}:</strong> {siteConfig.company.legalName}
</p>
<p>
<strong>{c.addressLabel}:</strong> {siteConfig.company.addressLine1}
</p>
{siteConfig.company.addressLine1 ? (
<p><strong>{c.addressLabel}:</strong> {siteConfig.company.addressLine1}</p>
) : null}
{siteConfig.company.addressLine2 ? <p>{siteConfig.company.addressLine2}</p> : null}
<p>{siteConfig.company.country}</p>
<p>
@@ -58,13 +55,12 @@ export default function ImprintPage() {
<p>
<strong>{c.contactLabel}:</strong> <a href={`mailto:${siteConfig.legalEmail}`}>{siteConfig.legalEmail}</a>
</p>
<p>
<strong>{c.registryLabel}:</strong> {siteConfig.company.registry}
</p>
<p>
<strong>{c.vatLabel}:</strong> {siteConfig.company.vatId}
</p>
<p style={{ marginTop: '1rem', fontSize: '0.95rem', opacity: 0.8 }}>{c.note}</p>
{siteConfig.company.registry ? (
<p><strong>{c.registryLabel}:</strong> {siteConfig.company.registry}</p>
) : null}
{siteConfig.company.vatId ? (
<p><strong>{c.vatLabel}:</strong> {siteConfig.company.vatId}</p>
) : null}
</div>
</main>
)

View File

@@ -1,72 +1,114 @@
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,
images: [
{
url: '/og-image.png',
width: 1200,
height: 630,
alt: 'GreenLens Plant Identifier and Care Planner',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'GreenLens - Plant Identifier and Care Planner',
description: 'Identify plants, get care guidance, and manage your collection with GreenLens.',
images: ['/og-image.png'],
},
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>
)
}

View File

@@ -1,29 +1,80 @@
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 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: 'Some experiences may require a connection, especially for scan-related features. Saved information inside the app can remain available afterward.',
},
},
{
'@type': 'Question',
name: 'What kind of plants can I use GreenLens for?',
acceptedAnswer: {
'@type': 'Answer',
text: 'GreenLens is built for everyday plant owners who want help with houseplants, garden plants, and general care questions.',
},
},
{
'@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(faqSchema) }}
/>
<Navbar />
<main>
<Hero />
<Ticker />
<Features />
<BrownLeaf />
<Intelligence />
<HowItWorks />
<FAQ />
<CTA />
</main>
<Footer />
</>
)
}

View File

@@ -5,26 +5,32 @@ 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}/imprint`,
lastModified: new Date('2026-04-08'),
changeFrequency: 'monthly',
priority: 0.3,
},
{
url: `${baseUrl}/privacy`,
lastModified: new Date(),
lastModified: new Date('2026-04-08'),
changeFrequency: 'monthly',
priority: 0.3,
},
{
url: `${baseUrl}/terms`,
lastModified: new Date(),
lastModified: new Date('2026-04-08'),
changeFrequency: 'monthly',
priority: 0.3,
},

View File

@@ -1,27 +1,46 @@
'use client'
import { createContext, useContext, useState, ReactNode } from 'react'
import { Lang, translations } from '@/lib/i18n'
interface LangCtx {
lang: Lang
setLang: (l: Lang) => void
t: typeof translations.de
}
const LangContext = createContext<LangCtx>({
lang: 'de',
setLang: () => {},
t: translations.de,
})
export function LangProvider({ children }: { children: ReactNode }) {
const [lang, setLang] = useState<Lang>('de')
return (
<LangContext.Provider value={{ lang, setLang, t: translations[lang] }}>
{children}
</LangContext.Provider>
)
}
export const useLang = () => useContext(LangContext)
'use client'
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import { Lang, translations } from '@/lib/i18n'
interface LangCtx {
lang: Lang
setLang: (l: Lang) => void
t: typeof translations.de
}
const LangContext = createContext<LangCtx>({
lang: 'de',
setLang: () => {},
t: translations.de,
})
function getInitialLang(): Lang {
if (typeof document === 'undefined') return 'de'
const match = document.cookie.match(/(?:^|;\s*)lang=([^;]+)/)
const val = match?.[1]
return val === 'en' || val === 'es' || val === 'de' ? val : 'de'
}
export function LangProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Lang>('de')
useEffect(() => {
setLangState(getInitialLang())
}, [])
const setLang = (l: Lang) => {
document.cookie = `lang=${l};path=/;max-age=31536000;SameSite=Lax`
// Update <html lang> for the current page visit without a full reload
document.documentElement.lang = l
setLangState(l)
}
return (
<LangContext.Provider value={{ lang, setLang, t: translations[lang] }}>
{children}
</LangContext.Provider>
)
}
export const useLang = () => useContext(LangContext)

View File

@@ -1,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

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB