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',
|
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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
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