Add developer tools page and secure guest scans
This commit is contained in:
11
.gitignore
vendored
11
.gitignore
vendored
@@ -43,6 +43,17 @@ tmp/
|
|||||||
ads/
|
ads/
|
||||||
.codex-temp/
|
.codex-temp/
|
||||||
|
|
||||||
|
# Local/generated exports
|
||||||
|
greenlens-chrome-extension.zip
|
||||||
|
create_slot02.py
|
||||||
|
assets/blogs/
|
||||||
|
|
||||||
|
# Standalone projects, versioned and published independently
|
||||||
|
greenlens-cli/
|
||||||
|
greenlens-python/
|
||||||
|
greenlens-vscode/
|
||||||
|
greenlens-chrome-extension/
|
||||||
|
|
||||||
# Claude / Agents (symlinks incompatible with EAS Build on Windows)
|
# Claude / Agents (symlinks incompatible with EAS Build on Windows)
|
||||||
.agents/
|
.agents/
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
168
greenlns-landing/app/developers/page.tsx
Normal file
168
greenlns-landing/app/developers/page.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import SeoFooter from '@/components/seo/SeoFooter'
|
||||||
|
import SeoNavbar from '@/components/seo/SeoNavbar'
|
||||||
|
import { siteConfig } from '@/lib/site'
|
||||||
|
|
||||||
|
const repositoryUrl = 'https://github.com/knuthtimo-lab/greenlenspro-cli'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Developer Suite & Open Source Tools',
|
||||||
|
description: 'Official GreenLens developer tools: VS Code extension, Python SDK and CLI, Node.js CLI, Chrome extension, and GitHub Action.',
|
||||||
|
alternates: { canonical: '/developers' },
|
||||||
|
openGraph: {
|
||||||
|
title: 'Developer Suite & Open Source Tools | GreenLens',
|
||||||
|
description: 'Open-source GreenLens tools for VS Code, Python, Node.js, Chrome, and GitHub Actions.',
|
||||||
|
url: `${siteConfig.domain}/developers`,
|
||||||
|
type: 'website',
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: 'Developer Suite & Open Source Tools | GreenLens',
|
||||||
|
description: 'Open-source GreenLens tools for VS Code, Python, Node.js, Chrome, and GitHub Actions.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const tools = [
|
||||||
|
{
|
||||||
|
icon: '💻',
|
||||||
|
eyebrow: 'VS Code Marketplace',
|
||||||
|
title: 'VS Code extension',
|
||||||
|
description: 'Search plant care, diagnose leaf symptoms, and set hydration-break timers without leaving VS Code.',
|
||||||
|
command: 'Ctrl+Shift+P → GreenLens: Search Houseplant Care',
|
||||||
|
href: 'https://marketplace.visualstudio.com/items?itemName=TimoKnuth.greenlens-vscode',
|
||||||
|
linkLabel: 'VS Code Marketplace',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🐍',
|
||||||
|
eyebrow: 'Python SDK & CLI',
|
||||||
|
title: 'Python SDK and CLI',
|
||||||
|
description: 'Use the greenlens-python package in applications or call greenlens-py directly for care guides, light evaluations, and symptom diagnostics.',
|
||||||
|
command: 'pip install greenlens-python\ngreenlens-py care "Monstera"',
|
||||||
|
href: 'https://pypi.org/project/greenlens-python/',
|
||||||
|
linkLabel: 'Python SDK & CLI on PyPI',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '📦',
|
||||||
|
eyebrow: 'NPM Package',
|
||||||
|
title: 'Node.js CLI and package',
|
||||||
|
description: 'A zero-dependency terminal utility and Node module for rapid plant-care lookup in any shell or application.',
|
||||||
|
command: 'npx greenlenspro-cli care "Monstera"',
|
||||||
|
href: 'https://www.npmjs.com/package/greenlenspro-cli',
|
||||||
|
linkLabel: 'Node.js package on NPM',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🧩',
|
||||||
|
eyebrow: 'Chrome Web Store',
|
||||||
|
title: 'Chrome extension (Soon)',
|
||||||
|
description: 'Search a local plant catalog, check leaf symptoms, or select a plant photo from the current browser page for AI analysis.',
|
||||||
|
command: 'Browser popup + photo scan tool',
|
||||||
|
href: 'https://greenlenspro.com/support',
|
||||||
|
linkLabel: 'Chrome extension details (Soon)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🐙',
|
||||||
|
eyebrow: 'GitHub CI/CD',
|
||||||
|
title: 'GitHub Action',
|
||||||
|
description: 'Run plant-care and diagnosis lookups in CI/CD workflows and print results directly in build output.',
|
||||||
|
command: 'uses: knuthtimo-lab/greenlenspro-cli@v1',
|
||||||
|
href: `${repositoryUrl}#github-action`,
|
||||||
|
linkLabel: 'View GitHub Action',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '📁',
|
||||||
|
eyebrow: 'Open source',
|
||||||
|
title: 'GitHub repository',
|
||||||
|
description: 'Browse the complete source code, releases, installation notes, and contribution context for greenlenspro-cli.',
|
||||||
|
command: 'github.com/knuthtimo-lab/greenlenspro-cli',
|
||||||
|
href: repositoryUrl,
|
||||||
|
linkLabel: 'Open GitHub repository',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function DevelopersPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background text-ink font-body">
|
||||||
|
<SeoNavbar locale="de" methodologyHref="#methodology" showDevelopers />
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section className="pt-36 pb-20 md:pt-44 md:pb-28" id="advantage">
|
||||||
|
<div className="max-w-[1200px] mx-auto px-6 grid lg:grid-cols-[minmax(0,1.05fr)_minmax(360px,0.75fr)] gap-14 lg:gap-20 items-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold tracking-[0.18em] uppercase text-primary mb-6">Developer suite & open source</p>
|
||||||
|
<h1 className="font-display text-5xl md:text-6xl lg:text-7xl font-bold leading-[0.98] tracking-tight text-ink max-w-3xl">
|
||||||
|
Plant care tools for the stack you already use.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-7 max-w-2xl text-base md:text-lg leading-relaxed text-on-surface-variant">
|
||||||
|
Bring GreenLens care guidance, symptom checks, and lighting evaluations into VS Code, Python, Node.js, your browser, or a GitHub workflow.
|
||||||
|
</p>
|
||||||
|
<div className="mt-9 flex flex-wrap gap-4">
|
||||||
|
<a href={repositoryUrl} target="_blank" rel="noopener noreferrer" className="inline-flex items-center rounded-full bg-ink px-6 py-3 text-sm font-bold text-white transition-colors hover:bg-primary">
|
||||||
|
Explore on GitHub <span aria-hidden="true" className="ml-2">→</span>
|
||||||
|
</a>
|
||||||
|
<Link href="/support" className="inline-flex items-center rounded-full border border-ink px-6 py-3 text-sm font-bold text-ink transition-colors hover:bg-surface-container">
|
||||||
|
Developer support
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="rounded-[2rem] bg-ink p-7 md:p-9 text-white shadow-[0_24px_60px_-30px_rgba(1,38,31,0.6)]" aria-label="GitHub repository overview">
|
||||||
|
<div className="flex items-center justify-between border-b border-white/15 pb-5">
|
||||||
|
<span className="text-sm font-bold tracking-wide">greenlenspro-cli</span>
|
||||||
|
<span className="text-[10px] font-bold tracking-[0.14em] uppercase text-primary-fixed">Open source</span>
|
||||||
|
</div>
|
||||||
|
<div className="py-7 font-mono text-sm leading-8 text-white/80">
|
||||||
|
<p><span className="text-primary-fixed">$</span> npx greenlenspro-cli care "Monstera"</p>
|
||||||
|
<p className="mt-5 text-white/55"># GitHub Actions</p>
|
||||||
|
<p><span className="text-primary-fixed">uses:</span> knuthtimo-lab/greenlenspro-cli@v1</p>
|
||||||
|
</div>
|
||||||
|
<a href={repositoryUrl} target="_blank" rel="noopener noreferrer" className="inline-flex items-center text-sm font-bold text-primary-fixed hover:text-white transition-colors">
|
||||||
|
View repository <span aria-hidden="true" className="ml-2">↗</span>
|
||||||
|
</a>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-y border-outline-variant/60 bg-surface-container-low py-20 md:py-28" id="methodology">
|
||||||
|
<div className="max-w-[1200px] mx-auto px-6">
|
||||||
|
<div className="max-w-2xl mb-12 md:mb-16">
|
||||||
|
<p className="text-xs font-bold tracking-[0.18em] uppercase text-primary mb-5">Tools & integrations</p>
|
||||||
|
<h2 className="font-display text-4xl md:text-5xl font-bold leading-tight text-ink">One plant-care toolkit, six practical entry points.</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
|
{tools.map((tool) => (
|
||||||
|
<article key={tool.title} className="flex flex-col rounded-3xl bg-white p-7 md:p-8 border border-outline-variant/70 transition-transform duration-300 hover:-translate-y-1 hover:shadow-[0_18px_40px_-28px_rgba(1,38,31,0.38)]">
|
||||||
|
<div className="flex items-start justify-between gap-5">
|
||||||
|
<span className="text-3xl leading-none" aria-hidden="true">{tool.icon}</span>
|
||||||
|
<span className="text-right text-[10px] font-bold tracking-[0.15em] uppercase text-primary">{tool.eyebrow}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-8 font-display text-3xl font-bold leading-tight text-ink">{tool.title}</h3>
|
||||||
|
<p className="mt-4 max-w-[60ch] text-sm leading-7 text-on-surface-variant">{tool.description}</p>
|
||||||
|
<code className="mt-6 block whitespace-pre-line rounded-xl bg-ink px-4 py-3 font-mono text-xs leading-6 text-primary-fixed">{tool.command}</code>
|
||||||
|
<a href={tool.href} target="_blank" rel="noopener noreferrer" className="mt-7 inline-flex w-fit items-center text-sm font-bold text-primary hover:text-ink transition-colors">
|
||||||
|
{tool.linkLabel} <span aria-hidden="true" className="ml-2">→</span>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="py-20 md:py-28" id="faq">
|
||||||
|
<div className="max-w-[1200px] mx-auto px-6 grid gap-8 lg:grid-cols-[1fr_auto] lg:items-end">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold tracking-[0.18em] uppercase text-primary mb-5">Need the full app?</p>
|
||||||
|
<h2 className="font-display text-4xl md:text-5xl font-bold leading-tight text-ink max-w-2xl">Use GreenLens on your phone for camera-based plant scans and ongoing care.</h2>
|
||||||
|
</div>
|
||||||
|
<a href={siteConfig.iosAppStoreUrl} target="_blank" rel="noopener noreferrer" className="inline-flex w-fit items-center rounded-full bg-ink px-6 py-3 text-sm font-bold text-white transition-colors hover:bg-primary">
|
||||||
|
Download on the App Store <span aria-hidden="true" className="ml-2">→</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<SeoFooter locale="de" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -251,14 +251,14 @@ h3 {
|
|||||||
transition: background var(--t), padding var(--t), box-shadow var(--t);
|
transition: background var(--t), padding var(--t), box-shadow var(--t);
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar.scrolled {
|
.navbar.scrolled {
|
||||||
background: rgba(19, 31, 22, 0.93);
|
background: rgba(19, 31, 22, 0.93);
|
||||||
backdrop-filter: blur(18px) saturate(160%);
|
backdrop-filter: blur(18px) saturate(160%);
|
||||||
-webkit-backdrop-filter: blur(18px) saturate(160%);
|
-webkit-backdrop-filter: blur(18px) saturate(160%);
|
||||||
padding: 0.65rem 0;
|
padding: 0.65rem 0;
|
||||||
box-shadow: 0 2px 40px rgba(0, 0, 0, 0.25);
|
box-shadow: 0 2px 40px rgba(0, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar .container {
|
.navbar .container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -287,9 +287,14 @@ h3 {
|
|||||||
transition: color var(--t);
|
transition: color var(--t);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-links a:hover {
|
.nav-links a:hover {
|
||||||
color: var(--cream);
|
color: var(--cream);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-links .nav-link--active {
|
||||||
|
color: var(--cream);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-cta {
|
.nav-cta {
|
||||||
background: #fff !important;
|
background: #fff !important;
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||||||
changeFrequency: 'monthly',
|
changeFrequency: 'monthly',
|
||||||
priority: 0.5,
|
priority: 0.5,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/developers`,
|
||||||
|
lastModified: new Date('2026-08-01'),
|
||||||
|
changeFrequency: 'weekly',
|
||||||
|
priority: 0.85,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
url: `${baseUrl}/press`,
|
url: `${baseUrl}/press`,
|
||||||
lastModified: new Date('2026-07-29'),
|
lastModified: new Date('2026-07-29'),
|
||||||
|
|||||||
@@ -82,6 +82,16 @@ export default function Footer() {
|
|||||||
<Link href="/es/diagnosticar-enfermedades-plantas">Diagnosticar enfermedades</Link>
|
<Link href="/es/diagnosticar-enfermedades-plantas">Diagnosticar enfermedades</Link>
|
||||||
<Link href="/es/comparar/google-lens">GreenLens vs Google Lens</Link>
|
<Link href="/es/comparar/google-lens">GreenLens vs Google Lens</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="footer-col">
|
||||||
|
<div className="footer-col-title">Developer & Open Source</div>
|
||||||
|
<Link href="/developers" style={{ fontWeight: 'bold', color: '#00c853' }}>Developer Suite Hub</Link>
|
||||||
|
<a href="https://marketplace.visualstudio.com/items?itemName=TimoKnuth.greenlens-vscode" target="_blank" rel="noopener noreferrer">VS Code Extension</a>
|
||||||
|
<a href="https://pypi.org/project/greenlens-python/" target="_blank" rel="noopener noreferrer">Python SDK & CLI (PyPI)</a>
|
||||||
|
<a href="https://www.npmjs.com/package/greenlenspro-cli" target="_blank" rel="noopener noreferrer">Node.js CLI (NPM)</a>
|
||||||
|
<Link href="/developers">Chrome Extension (Soon)</Link>
|
||||||
|
<a href="https://github.com/knuthtimo-lab/greenlenspro-cli" target="_blank" rel="noopener noreferrer">GitHub Action & Repository</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="footer-brand-xl" aria-hidden="true">GREENLENS</div>
|
<div className="footer-brand-xl" aria-hidden="true">GREENLENS</div>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export default function Navbar() {
|
|||||||
const { lang, t } = useLang()
|
const { lang, t } = useLang()
|
||||||
const localeHome = lang === 'de' ? '/' : `/${lang}`
|
const localeHome = lang === 'de' ? '/' : `/${lang}`
|
||||||
const homeHref = (hash: string) => (pathname === localeHome ? hash : `${localeHome}${hash}`)
|
const homeHref = (hash: string) => (pathname === localeHome ? hash : `${localeHome}${hash}`)
|
||||||
|
const isSubpage = pathname !== localeHome
|
||||||
|
const isDevelopersPage = pathname === '/developers'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onScroll = () => setScrolled(window.scrollY > 40)
|
const onScroll = () => setScrolled(window.scrollY > 40)
|
||||||
@@ -21,7 +23,7 @@ export default function Navbar() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className={`navbar${scrolled ? ' scrolled' : ''}`} id="navbar" role="navigation" aria-label="Main navigation">
|
<nav className={`navbar${scrolled || isSubpage ? ' scrolled' : ''}`} id="navbar" role="navigation" aria-label="Main navigation">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<Link href={localeHome} className="nav-logo" aria-label="GreenLens Home">
|
<Link href={localeHome} className="nav-logo" aria-label="GreenLens Home">
|
||||||
GREENLENS
|
GREENLENS
|
||||||
@@ -30,9 +32,10 @@ export default function Navbar() {
|
|||||||
<div className={`nav-links${menuOpen ? ' nav-links--open' : ''}`}>
|
<div className={`nav-links${menuOpen ? ' nav-links--open' : ''}`}>
|
||||||
<a href={homeHref('#features')} onClick={() => setMenuOpen(false)}>{t.nav.features}</a>
|
<a href={homeHref('#features')} onClick={() => setMenuOpen(false)}>{t.nav.features}</a>
|
||||||
<a href={homeHref('#intelligence')} onClick={() => setMenuOpen(false)}>{t.nav.tech}</a>
|
<a href={homeHref('#intelligence')} onClick={() => setMenuOpen(false)}>{t.nav.tech}</a>
|
||||||
<a href={homeHref('#faq')} onClick={() => setMenuOpen(false)}>FAQ</a>
|
<a href={homeHref('#faq')} onClick={() => setMenuOpen(false)}>FAQ</a>
|
||||||
<a href={homeHref('#how')} onClick={() => setMenuOpen(false)}>{t.nav.how}</a>
|
<a href={homeHref('#how')} onClick={() => setMenuOpen(false)}>{t.nav.how}</a>
|
||||||
<Link href="/support" onClick={() => setMenuOpen(false)}>Support</Link>
|
<Link href="/developers" className={isDevelopersPage ? 'nav-link--active' : undefined} onClick={() => setMenuOpen(false)}>Developers</Link>
|
||||||
|
<Link href="/support" onClick={() => setMenuOpen(false)}>Support</Link>
|
||||||
<a href={homeHref('#cta')} onClick={() => setMenuOpen(false)}>{t.nav.download}</a>
|
<a href={homeHref('#cta')} onClick={() => setMenuOpen(false)}>{t.nav.download}</a>
|
||||||
|
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ export default function SeoFooter({ locale, showMethodology = true }: SeoFooterP
|
|||||||
: copy.useCasesTitle
|
: copy.useCasesTitle
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-ink-deep text-white pt-24 pb-8 font-body overflow-hidden" id="download">
|
<footer className="bg-ink-deep text-white pt-28 pb-10 font-body overflow-hidden" id="download">
|
||||||
<div className="max-w-[1200px] mx-auto px-6">
|
<div className="max-w-[1280px] mx-auto px-6">
|
||||||
{/* CTA + Bild */}
|
{/* CTA + Bild */}
|
||||||
<div className="flex flex-col lg:flex-row gap-12 mb-24 items-center">
|
<div className="flex flex-col lg:flex-row gap-12 mb-24 items-center">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -101,7 +101,7 @@ export default function SeoFooter({ locale, showMethodology = true }: SeoFooterP
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-white/60">
|
<p className="text-sm text-white/60">
|
||||||
{copy.storeNote}{' '}
|
{copy.storeNote}{' '}
|
||||||
<Link href="/support" className="underline hover:text-white">
|
<Link href="/support" className="underline hover:text-white">
|
||||||
{copy.supportLabel}
|
{copy.supportLabel}
|
||||||
@@ -116,14 +116,14 @@ export default function SeoFooter({ locale, showMethodology = true }: SeoFooterP
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Marken- und Legal-Spalten */}
|
{/* Marken- und Legal-Spalten */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-12 mb-16 border-t border-white/10 pt-12">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-14 mb-20 border-t border-white/10 pt-14">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-display font-bold text-xl tracking-widest mb-4">GREENLENS</h3>
|
<h3 className="font-display font-bold text-2xl tracking-widest mb-5">GREENLENS</h3>
|
||||||
<p className="text-xs text-white/70 leading-relaxed max-w-xs">{copy.brandBody}</p>
|
<p className="text-sm text-white/70 leading-relaxed max-w-xs">{copy.brandBody}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-[10px] font-bold tracking-widest text-primary-fixed mb-6 uppercase">{copy.productTitle}</h4>
|
<h4 className="text-xs font-bold tracking-widest text-primary-fixed mb-6 uppercase">{copy.productTitle}</h4>
|
||||||
<ul className="space-y-4 text-xs text-white/80">
|
<ul className="space-y-4 text-sm text-white/80">
|
||||||
<li><a href="#advantage" className="hover:text-white">{seoTemplateCopy[locale].nav.features}</a></li>
|
<li><a href="#advantage" className="hover:text-white">{seoTemplateCopy[locale].nav.features}</a></li>
|
||||||
{showMethodology && <li><a href="#methodology" className="hover:text-white">{seoTemplateCopy[locale].nav.tech}</a></li>}
|
{showMethodology && <li><a href="#methodology" className="hover:text-white">{seoTemplateCopy[locale].nav.tech}</a></li>}
|
||||||
<li><a href="#download" className="hover:text-white">{seoTemplateCopy[locale].nav.download}</a></li>
|
<li><a href="#download" className="hover:text-white">{seoTemplateCopy[locale].nav.download}</a></li>
|
||||||
@@ -131,16 +131,16 @@ export default function SeoFooter({ locale, showMethodology = true }: SeoFooterP
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-[10px] font-bold tracking-widest text-primary-fixed mb-6 uppercase">{copy.companyTitle}</h4>
|
<h4 className="text-xs font-bold tracking-widest text-primary-fixed mb-6 uppercase">{copy.companyTitle}</h4>
|
||||||
<ul className="space-y-4 text-xs text-white/80">
|
<ul className="space-y-4 text-sm text-white/80">
|
||||||
{showMethodology && <li><a href="#methodology" className="hover:text-white">{seoTemplateCopy[locale].nav.how}</a></li>}
|
{showMethodology && <li><a href="#methodology" className="hover:text-white">{seoTemplateCopy[locale].nav.how}</a></li>}
|
||||||
<li><a href="#faq" className="hover:text-white">FAQ</a></li>
|
<li><a href="#faq" className="hover:text-white">FAQ</a></li>
|
||||||
<li><Link href="/support" className="hover:text-white">{copy.supportLabel}</Link></li>
|
<li><Link href="/support" className="hover:text-white">{copy.supportLabel}</Link></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-[10px] font-bold tracking-widest text-primary-fixed mb-6 uppercase">{copy.legalTitle}</h4>
|
<h4 className="text-xs font-bold tracking-widest text-primary-fixed mb-6 uppercase">{copy.legalTitle}</h4>
|
||||||
<ul className="space-y-4 text-xs text-white/80">
|
<ul className="space-y-4 text-sm text-white/80">
|
||||||
<li><Link href="/imprint" className="hover:text-white">{copy.imprint}</Link></li>
|
<li><Link href="/imprint" className="hover:text-white">{copy.imprint}</Link></li>
|
||||||
<li><Link href="/privacy" className="hover:text-white">{copy.privacy}</Link></li>
|
<li><Link href="/privacy" className="hover:text-white">{copy.privacy}</Link></li>
|
||||||
<li><Link href="/terms" className="hover:text-white">{copy.terms}</Link></li>
|
<li><Link href="/terms" className="hover:text-white">{copy.terms}</Link></li>
|
||||||
@@ -149,10 +149,10 @@ export default function SeoFooter({ locale, showMethodology = true }: SeoFooterP
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SEO-Link-Spalten (interne Verlinkung) */}
|
{/* SEO-Link-Spalten (interne Verlinkung) */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-24">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-14 mb-28">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-[10px] font-bold tracking-widest text-primary-fixed mb-6 uppercase">{primaryTitle}</h4>
|
<h4 className="text-xs font-bold tracking-widest text-primary-fixed mb-6 uppercase">{primaryTitle}</h4>
|
||||||
<ul className="space-y-3 text-xs text-white/70">
|
<ul className="space-y-3 text-sm leading-6 text-white/70">
|
||||||
{primaryLinks.map((link) => (
|
{primaryLinks.map((link) => (
|
||||||
<li key={link.href}>
|
<li key={link.href}>
|
||||||
<Link href={link.href} className="hover:text-white">{link.label}</Link>
|
<Link href={link.href} className="hover:text-white">{link.label}</Link>
|
||||||
@@ -161,8 +161,8 @@ export default function SeoFooter({ locale, showMethodology = true }: SeoFooterP
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-[10px] font-bold tracking-widest text-primary-fixed mb-6 uppercase">{languageTitles[locale]}</h4>
|
<h4 className="text-xs font-bold tracking-widest text-primary-fixed mb-6 uppercase">{languageTitles[locale]}</h4>
|
||||||
<ul className="space-y-3 text-xs text-white/70">
|
<ul className="space-y-3 text-sm leading-6 text-white/70">
|
||||||
{languageHubs.map((link) => (
|
{languageHubs.map((link) => (
|
||||||
<li key={link.href}>
|
<li key={link.href}>
|
||||||
<Link href={link.href} className="hover:text-white">{link.label}</Link>
|
<Link href={link.href} className="hover:text-white">{link.label}</Link>
|
||||||
@@ -170,6 +170,18 @@ export default function SeoFooter({ locale, showMethodology = true }: SeoFooterP
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-bold tracking-widest text-primary-fixed mb-6 uppercase">Developer & Open Source</h4>
|
||||||
|
<ul className="space-y-3 text-sm leading-6 text-white/70">
|
||||||
|
<li><Link href="/developers" className="text-[#00c853] font-bold hover:underline">Developer Suite Hub</Link></li>
|
||||||
|
<li><a href="https://marketplace.visualstudio.com/items?itemName=TimoKnuth.greenlens-vscode" target="_blank" rel="noopener noreferrer" className="hover:text-white">VS Code Extension</a></li>
|
||||||
|
<li><a href="https://pypi.org/project/greenlens-python/" target="_blank" rel="noopener noreferrer" className="hover:text-white">Python SDK & CLI (PyPI)</a></li>
|
||||||
|
<li><a href="https://www.npmjs.com/package/greenlenspro-cli" target="_blank" rel="noopener noreferrer" className="hover:text-white">Node.js CLI (NPM)</a></li>
|
||||||
|
<li><Link href="/developers" className="hover:text-white">Chrome Extension (Soon)</Link></li>
|
||||||
|
<li><a href="https://github.com/knuthtimo-lab/greenlenspro-cli#github-action" target="_blank" rel="noopener noreferrer" className="hover:text-white">GitHub Action</a></li>
|
||||||
|
<li><a href="https://github.com/knuthtimo-lab/greenlenspro-cli" target="_blank" rel="noopener noreferrer" className="hover:text-white">GitHub Repository</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Riesiger Schriftzug */}
|
{/* Riesiger Schriftzug */}
|
||||||
@@ -179,7 +191,7 @@ export default function SeoFooter({ locale, showMethodology = true }: SeoFooterP
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row justify-between items-center text-[10px] text-white/50 border-t border-white/10 pt-8">
|
<div className="flex flex-col md:flex-row justify-between items-center text-xs text-white/50 border-t border-white/10 pt-8">
|
||||||
<p>{copy.copy}</p>
|
<p>{copy.copy}</p>
|
||||||
<div className="mt-4 md:mt-0 opacity-50">
|
<div className="mt-4 md:mt-0 opacity-50">
|
||||||
<span className="font-bold tracking-widest">GREENLENS</span>
|
<span className="font-bold tracking-widest">GREENLENS</span>
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import { seoTemplateCopy } from '@/lib/seoTemplateCopy'
|
|||||||
interface SeoNavbarProps {
|
interface SeoNavbarProps {
|
||||||
locale: Lang
|
locale: Lang
|
||||||
methodologyHref?: '#methodology' | '#comparison'
|
methodologyHref?: '#methodology' | '#comparison'
|
||||||
|
showDevelopers?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SeoNavbar({ locale, methodologyHref = '#methodology' }: SeoNavbarProps) {
|
export default function SeoNavbar({ locale, methodologyHref = '#methodology', showDevelopers = false }: SeoNavbarProps) {
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const copy = seoTemplateCopy[locale]
|
const copy = seoTemplateCopy[locale]
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export default function SeoNavbar({ locale, methodologyHref = '#methodology' }:
|
|||||||
{ href: methodologyHref, label: copy.nav.tech },
|
{ href: methodologyHref, label: copy.nav.tech },
|
||||||
{ href: '#faq', label: copy.nav.faq },
|
{ href: '#faq', label: copy.nav.faq },
|
||||||
{ href: methodologyHref, label: copy.nav.how },
|
{ href: methodologyHref, label: copy.nav.how },
|
||||||
|
...(showDevelopers ? [{ href: '/developers', label: 'Developers' }] : []),
|
||||||
{ href: '/support', label: copy.nav.support },
|
{ href: '/support', label: copy.nav.support },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -253,6 +253,14 @@
|
|||||||
<a href="#">Datenschutz</a>
|
<a href="#">Datenschutz</a>
|
||||||
<a href="#">AGB</a>
|
<a href="#">AGB</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-col">
|
||||||
|
<h4>Developer Suite</h4>
|
||||||
|
<a href="https://marketplace.visualstudio.com/items?itemName=TimoKnuth.greenlens-vscode" target="_blank" rel="noopener noreferrer">VS Code Extension</a>
|
||||||
|
<a href="https://pypi.org/project/greenlens-python/" target="_blank" rel="noopener noreferrer">Python SDK & CLI</a>
|
||||||
|
<a href="https://www.npmjs.com/package/greenlenspro-cli" target="_blank" rel="noopener noreferrer">Node.js CLI (NPM)</a>
|
||||||
|
<a href="https://github.com/knuthtimo-lab/greenlenspro-cli" target="_blank" rel="noopener noreferrer">GitHub Action</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer-brand-large">GREENLENS</div>
|
<div class="footer-brand-large">GREENLENS</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const crypto = require('crypto');
|
|||||||
const dotenv = require('dotenv');
|
const dotenv = require('dotenv');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
|
||||||
const loadEnvFiles = (filePaths) => {
|
const loadEnvFiles = (filePaths) => {
|
||||||
const mergedFileEnv = {};
|
const mergedFileEnv = {};
|
||||||
@@ -68,7 +69,7 @@ const {
|
|||||||
identifyPlant,
|
identifyPlant,
|
||||||
isConfigured: isOpenAiConfigured,
|
isConfigured: isOpenAiConfigured,
|
||||||
} = require('./lib/openai');
|
} = require('./lib/openai');
|
||||||
const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding');
|
const { applyCatalogGrounding, enforceEnglishName, normalizeText } = require('./lib/scanGrounding');
|
||||||
const { decideReviewOutcome, reviewAgreesWithPrimary } = require('./lib/scanReview');
|
const { decideReviewOutcome, reviewAgreesWithPrimary } = require('./lib/scanReview');
|
||||||
const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage');
|
const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage');
|
||||||
const { prepareTiktokPhotoUrls } = require('./lib/tiktok-assets');
|
const { prepareTiktokPhotoUrls } = require('./lib/tiktok-assets');
|
||||||
@@ -82,6 +83,9 @@ const {
|
|||||||
} = require('./lib/tiktok');
|
} = require('./lib/tiktok');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
// Caddy is the only reverse proxy in front of this service (see
|
||||||
|
// greenlns-landing/Caddyfile), so trust exactly one hop for req.ip / X-Forwarded-For.
|
||||||
|
app.set('trust proxy', 1);
|
||||||
const port = Number(process.env.PORT || 3000);
|
const port = Number(process.env.PORT || 3000);
|
||||||
const plantsPublicDir = path.join(__dirname, 'public', 'plants');
|
const plantsPublicDir = path.join(__dirname, 'public', 'plants');
|
||||||
|
|
||||||
@@ -756,7 +760,25 @@ app.post('/v1/billing/sync-revenuecat', async (request, response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/v1/scan', async (request, response) => {
|
// The 'Bearer guest' token (see resolveUserId above) grants a free,
|
||||||
|
// pre-auth demo scan with no credit charge. Without a limit here, that
|
||||||
|
// path is an unmetered, unauthenticated OpenAI proxy for anyone who
|
||||||
|
// knows the header — cap it per-IP so it stays a teaser, not a leak.
|
||||||
|
const guestScanLimiter = rateLimit({
|
||||||
|
windowMs: 24 * 60 * 60 * 1000,
|
||||||
|
limit: 3,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
skip: (request) => request.header('authorization') !== 'Bearer guest',
|
||||||
|
message: {
|
||||||
|
error: {
|
||||||
|
code: 'GUEST_SCAN_LIMIT',
|
||||||
|
message: 'Daily free scan limit reached. Sign in or download the app for unlimited scans.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/v1/scan', guestScanLimiter, async (request, response) => {
|
||||||
let userId = 'unknown';
|
let userId = 'unknown';
|
||||||
let idempotencyKey = null;
|
let idempotencyKey = null;
|
||||||
let creditsCharged = 0;
|
let creditsCharged = 0;
|
||||||
@@ -924,6 +946,8 @@ app.post('/v1/scan', async (request, response) => {
|
|||||||
modelPath.push('review-skipped-free-plan');
|
modelPath.push('review-skipped-free-plan');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result = enforceEnglishName(result, language);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
result,
|
result,
|
||||||
lowConfidence: (result.confidence || 0) < LOW_CONFIDENCE_RESULT_THRESHOLD,
|
lowConfidence: (result.confidence || 0) < LOW_CONFIDENCE_RESULT_THRESHOLD,
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ const GERMAN_COMMON_NAME_HINTS = [
|
|||||||
'glueckskastanie',
|
'glueckskastanie',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Most of the catalog's German common names are compound nouns ending in one
|
||||||
|
// of these — a hardcoded name list alone (GERMAN_COMMON_NAME_HINTS) only
|
||||||
|
// catches names we've already seen slip through, so this suffix check acts
|
||||||
|
// as a general-purpose backstop for names we haven't hardcoded.
|
||||||
|
const GERMAN_NAME_SUFFIXES = [
|
||||||
|
'blume', 'blueten', 'bluten', 'baum', 'strauch', 'kraut', 'wurz', 'wurzel',
|
||||||
|
'farn', 'palme', 'pflanze', 'hanf', 'tute', 'veilchen', 'dorn', 'kaktus',
|
||||||
|
'lilie', 'birke', 'weide', 'ranke', 'stern', 'kette', 'schwanz', 'ohren',
|
||||||
|
'feige', 'moos', 'zwiebel', 'knolle', 'rohr', 'kirsche',
|
||||||
|
];
|
||||||
|
|
||||||
const isLikelyGermanCommonName = (value) => {
|
const isLikelyGermanCommonName = (value) => {
|
||||||
const raw = String(value || '').trim();
|
const raw = String(value || '').trim();
|
||||||
if (!raw) return false;
|
if (!raw) return false;
|
||||||
@@ -36,11 +47,27 @@ const isLikelyGermanCommonName = (value) => {
|
|||||||
|
|
||||||
const normalized = normalizeText(raw).replace(/[^a-z0-9 ]+/g, ' ');
|
const normalized = normalizeText(raw).replace(/[^a-z0-9 ]+/g, ' ');
|
||||||
if (!normalized) return false;
|
if (!normalized) return false;
|
||||||
if (/\b(der|die|das|ein|eine)\b/.test(normalized)) return true;
|
if (/\b(der|die|das|ein|eine|und|mit|fuer)\b/.test(normalized)) return true;
|
||||||
|
|
||||||
|
const compact = normalized.replace(/\s+/g, '');
|
||||||
|
if (GERMAN_NAME_SUFFIXES.some((suffix) => compact.endsWith(suffix))) return true;
|
||||||
|
|
||||||
return GERMAN_COMMON_NAME_HINTS.some((hint) => normalized.includes(hint));
|
return GERMAN_COMMON_NAME_HINTS.some((hint) => normalized.includes(hint));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Last-resort safety net applied to the FINAL result regardless of whether
|
||||||
|
// it went through catalog grounding — the AI is instructed to never return a
|
||||||
|
// German name when English was requested, but doesn't always comply, so this
|
||||||
|
// catches the ones that slip through and falls back to the botanical name
|
||||||
|
// (which is language-neutral) rather than showing a mismatched German name
|
||||||
|
// alongside English description/care text.
|
||||||
|
const enforceEnglishName = (result, language) => {
|
||||||
|
if (!result || language !== 'en') return result;
|
||||||
|
if (!isLikelyGermanCommonName(result.name)) return result;
|
||||||
|
if (!result.botanicalName) return result;
|
||||||
|
return { ...result, name: result.botanicalName };
|
||||||
|
};
|
||||||
|
|
||||||
const isLikelyBotanicalName = (value, botanicalName) => {
|
const isLikelyBotanicalName = (value, botanicalName) => {
|
||||||
const raw = String(value || '').trim();
|
const raw = String(value || '').trim();
|
||||||
const botanicalRaw = String(botanicalName || '').trim();
|
const botanicalRaw = String(botanicalName || '').trim();
|
||||||
@@ -124,6 +151,7 @@ const applyCatalogGrounding = (aiResult, catalogEntries, language = 'en') => {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
applyCatalogGrounding,
|
applyCatalogGrounding,
|
||||||
|
enforceEnglishName,
|
||||||
findCatalogMatch,
|
findCatalogMatch,
|
||||||
isLikelyGermanCommonName,
|
isLikelyGermanCommonName,
|
||||||
normalizeText,
|
normalizeText,
|
||||||
|
|||||||
17
server/package-lock.json
generated
17
server/package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"express-rate-limit": "^7.5.1",
|
||||||
"minio": "^8.0.5",
|
"minio": "^8.0.5",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
@@ -796,6 +797,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
@@ -834,6 +836,21 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-rate-limit": {
|
||||||
|
"version": "7.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
|
||||||
|
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/express-rate-limit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"express": ">= 4.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-xml-builder": {
|
"node_modules/fast-xml-builder": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"express-rate-limit": "^7.5.1",
|
||||||
"minio": "^8.0.5",
|
"minio": "^8.0.5",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
|
|||||||
Reference in New Issue
Block a user