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/
|
||||
.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)
|
||||
.agents/
|
||||
.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>
|
||||
)
|
||||
}
|
||||
@@ -291,6 +291,11 @@ h3 {
|
||||
color: var(--cream);
|
||||
}
|
||||
|
||||
.nav-links .nav-link--active {
|
||||
color: var(--cream);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nav-cta {
|
||||
background: #fff !important;
|
||||
color: #162118 !important;
|
||||
|
||||
@@ -24,6 +24,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/developers`,
|
||||
lastModified: new Date('2026-08-01'),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.85,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/press`,
|
||||
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/comparar/google-lens">GreenLens vs Google Lens</Link>
|
||||
</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 className="footer-brand-xl" aria-hidden="true">GREENLENS</div>
|
||||
|
||||
@@ -13,6 +13,8 @@ export default function Navbar() {
|
||||
const { lang, t } = useLang()
|
||||
const localeHome = lang === 'de' ? '/' : `/${lang}`
|
||||
const homeHref = (hash: string) => (pathname === localeHome ? hash : `${localeHome}${hash}`)
|
||||
const isSubpage = pathname !== localeHome
|
||||
const isDevelopersPage = pathname === '/developers'
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 40)
|
||||
@@ -21,7 +23,7 @@ export default function Navbar() {
|
||||
}, [])
|
||||
|
||||
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">
|
||||
<Link href={localeHome} className="nav-logo" aria-label="GreenLens Home">
|
||||
GREENLENS
|
||||
@@ -32,6 +34,7 @@ export default function Navbar() {
|
||||
<a href={homeHref('#intelligence')} onClick={() => setMenuOpen(false)}>{t.nav.tech}</a>
|
||||
<a href={homeHref('#faq')} onClick={() => setMenuOpen(false)}>FAQ</a>
|
||||
<a href={homeHref('#how')} onClick={() => setMenuOpen(false)}>{t.nav.how}</a>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -63,8 +63,8 @@ export default function SeoFooter({ locale, showMethodology = true }: SeoFooterP
|
||||
: copy.useCasesTitle
|
||||
|
||||
return (
|
||||
<footer className="bg-ink-deep text-white pt-24 pb-8 font-body overflow-hidden" id="download">
|
||||
<div className="max-w-[1200px] mx-auto px-6">
|
||||
<footer className="bg-ink-deep text-white pt-28 pb-10 font-body overflow-hidden" id="download">
|
||||
<div className="max-w-[1280px] mx-auto px-6">
|
||||
{/* CTA + Bild */}
|
||||
<div className="flex flex-col lg:flex-row gap-12 mb-24 items-center">
|
||||
<div className="flex-1">
|
||||
@@ -101,7 +101,7 @@ export default function SeoFooter({ locale, showMethodology = true }: SeoFooterP
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-white/60">
|
||||
<p className="text-sm text-white/60">
|
||||
{copy.storeNote}{' '}
|
||||
<Link href="/support" className="underline hover:text-white">
|
||||
{copy.supportLabel}
|
||||
@@ -116,14 +116,14 @@ export default function SeoFooter({ locale, showMethodology = true }: SeoFooterP
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<h3 className="font-display font-bold text-xl tracking-widest mb-4">GREENLENS</h3>
|
||||
<p className="text-xs text-white/70 leading-relaxed max-w-xs">{copy.brandBody}</p>
|
||||
<h3 className="font-display font-bold text-2xl tracking-widest mb-5">GREENLENS</h3>
|
||||
<p className="text-sm text-white/70 leading-relaxed max-w-xs">{copy.brandBody}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-[10px] font-bold tracking-widest text-primary-fixed mb-6 uppercase">{copy.productTitle}</h4>
|
||||
<ul className="space-y-4 text-xs text-white/80">
|
||||
<h4 className="text-xs font-bold tracking-widest text-primary-fixed mb-6 uppercase">{copy.productTitle}</h4>
|
||||
<ul className="space-y-4 text-sm text-white/80">
|
||||
<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>}
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-[10px] font-bold tracking-widest text-primary-fixed mb-6 uppercase">{copy.companyTitle}</h4>
|
||||
<ul className="space-y-4 text-xs text-white/80">
|
||||
<h4 className="text-xs font-bold tracking-widest text-primary-fixed mb-6 uppercase">{copy.companyTitle}</h4>
|
||||
<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>}
|
||||
<li><a href="#faq" className="hover:text-white">FAQ</a></li>
|
||||
<li><Link href="/support" className="hover:text-white">{copy.supportLabel}</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-[10px] font-bold tracking-widest text-primary-fixed mb-6 uppercase">{copy.legalTitle}</h4>
|
||||
<ul className="space-y-4 text-xs text-white/80">
|
||||
<h4 className="text-xs font-bold tracking-widest text-primary-fixed mb-6 uppercase">{copy.legalTitle}</h4>
|
||||
<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="/privacy" className="hover:text-white">{copy.privacy}</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>
|
||||
|
||||
{/* 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>
|
||||
<h4 className="text-[10px] font-bold tracking-widest text-primary-fixed mb-6 uppercase">{primaryTitle}</h4>
|
||||
<ul className="space-y-3 text-xs text-white/70">
|
||||
<h4 className="text-xs font-bold tracking-widest text-primary-fixed mb-6 uppercase">{primaryTitle}</h4>
|
||||
<ul className="space-y-3 text-sm leading-6 text-white/70">
|
||||
{primaryLinks.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link href={link.href} className="hover:text-white">{link.label}</Link>
|
||||
@@ -161,8 +161,8 @@ export default function SeoFooter({ locale, showMethodology = true }: SeoFooterP
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-[10px] font-bold tracking-widest text-primary-fixed mb-6 uppercase">{languageTitles[locale]}</h4>
|
||||
<ul className="space-y-3 text-xs text-white/70">
|
||||
<h4 className="text-xs font-bold tracking-widest text-primary-fixed mb-6 uppercase">{languageTitles[locale]}</h4>
|
||||
<ul className="space-y-3 text-sm leading-6 text-white/70">
|
||||
{languageHubs.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link href={link.href} className="hover:text-white">{link.label}</Link>
|
||||
@@ -170,6 +170,18 @@ export default function SeoFooter({ locale, showMethodology = true }: SeoFooterP
|
||||
))}
|
||||
</ul>
|
||||
</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>
|
||||
|
||||
{/* Riesiger Schriftzug */}
|
||||
@@ -179,7 +191,7 @@ export default function SeoFooter({ locale, showMethodology = true }: SeoFooterP
|
||||
</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>
|
||||
<div className="mt-4 md:mt-0 opacity-50">
|
||||
<span className="font-bold tracking-widest">GREENLENS</span>
|
||||
|
||||
@@ -11,9 +11,10 @@ import { seoTemplateCopy } from '@/lib/seoTemplateCopy'
|
||||
interface SeoNavbarProps {
|
||||
locale: Lang
|
||||
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 copy = seoTemplateCopy[locale]
|
||||
|
||||
@@ -22,6 +23,7 @@ export default function SeoNavbar({ locale, methodologyHref = '#methodology' }:
|
||||
{ href: methodologyHref, label: copy.nav.tech },
|
||||
{ href: '#faq', label: copy.nav.faq },
|
||||
{ href: methodologyHref, label: copy.nav.how },
|
||||
...(showDevelopers ? [{ href: '/developers', label: 'Developers' }] : []),
|
||||
{ href: '/support', label: copy.nav.support },
|
||||
]
|
||||
|
||||
|
||||
@@ -253,6 +253,14 @@
|
||||
<a href="#">Datenschutz</a>
|
||||
<a href="#">AGB</a>
|
||||
</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 class="footer-brand-large">GREENLENS</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ const crypto = require('crypto');
|
||||
const dotenv = require('dotenv');
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
|
||||
const loadEnvFiles = (filePaths) => {
|
||||
const mergedFileEnv = {};
|
||||
@@ -68,7 +69,7 @@ const {
|
||||
identifyPlant,
|
||||
isConfigured: isOpenAiConfigured,
|
||||
} = require('./lib/openai');
|
||||
const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding');
|
||||
const { applyCatalogGrounding, enforceEnglishName, normalizeText } = require('./lib/scanGrounding');
|
||||
const { decideReviewOutcome, reviewAgreesWithPrimary } = require('./lib/scanReview');
|
||||
const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage');
|
||||
const { prepareTiktokPhotoUrls } = require('./lib/tiktok-assets');
|
||||
@@ -82,6 +83,9 @@ const {
|
||||
} = require('./lib/tiktok');
|
||||
|
||||
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 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 idempotencyKey = null;
|
||||
let creditsCharged = 0;
|
||||
@@ -924,6 +946,8 @@ app.post('/v1/scan', async (request, response) => {
|
||||
modelPath.push('review-skipped-free-plan');
|
||||
}
|
||||
|
||||
result = enforceEnglishName(result, language);
|
||||
|
||||
const payload = {
|
||||
result,
|
||||
lowConfidence: (result.confidence || 0) < LOW_CONFIDENCE_RESULT_THRESHOLD,
|
||||
|
||||
@@ -29,6 +29,17 @@ const GERMAN_COMMON_NAME_HINTS = [
|
||||
'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 raw = String(value || '').trim();
|
||||
if (!raw) return false;
|
||||
@@ -36,11 +47,27 @@ const isLikelyGermanCommonName = (value) => {
|
||||
|
||||
const normalized = normalizeText(raw).replace(/[^a-z0-9 ]+/g, ' ');
|
||||
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));
|
||||
};
|
||||
|
||||
// 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 raw = String(value || '').trim();
|
||||
const botanicalRaw = String(botanicalName || '').trim();
|
||||
@@ -124,6 +151,7 @@ const applyCatalogGrounding = (aiResult, catalogEntries, language = 'en') => {
|
||||
|
||||
module.exports = {
|
||||
applyCatalogGrounding,
|
||||
enforceEnglishName,
|
||||
findCatalogMatch,
|
||||
isLikelyGermanCommonName,
|
||||
normalizeText,
|
||||
|
||||
17
server/package-lock.json
generated
17
server/package-lock.json
generated
@@ -12,6 +12,7 @@
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^7.5.1",
|
||||
"minio": "^8.0.5",
|
||||
"pg": "^8.16.3",
|
||||
"sharp": "^0.34.5",
|
||||
@@ -796,6 +797,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -834,6 +836,21 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^7.5.1",
|
||||
"minio": "^8.0.5",
|
||||
"pg": "^8.16.3",
|
||||
"sharp": "^0.34.5",
|
||||
|
||||
Reference in New Issue
Block a user