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

View File

@@ -1,72 +1,114 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import './globals.css' import { cookies } from 'next/headers'
import { LangProvider } from '@/context/LangContext' import './globals.css'
import { siteConfig } from '@/lib/site' import { LangProvider } from '@/context/LangContext'
import { siteConfig, hasIosStoreUrl } from '@/lib/site'
export const metadata: Metadata = {
metadataBase: new URL(siteConfig.domain), export const metadata: Metadata = {
title: { metadataBase: new URL(siteConfig.domain),
default: 'GreenLens - Plant Identifier and Care Planner', title: {
template: '%s | GreenLens', 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.', description:
keywords: [ 'GreenLens helps you identify plants, organize your collection, and keep up with care routines in one app.',
'plant identifier by picture', keywords: [
'plant care app', 'plant identifier by picture',
'watering reminders', 'plant care app',
'houseplant tracker', 'watering reminders',
'plant identification', 'houseplant tracker',
'plant health check', 'plant identification',
'Pflanzen App', 'plant health check',
'GreenLens', 'Pflanzen App',
], 'GreenLens',
authors: [{ name: siteConfig.name }], ],
openGraph: { authors: [{ name: siteConfig.name }],
title: 'GreenLens - Plant Identifier and Care Planner', openGraph: {
description: 'Identify plants, get care guidance, and manage your collection with GreenLens.', title: 'GreenLens - Plant Identifier and Care Planner',
type: 'website', description: 'Identify plants, get care guidance, and manage your collection with GreenLens.',
url: siteConfig.domain, type: 'website',
}, url: siteConfig.domain,
alternates: { images: [
canonical: '/', {
languages: { url: '/og-image.png',
de: '/', width: 1200,
en: '/', height: 630,
es: '/', alt: 'GreenLens Plant Identifier and Care Planner',
'x-default': '/', },
}, ],
}, },
} twitter: {
card: 'summary_large_image',
export default function RootLayout({ children }: { children: React.ReactNode }) { title: 'GreenLens - Plant Identifier and Care Planner',
return ( description: 'Identify plants, get care guidance, and manage your collection with GreenLens.',
<html lang="de"> images: ['/og-image.png'],
<head> },
<link rel="preconnect" href="https://fonts.googleapis.com" /> alternates: {
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" /> canonical: '/',
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> languages: {
<script de: '/',
type="application/ld+json" en: '/',
dangerouslySetInnerHTML={{ es: '/',
__html: JSON.stringify({ 'x-default': '/',
'@context': 'https://schema.org', },
'@type': 'SoftwareApplication', },
name: siteConfig.name, }
operatingSystem: 'iOS, Android',
applicationCategory: 'LifestyleApplication', export default async function RootLayout({ children }: { children: React.ReactNode }) {
offers: { const cookieStore = await cookies()
'@type': 'Offer', const lang = (cookieStore.get('lang')?.value ?? 'de') as 'de' | 'en' | 'es'
price: '0', const validLangs = ['de', 'en', 'es']
priceCurrency: 'EUR', const htmlLang = validLangs.includes(lang) ? lang : 'de'
},
}), return (
}} <html lang={htmlLang}>
/> <head>
</head> <link rel="preconnect" href="https://fonts.googleapis.com" />
<body> <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<LangProvider>{children}</LangProvider> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
</body> <script
</html> 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 Navbar from '@/components/Navbar'
import Hero from '@/components/Hero' import Hero from '@/components/Hero'
import Ticker from '@/components/Ticker' import Ticker from '@/components/Ticker'
import Features from '@/components/Features' import Features from '@/components/Features'
import BrownLeaf from '@/components/BrownLeaf' import BrownLeaf from '@/components/BrownLeaf'
import Intelligence from '@/components/Intelligence' import Intelligence from '@/components/Intelligence'
import HowItWorks from '@/components/HowItWorks' import HowItWorks from '@/components/HowItWorks'
import FAQ from '@/components/FAQ' import FAQ from '@/components/FAQ'
import CTA from '@/components/CTA' import CTA from '@/components/CTA'
import Footer from '@/components/Footer' import Footer from '@/components/Footer'
export default function Home() { const faqSchema = {
return ( '@context': 'https://schema.org',
<> '@type': 'FAQPage',
<Navbar /> mainEntity: [
<main> {
<Hero /> '@type': 'Question',
<Ticker /> name: 'How does GreenLens identify a plant?',
<Features /> acceptedAnswer: {
<BrownLeaf /> '@type': 'Answer',
<Intelligence /> text: 'GreenLens analyzes the plant photo and combines that with app-side care guidance so you can move from scan to next steps faster.',
<HowItWorks /> },
<FAQ /> },
<CTA /> {
</main> '@type': 'Question',
<Footer /> 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 [ return [
{ {
url: baseUrl, url: baseUrl,
lastModified: new Date(), lastModified: new Date('2026-04-08'),
changeFrequency: 'weekly', changeFrequency: 'weekly',
priority: 1, priority: 1,
}, },
{ {
url: `${baseUrl}/imprint`, url: `${baseUrl}/support`,
lastModified: new Date(), lastModified: new Date('2026-04-08'),
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.3, priority: 0.5,
}, },
{
url: `${baseUrl}/imprint`,
lastModified: new Date('2026-04-08'),
changeFrequency: 'monthly',
priority: 0.3,
},
{ {
url: `${baseUrl}/privacy`, url: `${baseUrl}/privacy`,
lastModified: new Date(), lastModified: new Date('2026-04-08'),
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.3, priority: 0.3,
}, },
{ {
url: `${baseUrl}/terms`, url: `${baseUrl}/terms`,
lastModified: new Date(), lastModified: new Date('2026-04-08'),
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.3, priority: 0.3,
}, },

View File

@@ -1,27 +1,46 @@
'use client' 'use client'
import { createContext, useContext, useState, ReactNode } from 'react' import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import { Lang, translations } from '@/lib/i18n' import { Lang, translations } from '@/lib/i18n'
interface LangCtx { interface LangCtx {
lang: Lang lang: Lang
setLang: (l: Lang) => void setLang: (l: Lang) => void
t: typeof translations.de t: typeof translations.de
} }
const LangContext = createContext<LangCtx>({ const LangContext = createContext<LangCtx>({
lang: 'de', lang: 'de',
setLang: () => {}, setLang: () => {},
t: translations.de, t: translations.de,
}) })
export function LangProvider({ children }: { children: ReactNode }) { function getInitialLang(): Lang {
const [lang, setLang] = useState<Lang>('de') if (typeof document === 'undefined') return 'de'
return ( const match = document.cookie.match(/(?:^|;\s*)lang=([^;]+)/)
<LangContext.Provider value={{ lang, setLang, t: translations[lang] }}> const val = match?.[1]
{children} return val === 'en' || val === 'es' || val === 'de' ? val : 'de'
</LangContext.Provider> }
)
} export function LangProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Lang>('de')
export const useLang = () => useContext(LangContext)
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() const siteUrl = (process.env.NEXT_PUBLIC_SITE_URL || 'https://greenlenspro.com').trim()
export const siteConfig = { export const siteConfig = {
name: 'GreenLens', name: 'GreenLens',
domain: siteUrl, domain: siteUrl,
supportEmail: 'knuth.timo@gmail.com', supportEmail: 'knuth.timo@gmail.com',
legalEmail: 'knuth.timo@gmail.com', legalEmail: 'knuth.timo@gmail.com',
iosAppStoreUrl: '', iosAppStoreUrl: 'https://apps.apple.com/de/app/greenlens-pro/id6759843546?l=en-GB',
androidPlayStoreUrl: '', androidPlayStoreUrl: '',
company: { company: {
legalName: 'GreenLens', legalName: 'GreenLens',
representative: 'Tim Knuth', representative: 'Tim Knuth',
addressLine1: 'Replace with your legal business address', addressLine1: '',
addressLine2: '', addressLine2: '',
country: 'Germany', country: 'Germany',
registry: 'Replace with your company registry details', registry: '',
vatId: 'Replace with your VAT ID or remove this line', vatId: '',
}, },
} as const } as const

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB