feat: implement multi-language support, SEO metadata, schema markup, and legal pages
This commit is contained in:
@@ -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,7 +1,8 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { cookies } from 'next/headers'
|
||||
import './globals.css'
|
||||
import { LangProvider } from '@/context/LangContext'
|
||||
import { siteConfig } from '@/lib/site'
|
||||
import { siteConfig, hasIosStoreUrl } from '@/lib/site'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
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.',
|
||||
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: '/',
|
||||
@@ -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 (
|
||||
<html lang="de">
|
||||
<html lang={htmlLang}>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
@@ -49,18 +69,40 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
__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>
|
||||
|
||||
@@ -9,9 +9,60 @@ 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 />
|
||||
|
||||
@@ -6,25 +6,31 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
url: baseUrl,
|
||||
lastModified: new Date(),
|
||||
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(),
|
||||
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,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useState, ReactNode } from 'react'
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||
import { Lang, translations } from '@/lib/i18n'
|
||||
|
||||
interface LangCtx {
|
||||
@@ -15,8 +15,27 @@ const LangContext = createContext<LangCtx>({
|
||||
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, 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 (
|
||||
<LangContext.Provider value={{ lang, setLang, t: translations[lang] }}>
|
||||
{children}
|
||||
|
||||
@@ -5,16 +5,16 @@ export const siteConfig = {
|
||||
domain: siteUrl,
|
||||
supportEmail: 'knuth.timo@gmail.com',
|
||||
legalEmail: 'knuth.timo@gmail.com',
|
||||
iosAppStoreUrl: '',
|
||||
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
|
||||
|
||||
|
||||
BIN
greenlns-landing/public/og-image.png
Normal file
BIN
greenlns-landing/public/og-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
Reference in New Issue
Block a user