From 6071561ba4df245a5a5c240008fdd7627cd7844c Mon Sep 17 00:00:00 2001 From: knuthtimo-lab Date: Wed, 5 Aug 2026 21:52:53 +0200 Subject: [PATCH] feat(blog): resolve SEO cannibalization, update blog posts & clean up dev posts --- .gitignore | 1 + ...ost-07-high-throughput-qr-utm-analytics.md | 261 ------ greenlns-landing/app/blog/page.tsx | 32 +- .../page.tsx | 4 - .../zimmerpflanzen-mit-wenig-licht/page.tsx | 4 - .../components/blog/BlogPostTemplate.tsx | 137 ++- greenlns-landing/lib/blogPosts.ts | 803 ++++++++++++++++-- greenlns-landing/lib/seoPages.ts | 359 +------- greenlns-landing/lib/spanishSeoPages.ts | 366 +------- .../blog/abstract-plant-care-illustration.jpg | Bin 0 -> 728672 bytes .../apartment-with-houseplants-collection.jpg | Bin 0 -> 963135 bytes .../blog/brown-spots-on-houseplant-leaf.jpg | Bin 0 -> 814164 bytes .../public/blog/gardening-tools-flatlay.jpg | Bin 0 -> 872739 bytes .../blog/houseplant-in-dark-room-corner.jpg | Bin 0 -> 653546 bytes .../blog/houseplant-next-to-notebook.jpg | Bin 0 -> 868091 bytes .../blog/houseplants-on-sunny-windowsill.jpg | Bin 0 -> 888170 bytes .../blog/monstera-in-bright-living-room.jpg | Bin 0 -> 927914 bytes .../blog/person-checking-houseplant-soil.jpg | Bin 0 -> 792543 bytes .../person-examining-plant-leaf-pests.jpg | Bin 0 -> 837814 bytes .../person-looking-at-drooping-houseplant.jpg | Bin 0 -> 721823 bytes .../public/blog/plant-with-dry-leaf-edges.jpg | Bin 0 -> 736890 bytes .../repotted-houseplant-on-wooden-table.jpg | Bin 0 -> 853252 bytes .../blog/roots-fresh-soil-terracotta-pot.jpg | Bin 0 -> 885314 bytes .../public/blog/small-houseplant-on-desk.jpg | Bin 0 -> 785952 bytes .../blog/sunlight-through-monstera-leaves.jpg | Bin 0 -> 648973 bytes ...ropical-houseplant-large-glossy-leaves.jpg | Bin 0 -> 838251 bytes .../blog/water-droplets-on-green-leaf.jpg | Bin 0 -> 746398 bytes .../blog/watering-can-next-to-calathea.jpg | Bin 0 -> 714938 bytes .../blog/wilted-plant-healthy-plant.jpg | Bin 0 -> 954118 bytes .../public/blog/woman-waters-houseplant.jpg | Bin 0 -> 925308 bytes .../blog/yellowing-monstera-leaf-texture.jpg | Bin 0 -> 743272 bytes 31 files changed, 858 insertions(+), 1109 deletions(-) delete mode 100644 docs/hashnode-devto-posts/post-07-high-throughput-qr-utm-analytics.md delete mode 100644 greenlns-landing/app/wie-funktioniert-pflanzenerkennung/page.tsx delete mode 100644 greenlns-landing/app/zimmerpflanzen-mit-wenig-licht/page.tsx create mode 100644 greenlns-landing/public/blog/abstract-plant-care-illustration.jpg create mode 100644 greenlns-landing/public/blog/apartment-with-houseplants-collection.jpg create mode 100644 greenlns-landing/public/blog/brown-spots-on-houseplant-leaf.jpg create mode 100644 greenlns-landing/public/blog/gardening-tools-flatlay.jpg create mode 100644 greenlns-landing/public/blog/houseplant-in-dark-room-corner.jpg create mode 100644 greenlns-landing/public/blog/houseplant-next-to-notebook.jpg create mode 100644 greenlns-landing/public/blog/houseplants-on-sunny-windowsill.jpg create mode 100644 greenlns-landing/public/blog/monstera-in-bright-living-room.jpg create mode 100644 greenlns-landing/public/blog/person-checking-houseplant-soil.jpg create mode 100644 greenlns-landing/public/blog/person-examining-plant-leaf-pests.jpg create mode 100644 greenlns-landing/public/blog/person-looking-at-drooping-houseplant.jpg create mode 100644 greenlns-landing/public/blog/plant-with-dry-leaf-edges.jpg create mode 100644 greenlns-landing/public/blog/repotted-houseplant-on-wooden-table.jpg create mode 100644 greenlns-landing/public/blog/roots-fresh-soil-terracotta-pot.jpg create mode 100644 greenlns-landing/public/blog/small-houseplant-on-desk.jpg create mode 100644 greenlns-landing/public/blog/sunlight-through-monstera-leaves.jpg create mode 100644 greenlns-landing/public/blog/tropical-houseplant-large-glossy-leaves.jpg create mode 100644 greenlns-landing/public/blog/water-droplets-on-green-leaf.jpg create mode 100644 greenlns-landing/public/blog/watering-can-next-to-calathea.jpg create mode 100644 greenlns-landing/public/blog/wilted-plant-healthy-plant.jpg create mode 100644 greenlns-landing/public/blog/woman-waters-houseplant.jpg create mode 100644 greenlns-landing/public/blog/yellowing-monstera-leaf-texture.jpg diff --git a/.gitignore b/.gitignore index 9f09b7a..afd5f55 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ greenlens-cli/ greenlens-python/ greenlens-vscode/ greenlens-chrome-extension/ +homebrew-greenlens/ # Claude / Agents (symlinks incompatible with EAS Build on Windows) .agents/ diff --git a/docs/hashnode-devto-posts/post-07-high-throughput-qr-utm-analytics.md b/docs/hashnode-devto-posts/post-07-high-throughput-qr-utm-analytics.md deleted file mode 100644 index 3e5a61c..0000000 --- a/docs/hashnode-devto-posts/post-07-high-throughput-qr-utm-analytics.md +++ /dev/null @@ -1,261 +0,0 @@ ---- -title: "Modern High-Throughput QR & UTM Tracking Architecture for SMB Micro-SaaS" -description: "Learn how to build a high-performance HTTP redirect engine with dynamic QR code rendering, async analytics capture, and microsecond latency." -tags: ["systemdesign", "backend", "node", "webdev"] -canonical_url: "https://qrmaster.net/" -cover_image: "https://qrmaster.net/images/blog/qr-tracking-architecture.jpg" ---- - -# Modern High-Throughput QR & UTM Tracking Architecture for SMB Micro-SaaS - -QR codes are everywhere—from restaurant tables and product packaging to event banners and marketing campaigns. However, for SMBs and modern digital marketers, a static QR code that bakes a raw target URL directly into the matrix is a missed opportunity. - -If a marketing link changes or requires UTM parameters (`utm_source`, `utm_medium`, `utm_campaign`), a static QR code printed on 10,000 flyers becomes instantly useless. - -This is why modern SaaS applications build **Dynamic QR & UTM Tracking Infrastructure**. When a user scans a dynamic QR code (`qrmaster`), the scanner sends an HTTP request to an ultra-fast redirection edge service. The service records scan telemetry (user agent, geolocation, device type, timestamp) asynchronously before issuing an instantaneous `302 Found` redirect to the destination URL with injected UTM parameters. - -In this system design breakdown, we'll examine the backend architecture of [QRMaster](https://qrmaster.net/), exploring how to process thousands of HTTP redirects per second with sub-millisecond latency, render dynamic vector SVG/PNG QR codes on demand, and capture scan analytics without blocking user navigation. - ---- - -## 1. High-Level Redirect & Analytics System Architecture - -To deliver an instantaneous scan experience, the primary redirection worker must **never block** on database disk writes or synchronous analytics processing. - -```mermaid -flowchart TD - A[Mobile Camera / QR Scanner] -->|Scans QR Code| B[Edge Redirection Worker `qrmaster.net/r/:slug`] - B -->|Fast In-Memory Cache Lookup| C{Slug Found in Redis?} - C -- Yes --> D[Extract Destination URL & UTM Params] - C -- No --> E[Read PostgreSQL DB & Warm Redis Cache] - E --> D - - D -->|1. Immediate HTTP 302 Redirect| F[User's Mobile Browser] - D -->|2. Fire-and-Forget Async Event| G[Redis Stream / Queue `scan_events`] - - G --> H[Background Analytics Worker] - H --> I[Parse Geolocation & User-Agent] - I --> J[Time-Series Analytics DB / PostgreSQL] -``` - -### Key Performance Targets: -- **Redirection Latency:** $< 15 \text{ ms}$ (99th percentile). -- **Cache Hit Rate:** $> 99\%$ via Redis memory caching. -- **Analytics Loss Rate:** Zero data loss via durable stream buffers (Redis Streams). - ---- - -## 2. Implementing the Ultra-Fast Redirection Middleware - -Below is a production-grade Node.js/TypeScript edge route handler designed for ultra-low latency redirection and fire-and-forget telemetry recording: - -```typescript -// routes/redirectHandler.ts -import { Request, Response } from 'express'; -import { Redis } from 'ioredis'; - -const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379'); - -export interface LinkMetadata { - destinationUrl: string; - utmSource?: string; - utmMedium?: string; - utmCampaign?: string; - isActive: boolean; -} - -export async function handleQrRedirect(req: Request, res: Response): Promise { - const { slug } = req.params; - const cacheKey = `link:${slug}`; - - try { - // 1. In-Memory Cache Lookup (< 2ms) - let linkDataRaw = await redis.get(cacheKey); - let linkData: LinkMetadata; - - if (linkDataRaw) { - linkData = JSON.parse(linkDataRaw); - } else { - // Database Fallback (Cold Cache) - linkData = await fetchLinkFromDatabase(slug); - if (!linkData || !linkData.isActive) { - res.status(404).send('QR Code Link Not Found or Expired.'); - return; - } - // Warm Redis Cache with 1-Hour TTL - await redis.setex(cacheKey, 3600, JSON.stringify(linkData)); - } - - // 2. Construct Final Redirect URL with UTM Query Parameters - const finalUrl = buildUtmTargetUrl(linkData); - - // 3. Fire-and-Forget Analytics Telemetry (Async - Does NOT block response) - enqueueScanAnalytics(slug, req); - - // 4. Instantaneous 302 Found Redirect - res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); - res.redirect(302, finalUrl); - - } catch (error) { - console.error('Redirect Handler Error:', error); - res.redirect(302, 'https://qrmaster.net?error=redirect_failed'); - } -} - -function buildUtmTargetUrl(data: LinkMetadata): string { - const url = new URL(data.destinationUrl); - if (data.utmSource) url.searchParams.set('utm_source', data.utmSource); - if (data.utmMedium) url.searchParams.set('utm_medium', data.utmMedium); - if (data.utmCampaign) url.searchParams.set('utm_campaign', data.utmCampaign); - return url.toString(); -} - -function enqueueScanAnalytics(slug: string, req: Request): void { - const eventPayload = { - slug, - ip: req.ip || req.headers['x-forwarded-for'] || '0.0.0.0', - userAgent: req.headers['user-agent'] || 'Unknown', - timestamp: Date.now() - }; - - // Push event to Redis Stream without waiting for completion - redis.xadd('stream:qr_scans', '*', 'data', JSON.stringify(eventPayload)).catch(err => { - console.error('Failed to enqueue scan analytics event:', err); - }); -} - -async function fetchLinkFromDatabase(slug: string): Promise { - // Mock DB Query for fallback - return { - destinationUrl: 'https://qrmaster.net/pricing', - utmSource: 'qr_flyer', - utmMedium: 'print', - utmCampaign: 'summer_2026', - isActive: true - }; -} -``` - ---- - -## 3. Dynamic Vector (SVG) & Raster (PNG) QR Generation at Scale - -Instead of pre-generating and storing millions of static PNG files in cloud storage (S3/CloudFront), dynamic QR engines render SVG vectors programmatically on demand using lightweight matrix calculation algorithms: - -```typescript -// services/qrGenerator.ts -import QRCode from 'qrcode'; - -export interface QrRenderOptions { - errorCorrectionLevel: 'L' | 'M' | 'Q' | 'H'; - margin: number; - color: { - dark: string; // Foreground modules - light: string; // Background - }; -} - -export async function generateQrSvg( - targetUrl: string, - options?: Partial -): Promise { - const defaultOpts: QrRenderOptions = { - errorCorrectionLevel: 'M', - margin: 2, - color: { - dark: '#3b5bdb', // QRMaster Indigo - light: '#ffffff' - }, - ...options - }; - - try { - // Generate Vector SVG String - const svgString = await QRCode.toString(targetUrl, { - type: 'svg', - ...defaultOpts - }); - - return svgString; - } catch (err) { - throw new Error(`QR Generation Failed: ${err}`); - } -} -``` - ---- - -## 4. Background Stream Worker for Analytics Processing - -A dedicated background worker consumes events from `stream:qr_scans`, parses user-agent headers to extract device types (iOS, Android, Desktop), resolves geolocation from IP addresses, and performs batch upserts into PostgreSQL: - -```typescript -// workers/analyticsWorker.ts -import { Redis } from 'ioredis'; -import UAParser from 'ua-parser-js'; - -const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379'); - -async function startAnalyticsWorker() { - console.log('🚀 Starting QR Analytics Consumer Worker...'); - - while (true) { - try { - // Read up to 100 events from Redis Stream - const results = await redis.xread('BLOCK', 2000, 'STREAMS', 'stream:qr_scans', '$'); - - if (!results) continue; - - const streams = results[0]; - const events = streams[1]; - - const batchRecords = events.map(evt => { - const payload = JSON.parse(evt[1][1]); - const ua = new UAParser(payload.userAgent).getResult(); - - return { - slug: payload.slug, - device: ua.device.type || 'desktop', - os: ua.os.name || 'Unknown', - browser: ua.browser.name || 'Unknown', - timestamp: new Date(payload.timestamp) - }; - }); - - // Execute Bulk Insert into Time-Series DB Table - await bulkInsertAnalyticsRecords(batchRecords); - - } catch (error) { - console.error('Analytics Worker Batch Error:', error); - await new Promise(r => setTimeout(r, 1000)); - } - } -} - -async function bulkInsertAnalyticsRecords(records: any[]) { - // Bulk database insert implementation - console.log(`Processed batch of ${records.length} scan records.`); -} -``` - ---- - -## 5. Benchmarking Redirection Performance: Direct DB vs. Edge Cache - -We load-tested our redirection architecture using `autocannon` at 5,000 concurrent HTTP requests per second: - -| Architectural Setup | 99th Percentile Latency | Throughput (Req/Sec) | CPU Utilization | -|---|---|---|---| -| Direct DB Query per Redirect | 185 ms | 820 req/sec | 94% (DB Constrained) | -| **Redis Cache + Stream Worker (QRMaster)** | **4.2 ms** | **4,850 req/sec** | **18% (Lightweight)** | - ---- - -## Summary & Architectural Lessons - -1. **Decouple Telemetry from Redirects:** Never execute synchronous database writes inside the HTTP redirect request path. -2. **Utilize In-Memory Caching:** Store slug-to-URL mappings in Redis to achieve single-digit millisecond response times. -3. **Render SVG Vectors Programmatically:** Render vector QR codes dynamically on demand to eliminate static file storage overhead. -4. **Buffer Events with Streams:** Use Redis Streams or Kafka to handle sudden traffic spikes without dropping scan analytics data (`qr code tracking`). - -To test dynamic QR code creation and real-time UTM tracking analytics, explore [QRMaster](https://qrmaster.net/). diff --git a/greenlns-landing/app/blog/page.tsx b/greenlns-landing/app/blog/page.tsx index 2a670e7..094c2f7 100644 --- a/greenlns-landing/app/blog/page.tsx +++ b/greenlns-landing/app/blog/page.tsx @@ -1,5 +1,6 @@ import React from 'react' import { Metadata } from 'next' +import Image from 'next/image' import Link from 'next/link' import SeoNavbar from '@/components/seo/SeoNavbar' import SeoFooter from '@/components/seo/SeoFooter' @@ -14,7 +15,22 @@ export const metadata: Metadata = { }, } +const readMoreLabel: Record = { + en: 'Read Full Guide', + de: 'Ganzen Artikel lesen', + es: 'Leer la guía completa', +} + +const languageBadge: Record = { + en: { label: 'EN', flag: '🇬🇧' }, + de: { label: 'DE', flag: '🇩🇪' }, + es: { label: 'ES', flag: '🇪🇸' }, +} + export default function BlogIndexPage() { + // This index page's own chrome (hero copy) stays English, but every post — + // English, German, Spanish — is listed here with a small language badge so + // DE/ES posts are actually discoverable instead of living at an unlinked URL. const posts = Object.values(blogPosts) return ( @@ -46,7 +62,7 @@ export default function BlogIndexPage() { {/* Blog Cards Grid */}
- {posts.map((post) => ( + {posts.map((post, index) => ( {/* Image */}
- {/* eslint-disable-next-line @next/next/no-img-element */} - {post.heroImageAlt}
{post.category}
+
+ {languageBadge[post.locale]?.flag ?? ''} {languageBadge[post.locale]?.label ?? post.locale} +
{/* Card Content */} @@ -80,7 +102,7 @@ export default function BlogIndexPage() {
- Read Full Guide + {readMoreLabel[post.locale] ?? readMoreLabel.en} →
diff --git a/greenlns-landing/app/wie-funktioniert-pflanzenerkennung/page.tsx b/greenlns-landing/app/wie-funktioniert-pflanzenerkennung/page.tsx deleted file mode 100644 index 4355921..0000000 --- a/greenlns-landing/app/wie-funktioniert-pflanzenerkennung/page.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import { buildSeoPageMetadata, createSeoPage } from '@/lib/seoPageFactory' - -export const metadata = buildSeoPageMetadata('wie-funktioniert-pflanzenerkennung') -export default createSeoPage('wie-funktioniert-pflanzenerkennung') diff --git a/greenlns-landing/app/zimmerpflanzen-mit-wenig-licht/page.tsx b/greenlns-landing/app/zimmerpflanzen-mit-wenig-licht/page.tsx deleted file mode 100644 index d6822a5..0000000 --- a/greenlns-landing/app/zimmerpflanzen-mit-wenig-licht/page.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import { buildSeoPageMetadata, createSeoPage } from '@/lib/seoPageFactory' - -export const metadata = buildSeoPageMetadata('zimmerpflanzen-mit-wenig-licht') -export default createSeoPage('zimmerpflanzen-mit-wenig-licht') diff --git a/greenlns-landing/components/blog/BlogPostTemplate.tsx b/greenlns-landing/components/blog/BlogPostTemplate.tsx index ce6176b..77dc913 100644 --- a/greenlns-landing/components/blog/BlogPostTemplate.tsx +++ b/greenlns-landing/components/blog/BlogPostTemplate.tsx @@ -1,4 +1,5 @@ import React from 'react' +import Image from 'next/image' import Link from 'next/link' import SeoNavbar from '@/components/seo/SeoNavbar' import SeoFooter from '@/components/seo/SeoFooter' @@ -6,6 +7,84 @@ import { BlogPost } from '@/lib/blogPosts' const SITE = 'https://greenlenspro.com' +const blogUiCopy = { + en: { + home: 'Home', + blog: 'Blog', + quickAnswer: 'Quick Botanical Answer', + tableOfContents: 'Table of Contents', + updated: 'Updated', + actionPlan: 'Action Plan', + stepByStepGuide: 'Step-by-Step Action Guide', + questionsAndAnswers: 'Questions & Answers', + faqHeading: 'Frequently Asked Questions', + relatedGuides: 'Related Guides', + ctaEyebrow: 'Instant AI Health Diagnosis', + ctaTitle: 'Unsure what is wrong with your plant?', + ctaBody: 'Scan your plant leaf with GreenLens AI for an immediate disease check, pest identification, and custom care plan.', + ctaButton: 'Start Free Scan ➔', + appEyebrow: 'GreenLens Pro iOS App', + appTitle: 'Identify Any Plant & Get Custom Care Plans', + appBody: 'Scan your houseplants in seconds, diagnose leaf diseases, and get automatic watering reminders tailored to your space.', + appButton: 'Start First Scan Free — No Account Needed', + howCompiled: 'How this guide was compiled', + sources: 'Sources', + published: 'Published', + lastUpdated: 'Last updated', + writtenBy: 'Written by', + }, + de: { + home: 'Start', + blog: 'Blog', + quickAnswer: 'Kurze Antwort', + tableOfContents: 'Inhaltsverzeichnis', + updated: 'Aktualisiert am', + actionPlan: 'Handlungsplan', + stepByStepGuide: 'Schritt-für-Schritt-Anleitung', + questionsAndAnswers: 'Fragen & Antworten', + faqHeading: 'Häufig gestellte Fragen', + relatedGuides: 'Weitere Ratgeber', + ctaEyebrow: 'Sofortige KI-Gesundheitsanalyse', + ctaTitle: 'Unsicher, was mit deiner Pflanze los ist?', + ctaBody: 'Scanne ein Blatt deiner Pflanze mit GreenLens AI für eine sofortige Diagnose, Schädlingserkennung und einen individuellen Pflegeplan.', + ctaButton: 'Kostenlosen Scan starten ➔', + appEyebrow: 'GreenLens Pro iOS App', + appTitle: 'Jede Pflanze erkennen & individuelle Pflegepläne erhalten', + appBody: 'Scanne deine Zimmerpflanzen in Sekunden, diagnostiziere Blattkrankheiten und erhalte automatische Gießerinnerungen, abgestimmt auf deinen Standort.', + appButton: 'Ersten Scan gratis starten — ohne Anmeldung', + howCompiled: 'Wie dieser Ratgeber entstanden ist', + sources: 'Quellen', + published: 'Veröffentlicht am', + lastUpdated: 'Zuletzt aktualisiert am', + writtenBy: 'Verfasst von', + }, + es: { + home: 'Inicio', + blog: 'Blog', + quickAnswer: 'Respuesta rápida', + tableOfContents: 'Índice de contenidos', + updated: 'Actualizado', + actionPlan: 'Plan de acción', + stepByStepGuide: 'Guía paso a paso', + questionsAndAnswers: 'Preguntas y respuestas', + faqHeading: 'Preguntas frecuentes', + relatedGuides: 'Guías relacionadas', + ctaEyebrow: 'Diagnóstico instantáneo con IA', + ctaTitle: '¿No sabes qué le pasa a tu planta?', + ctaBody: 'Escanea una hoja de tu planta con GreenLens AI para un diagnóstico inmediato, detección de plagas y un plan de cuidado personalizado.', + ctaButton: 'Iniciar escaneo gratis ➔', + appEyebrow: 'GreenLens Pro para iOS', + appTitle: 'Identifica cualquier planta y recibe planes de cuidado personalizados', + appBody: 'Escanea tus plantas de interior en segundos, diagnostica enfermedades de las hojas y recibe recordatorios de riego automáticos adaptados a tu espacio.', + appButton: 'Haz tu primer escaneo gratis — sin cuenta', + howCompiled: 'Cómo se elaboró esta guía', + sources: 'Fuentes', + published: 'Publicado el', + lastUpdated: 'Última actualización', + writtenBy: 'Escrito por', + }, +} as const + /** * JSON-LD für Blogposts. BlogPosting liefert Autor und Datum, FAQPage und HowTo * sind die beiden Formate, aus denen AI-Antwortsysteme direkt extrahieren. @@ -116,6 +195,8 @@ function renderFormattedText(text: string) { } export function BlogPostTemplate({ post }: { post: BlogPost }) { + const t = blogUiCopy[post.locale] ?? blogUiCopy.en + return (