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,7 +1,8 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { cookies } from 'next/headers'
import './globals.css' import './globals.css'
import { LangProvider } from '@/context/LangContext' import { LangProvider } from '@/context/LangContext'
import { siteConfig } from '@/lib/site' import { siteConfig, hasIosStoreUrl } from '@/lib/site'
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(siteConfig.domain), metadataBase: new URL(siteConfig.domain),
@@ -27,6 +28,20 @@ export const metadata: Metadata = {
description: 'Identify plants, get care guidance, and manage your collection with GreenLens.', description: 'Identify plants, get care guidance, and manage your collection with GreenLens.',
type: 'website', type: 'website',
url: siteConfig.domain, 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: { alternates: {
canonical: '/', canonical: '/',
@@ -39,9 +54,14 @@ export const metadata: Metadata = {
}, },
} }
export default function RootLayout({ children }: { children: React.ReactNode }) { export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies()
const lang = (cookieStore.get('lang')?.value ?? 'de') as 'de' | 'en' | 'es'
const validLangs = ['de', 'en', 'es']
const htmlLang = validLangs.includes(lang) ? lang : 'de'
return ( return (
<html lang="de"> <html lang={htmlLang}>
<head> <head>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
@@ -49,18 +69,40 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<script <script
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: JSON.stringify({ __html: JSON.stringify([
{
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'SoftwareApplication', '@type': 'SoftwareApplication',
name: siteConfig.name, name: siteConfig.name,
operatingSystem: 'iOS, Android', operatingSystem: 'iOS, Android',
applicationCategory: 'LifestyleApplication', 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: { offers: {
'@type': 'Offer', '@type': 'Offer',
price: '0', price: '0',
priceCurrency: 'EUR', priceCurrency: 'EUR',
}, },
},
{
'@context': 'https://schema.org',
'@type': 'Organization',
name: siteConfig.name,
url: siteConfig.domain,
description:
'GreenLens is a plant identification and care planning app for iOS and Android.',
contactPoint: {
'@type': 'ContactPoint',
contactType: 'customer support',
email: siteConfig.supportEmail,
},
...(hasIosStoreUrl && {
sameAs: [siteConfig.iosAppStoreUrl],
}), }),
},
]),
}} }}
/> />
</head> </head>

View File

@@ -9,9 +9,60 @@ import FAQ from '@/components/FAQ'
import CTA from '@/components/CTA' import CTA from '@/components/CTA'
import Footer from '@/components/Footer' import Footer from '@/components/Footer'
const 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() { export default function Home() {
return ( return (
<> <>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
/>
<Navbar /> <Navbar />
<main> <main>
<Hero /> <Hero />

View File

@@ -6,25 +6,31 @@ export default function sitemap(): MetadataRoute.Sitemap {
return [ return [
{ {
url: baseUrl, url: baseUrl,
lastModified: new Date(), lastModified: new Date('2026-04-08'),
changeFrequency: 'weekly', changeFrequency: 'weekly',
priority: 1, priority: 1,
}, },
{
url: `${baseUrl}/support`,
lastModified: new Date('2026-04-08'),
changeFrequency: 'monthly',
priority: 0.5,
},
{ {
url: `${baseUrl}/imprint`, url: `${baseUrl}/imprint`,
lastModified: new Date(), lastModified: new Date('2026-04-08'),
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.3, priority: 0.3,
}, },
{ {
url: `${baseUrl}/privacy`, url: `${baseUrl}/privacy`,
lastModified: new Date(), lastModified: new Date('2026-04-08'),
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.3, priority: 0.3,
}, },
{ {
url: `${baseUrl}/terms`, url: `${baseUrl}/terms`,
lastModified: new Date(), lastModified: new Date('2026-04-08'),
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.3, priority: 0.3,
}, },

View File

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

View File

@@ -5,16 +5,16 @@ export const siteConfig = {
domain: siteUrl, domain: siteUrl,
supportEmail: 'knuth.timo@gmail.com', supportEmail: 'knuth.timo@gmail.com',
legalEmail: 'knuth.timo@gmail.com', legalEmail: 'knuth.timo@gmail.com',
iosAppStoreUrl: '', iosAppStoreUrl: 'https://apps.apple.com/de/app/greenlens-pro/id6759843546?l=en-GB',
androidPlayStoreUrl: '', androidPlayStoreUrl: '',
company: { company: {
legalName: 'GreenLens', legalName: 'GreenLens',
representative: 'Tim Knuth', representative: 'Tim Knuth',
addressLine1: 'Replace with your legal business address', addressLine1: '',
addressLine2: '', addressLine2: '',
country: 'Germany', country: 'Germany',
registry: 'Replace with your company registry details', registry: '',
vatId: 'Replace with your VAT ID or remove this line', vatId: '',
}, },
} as const } as const

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB