Compare commits

...

15 Commits

Author SHA1 Message Date
e441bcb25f weekly seo 2026-06-08 20:33:51 +02:00
d70a50f47a product Hunt 2026-06-01 20:18:48 +02:00
9d80dc8875 SEO 2026-05-27 20:35:54 +02:00
26713b13b1 SEO 2026-05-18 15:59:46 +02:00
2658c37453 App chrash + seo 2026-05-10 22:37:01 +02:00
9386ae1be7 Onboarding 2026-05-08 13:00:30 +02:00
d37b49f1f6 Harte Paywall 2026-04-29 21:17:49 +02:00
0f933da3c9 Merge branch 'main' of git.bizmatch.net:tknuth/Greenlens 2026-04-28 16:18:31 -05:00
0a4dca5faf port 5434 2026-04-28 16:18:05 -05:00
86631a9bc0 Hard paywall 2026-04-28 20:35:53 +02:00
05efbb9910 SEO 2026-04-27 22:23:33 +02:00
3bb0549109 SEO 2026-04-27 17:02:49 +02:00
3e9f863121 Onboarding 2026-04-22 21:37:52 +02:00
c16fee77af email 2026-04-20 19:26:44 +02:00
Timo Knuth
fffcefba62 chore: add gstack skill routing rules to CLAUDE.md 2026-04-17 19:53:20 +02:00
144 changed files with 64091 additions and 17226 deletions

972
5blogpostseo.md Normal file
View File

@@ -0,0 +1,972 @@
# GreenLens Pro — 5 SEO Blog Posts
These posts are written for **greenlenspro.com** and optimized around high-intent SEO keywords. Each post includes: focus keyword, page title, meta description, URL slug, H1, image specifications, internal link structure, full SEO content, and FAQ section.
**Publishing order:**
1. `/plant-disease-identifier`
2. `/why-are-my-plant-leaves-yellow`
3. `/plant-doctor-app`
4. `/how-to-revive-a-dying-plant`
5. `/plant-identifier-app`
---
# Blog Post 1
## Focus Keyword
**plant disease identifier**
## URL Slug
`/plant-disease-identifier`
## Page Title
Plant Disease Identifier App | Scan Sick Plants With AI
## Meta Description
Scan sick plants with GreenLens Pro. Identify possible plant diseases, yellow leaves, brown spots, pests, and get clear next steps.
## H1
Plant Disease Identifier App: Scan Sick Plants With AI
## Image Filename
`plant-disease-identifier-app-greenlens-pro.jpg`
## Image Alt Tag
Plant disease identifier app scanning a sick houseplant with yellow leaves
## Internal Links
- `/why-are-my-plant-leaves-yellow`
- `/plant-doctor-app`
- `/how-to-revive-a-dying-plant`
- `/plant-pest-identification`
## Technical SEO Requirement
Add FAQPage Schema.org JSON-LD for all FAQ questions. Also add BreadcrumbList schema and SoftwareApplication schema for GreenLens Pro.
---
## Plant Disease Identifier App: Scan Sick Plants With AI
A **plant disease identifier** can help you understand what may be wrong with your plant before you start guessing. If your houseplant has yellow leaves, brown spots, curling leaves, sticky residue, weak growth, or signs of pests, it can be difficult to know what is actually happening. Many plant problems look similar at first. Overwatering can look like underwatering. Lack of light can look like nutrient stress. Pest damage can look like dryness. That is exactly why a plant disease identifier app can be useful.
GreenLens Pro is designed to help plant owners scan their plants, check visible symptoms, and get simple guidance about possible plant problems. Instead of reacting too fast with more water, more fertilizer, or repotting, you can slow down and observe the signs first.
This guide explains what a plant disease identifier does, which symptoms you should watch for, why plant diagnosis is harder than it looks, and when you should take action.
## What Is a Plant Disease Identifier?
A plant disease identifier is a tool that helps you analyze symptoms on a plant and understand possible causes. It can be used for houseplants, indoor plants, tropical plants, garden plants, and sometimes flowers. The goal is not only to name the plant, but to help you understand whether the plant may be stressed, infected, damaged, underwatered, overwatered, or affected by pests.
A classic plant identifier focuses mainly on the question:
"What plant is this?"
A plant disease identifier focuses more on the question:
"What is wrong with this plant?"
That difference matters. Many plant owners already know they have a monstera, pothos, snake plant, orchid, or fiddle leaf fig. Their real problem is not identification. Their real problem is that the plant suddenly looks unhealthy.
A plant disease identifier app like GreenLens Pro can help bridge that gap by combining plant scanning, symptom recognition, and care guidance.
## Why Plant Problems Are So Hard To Diagnose
Plant problems are difficult because the same symptom can point to completely different causes. A yellow leaf does not automatically mean your plant needs water. A drooping plant is not always thirsty. Brown spots are not always a disease.
This is where many plant owners make the problem worse. They react to the symptom instead of understanding the cause.
For example:
- **Yellow leaves** can mean overwatering, underwatering, low light, pests, root stress, or natural aging.
- **Drooping leaves** can mean the plant is dry, but they can also mean the roots are damaged and cannot absorb water.
- **Brown spots** can come from sunburn, fungal issues, bacterial problems, dry air, or pest damage.
- **Sticky leaves** are often not a watering issue at all, but a possible pest signal.
That is why guessing is risky. If your plant is overwatered and you water it again, you may make root stress worse. If your plant has pests and you only change the watering schedule, the actual problem continues. If your plant is suffering from low light and you add fertilizer, the plant still does not have enough energy to grow.
GreenLens Pro is built around this exact problem. Instead of treating every symptom the same way, it helps you scan the visible signs, understand possible causes, and decide what to check next.
## Common Symptoms a Plant Disease Identifier Can Help With
### Yellow Leaves
Yellow leaves are one of the most common houseplant problems. They can be harmless if only one old lower leaf is fading. But if many leaves are turning yellow at the same time, it may signal overwatering, root stress, poor light, or nutrient problems.
A plant disease identifier can help you compare yellowing patterns. Are the leaves yellow and soft? Are they yellow and crispy? Are only the lower leaves affected? Are new leaves coming out pale? These details matter.
### Brown Spots
Brown spots can appear for many reasons. Dry brown edges may point toward low humidity or underwatering. Dark, soft spots may suggest overwatering or fungal problems. Pale brown patches may come from sunburn. Tiny dots may suggest pests.
Before cutting leaves or spraying products, it helps to inspect the plant carefully.
### Wilting Leaves
Wilting does not always mean the plant needs water. A plant can wilt from underwatering, but it can also wilt when roots are damaged from overwatering. If the roots cannot take up oxygen and water properly, the plant may look thirsty even when the soil is wet.
This is one of the biggest beginner mistakes: seeing wilted leaves and immediately adding more water.
### Curling Leaves
Curling leaves can happen because of dry soil, low humidity, pests, temperature stress, or too much light. The direction and texture of the curl can help you understand what may be happening.
### Sticky Leaves
Sticky leaves are often a sign that pests may be present. Scale insects, aphids, and mealybugs can leave sticky residue behind. If you notice sticky spots on leaves, stems, or nearby surfaces, inspect the plant closely.
### Weak Growth
If your plant is alive but not growing, the cause may be light, root space, nutrients, temperature, or seasonal dormancy. A plant disease identifier may help you separate normal slow growth from actual plant stress.
## How To Use GreenLens Pro as a Plant Disease Identifier
Using GreenLens Pro is simple:
1. Open the app.
2. Take a clear photo of the plant.
3. Focus on the affected leaves or problem area.
4. Scan the plant.
5. Review the possible diagnosis and care guidance.
6. Compare the suggestions with your plant's environment.
For best results, take photos in natural light. Avoid blurry images, dark corners, or photos where the symptom is too far away. If the problem is on the leaf, take a close-up of the leaf. If the whole plant is drooping, take a wider photo that shows the full plant and pot.
## Plant Disease Identifier vs Plant Identifier
A plant identifier and a plant disease identifier are related, but they are not the same.
A plant identifier helps answer:
- What species is this?
- Is this a pothos, philodendron, monstera, or snake plant?
- What kind of care does this plant usually need?
A plant disease identifier helps answer:
- Why are the leaves yellow?
- Why are there brown spots?
- Why is the plant wilting?
- Could pests be involved?
- What should I check first?
GreenLens Pro combines both angles. You can identify a plant by photo and also use the app to understand possible plant health problems.
## Why You Should Not Guess Too Quickly
Many plant owners react emotionally when a plant looks sick. They want to help, so they do something immediately. They water it again. They move it to a window. They fertilize it. They repot it. They cut leaves. They spray it.
But more action is not always better.
A stressed plant often needs the right action, not more action. If the cause is overwatering, more water makes it worse. If the cause is low light, fertilizer will not solve the main issue. If the cause is pests, changing the watering schedule will not remove the pests.
That is why scanning and observing first can be smarter than guessing.
## What To Check Before Treating a Sick Plant
Before you take action, check these basics:
**Soil Moisture** — Put your finger into the soil or use a moisture meter. Is the soil wet, slightly moist, dry, or compacted? Many problems start in the root zone.
**Light Conditions** — Ask yourself how much real light the plant receives. Bright indoor light is often weaker than people think. A plant sitting far from a window may not receive enough energy to grow well.
**Drainage** — Check whether the pot has drainage holes. If water cannot escape, roots can sit in wet soil for too long.
**Pest Signs** <20><><EFBFBD> Look under leaves, around stems, and near new growth. Common signs include tiny moving dots, webbing, white cotton-like spots, sticky residue, or small bumps on stems.
**Recent Changes** — Did you recently repot, move the plant, fertilize it, water it differently, or expose it to colder temperatures? Sudden changes can trigger stress.
## When To Use a Plant Disease Identifier
You should use a plant disease identifier when:
- Several leaves turn yellow at once
- Brown spots spread quickly
- Leaves become soft, mushy, or black
- The plant wilts even when soil is wet
- You see sticky leaves or pest signs
- Growth suddenly stops
- New leaves come out damaged
- You are unsure whether to water or wait
The earlier you check, the easier it may be to correct the problem.
## Can AI Diagnose Plant Diseases Perfectly?
AI plant diagnosis can be very helpful, but it should be used as guidance, not as a guaranteed laboratory diagnosis. A photo-based plant disease identifier can analyze visible patterns, but plant health also depends on hidden factors such as root condition, soil quality, watering history, humidity, and light exposure.
The best approach is to use AI as a smart first step. GreenLens Pro can help you narrow down likely causes and decide what to inspect next.
## FAQ
- What is a plant disease identifier?
- Can I identify plant disease by photo?
- What is the best plant disease identification app?
- How do I know if my plant is sick?
- Can GreenLens Pro identify plant problems?
- What is a plant problem identifier?
- What is a plant sickness identifier?
- How does an AI plant disease app work?
- Can I use GreenLens Pro for free to identify plant disease?
---
# Blog Post 2
## Focus Keyword
**why are my plant leaves yellow**
## URL Slug
`/why-are-my-plant-leaves-yellow`
## Page Title
Why Are My Plant Leaves Yellow? 9 Common Causes
## Meta Description
Why are my plant leaves yellow? Learn 9 common causes like overwatering, pests, poor light, and when to scan with GreenLens Pro.
## H1
Why Are My Plant Leaves Yellow? 9 Common Causes
## Image Filename
`why-are-my-plant-leaves-yellow-greenlens-pro.jpg`
## Image Alt Tag
Why are my plant leaves yellow on an indoor houseplant
## Internal Links
- `/plant-disease-identifier`
- `/plant-doctor-app`
- `/how-to-revive-a-dying-plant`
- `/plant-pest-identification`
## Technical SEO Requirement
Add FAQPage Schema.org JSON-LD for all FAQ questions. Also add BreadcrumbList schema and SoftwareApplication schema for GreenLens Pro.
---
## Why Are My Plant Leaves Yellow? 9 Common Causes
**Why are my plant leaves yellow?** This is one of the most common questions houseplant owners ask, and the honest answer is: it depends. Yellow leaves can mean your plant is overwatered, underwatered, lacking light, losing old leaves naturally, struggling with pests, sitting in poor soil, or dealing with root stress.
The frustrating part is that many different problems create the same visible symptom. That is why yellow leaves are so easy to misread. A beginner may see yellow leaves and immediately water the plant. But if the real cause is overwatering, that extra water can make the problem worse.
GreenLens Pro can help you scan your plant, inspect visible symptoms, and understand possible causes before you guess. This guide explains the most common reasons plant leaves turn yellow and what you should check first.
## 1. Overwatering
Overwatering is one of the most common causes of yellow leaves. Many plant owners think watering is the safest way to help a stressed plant, but too much water can suffocate the roots.
Roots need oxygen. When soil stays wet for too long, the roots may struggle to breathe. Over time, this can lead to root rot or general root stress. The plant may respond with yellow leaves, soft leaves, wilting, or leaf drop.
Signs of overwatering may include:
- Yellow leaves that feel soft
- Wet soil that stays wet for days
- A heavy pot
- Mushy stems
- Fungus gnats
- Drooping even though the soil is wet
- A bad smell from the soil
If you suspect overwatering, do not water again immediately. Check the soil deeper than the surface. The top may feel dry while the lower soil is still wet.
## 2. Underwatering
Underwatering can also cause yellow leaves. When a plant does not get enough water, it may drop older leaves to conserve energy. Leaves may become yellow, crispy, curled, or dry around the edges.
Signs of underwatering may include:
- Very dry soil
- Soil pulling away from the pot edges
- Crispy leaf tips
- Limp leaves
- A light pot
- Leaves curling inward
The tricky part is that both overwatering and underwatering can cause drooping. That is why you should always check the soil before deciding what to do.
## 3. Not Enough Light
Light is energy for plants. If your plant does not receive enough light, it may not be able to support all of its leaves. Older leaves may turn yellow and drop. Growth may slow down, and new leaves may come out smaller or paler.
This is very common indoors. A room may look bright to you, but it may still be too dark for the plant.
Signs of low light may include:
- Slow growth
- Long, stretched stems
- Small new leaves
- Yellowing lower leaves
- Leaning toward the window
- Soil staying wet for too long
If your plant is in a dark corner, try moving it closer to a bright window. Avoid sudden harsh direct sun if the plant is not used to it.
## 4. Too Much Direct Sun
While low light can cause yellow leaves, too much direct sun can also stress some houseplants. Many tropical indoor plants prefer bright indirect light. Strong direct sun can burn leaves, creating pale yellow patches, brown crispy areas, or scorched spots.
Signs of sun stress may include:
- Yellow or pale patches
- Brown crispy spots
- Damage on leaves facing the window
- Dry edges
- Faded leaf color
If the yellowing appears on the sun-facing side, consider filtering the light with a curtain or moving the plant slightly away from the window.
## 5. Natural Leaf Aging
Not every yellow leaf is a problem. Plants naturally shed older leaves. If one lower leaf slowly turns yellow while the rest of the plant looks healthy, it may simply be aging.
Natural leaf aging usually looks like this:
- One or two older leaves yellow slowly
- New growth looks healthy
- The plant is otherwise stable
- No spreading spots or pests
- No major drooping
In this case, you usually do not need to panic. Remove the leaf once it is fully yellow or comes off easily.
## 6. Nutrient Problems
Plants need nutrients to grow. If the soil is depleted or the plant has been in the same pot for a long time, nutrient problems can appear. Yellowing patterns can vary depending on the nutrient involved.
Possible signs include:
- Pale new growth
- Yellowing between leaf veins
- Slow growth
- Weak stems
- Smaller leaves
However, fertilizer is not always the answer. Fertilizing a stressed or overwatered plant can make things worse. First check water, light, roots, and pests before adding fertilizer.
## 7. Pest Stress
Pests can cause yellow leaves by damaging plant tissue and draining energy from the plant. Common houseplant pests include spider mites, aphids, thrips, scale, fungus gnats, and mealybugs.
Signs of pest problems may include:
- Tiny dots on leaves
- Sticky residue
- Fine webbing
- White cotton-like clusters
- Small insects under leaves
- Silver streaks
- Deformed new growth
- Yellow speckling
Inspect the underside of leaves and new growth carefully. Many pests hide in small spaces.
## 8. Root Stress
Root stress is a hidden cause of yellow leaves. The leaves may show the symptom, but the real problem is below the soil.
Root stress can happen because of:
- Overwatering
- Poor drainage
- Compacted soil
- Root rot
- A pot that is too small
- A recent repot
- Damaged roots
If the plant keeps yellowing and the soil feels wrong, you may need to inspect the roots. Healthy roots are usually firm and light-colored. Rotten roots may be dark, mushy, or smelly.
## 9. Sudden Environmental Changes
Plants can react to sudden changes. Moving a plant from one room to another, changing light exposure, exposing it to cold drafts, repotting, or changing watering habits can trigger yellow leaves.
Common stress triggers include:
- Moving house
- Buying a new plant from a store
- Repotting
- Cold window drafts
- Heating vents
- Air conditioning
- Sudden direct sun
- Temperature drops
If the yellowing started after a recent change, that change may be part of the cause.
## What To Do When Plant Leaves Turn Yellow
Do not immediately water, fertilize, or repot. Follow this process first:
1. Check soil moisture.
2. Look at where the yellow leaves are located.
3. Inspect for pests.
4. Check the light level.
5. Review recent changes.
6. Scan the plant with GreenLens Pro.
7. Make one careful change at a time.
8. Observe the plant for several days.
The goal is not to do everything at once. Too many changes can stress the plant more.
## How GreenLens Pro Can Help
GreenLens Pro helps you scan your plant and understand possible causes of yellow leaves. The app can be useful when you are unsure whether the issue is watering, light, pests, disease, or general stress.
It works best when you take clear photos of the affected leaves and the full plant. You can use the app as a first step before deciding what to change.
## FAQ
- Why are my plant leaves yellow?
- Do yellow leaves mean overwatering?
- Should I cut yellow leaves off my plant?
- Can yellow leaves turn green again?
- Can GreenLens Pro help with yellow leaves?
- What causes yellow leaves on houseplants?
- Is overwatering or underwatering causing yellow leaves?
- How do I fix yellow leaves on my plant?
- Should I remove yellow leaves from my plant?
- Can nutrient deficiency cause yellow leaves?
---
# Blog Post 3
## Focus Keyword
**plant doctor app**
## URL Slug
`/plant-doctor-app`
## Page Title
Plant Doctor App | Diagnose Sick Houseplants With AI
## Meta Description
Use GreenLens Pro as your AI plant doctor. Scan sick houseplants, understand symptoms, and get clear care steps before guessing.
## H1
Plant Doctor App: Diagnose Sick Houseplants Fast
## Image Filename
`plant-doctor-app-sick-houseplant.jpg`
## Image Alt Tag
Plant doctor app checking a sick indoor plant with brown spots
## Internal Links
- `/plant-disease-identifier`
- `/why-are-my-plant-leaves-yellow`
- `/how-to-revive-a-dying-plant`
- `/plant-pest-identification`
## Technical SEO Requirement
Add FAQPage Schema.org JSON-LD for all FAQ questions. Also add BreadcrumbList schema and SoftwareApplication schema for GreenLens Pro.
---
## Plant Doctor App: Diagnose Sick Houseplants Fast
A **plant doctor app** can help you understand what may be wrong with your plant when the leaves turn yellow, brown, soft, curled, sticky, or weak. If you have ever looked at a struggling houseplant and thought, "I have no idea what this plant needs," you are not alone. Plant care can feel simple when everything looks healthy, but it becomes confusing as soon as symptoms appear.
GreenLens Pro works like a plant doctor app for everyday plant owners. You scan your plant, check visible symptoms, and get simple guidance that helps you make a better decision before watering, fertilizing, repotting, or cutting leaves.
This guide explains how a plant doctor app works, when to use one, what symptoms matter, and how GreenLens Pro can help you stop guessing.
## What Is a Plant Doctor App?
A plant doctor app is an app designed to help diagnose possible plant problems. It is not only about identifying the plant species. It is about understanding what the plant may be trying to tell you.
A good plant doctor app helps with questions like:
- Why are my plant leaves yellow?
- Why are the leaves turning brown?
- Why is my plant drooping?
- Does my plant have pests?
- Is my plant overwatered or underwatered?
- What should I do next?
The main value is guidance. A plant doctor app gives you a starting point when you do not know what is wrong.
## Why Plant Owners Need a Plant Doctor App
Most plant owners do not kill plants because they do not care. They kill plants because they care too much in the wrong direction.
A plant looks sad, so they water it.
It still looks sad, so they water it again.
Then they add fertilizer.
Then they move it to direct sun.
Then they repot it.
By the time they realize the issue was root stress or pests, the plant is already weaker.
A plant doctor app helps you pause before reacting. Instead of guessing, you inspect symptoms and possible causes.
## Common Problems a Plant Doctor App Can Help With
### Yellow Leaves
Yellow leaves are one of the biggest reasons people search for plant help. The problem is that yellow leaves are not specific. They can mean too much water, too little water, not enough light, stress, old leaves, poor drainage, nutrient issues, or root damage.
GreenLens Pro can help you look at the pattern and understand what to check next.
### Brown Spots
Brown spots can be dry, crispy, soft, dark, pale, circular, or irregular. Each pattern may point to a different issue. A plant doctor app can help you sort the possibilities instead of treating every brown spot the same way.
### Wilting
Wilting is confusing because both dry plants and overwatered plants can wilt. If the soil is dry and the plant is limp, watering may help. If the soil is wet and the plant is limp, the roots may be struggling. That difference is important.
### Pests
Pests are easy to miss. Spider mites, scale, aphids, thrips, and mealybugs can hide under leaves and near stems. By the time the damage is obvious, the pest population may already be growing.
### Weak Growth
If your plant is alive but not growing, it may not be getting enough light, nutrients, warmth, or root space. It may also simply be growing slowly because of the season. A plant doctor app can help you think through the environment.
## How GreenLens Pro Works as a Plant Doctor App
GreenLens Pro helps you scan your plant and understand possible problems in a simple way.
Here is the basic process:
1. Open GreenLens Pro.
2. Take a clear photo of the plant.
3. Focus on the problem area.
4. Scan the plant.
5. Review possible causes.
6. Follow simple next-step guidance.
The app is especially useful when you are unsure whether the issue is related to water, light, pests, disease, or general stress.
## How To Take a Good Plant Photo
A plant doctor app works best when the photo is clear. Here are some tips:
- Use natural light if possible.
- Avoid strong shadows.
- Take one close-up of the symptom.
- Take one wider photo of the whole plant.
- Include the pot and soil if relevant.
- Do not use a blurry image.
- Photograph both the top and underside of leaves if pests are possible.
The more visible the symptom, the better the analysis can be.
## Plant Doctor App vs Plant Care App
A plant care app usually focuses on ongoing routines. It may remind you to water, track plants, or provide general care tips.
A plant doctor app focuses more on solving a problem.
Plant care app question:
"How often should I water this plant?"
Plant doctor app question:
"Why does this plant look sick?"
GreenLens Pro fits both categories, but its strongest value is helping people diagnose issues when a plant already shows symptoms.
## Why Watering Reminders Are Not Always Enough
Watering reminders can be helpful, but they can also be risky if they are followed blindly. A plant does not need water just because a calendar says so. Water needs depend on light, temperature, pot size, soil type, humidity, season, and plant species.
For example, a plant near a bright window may dry out faster than the same plant in a darker corner. A plant in a small terracotta pot may dry faster than a plant in a large plastic pot.
A plant doctor app encourages observation instead of routine-only care.
## When You Should Act Quickly
Some plant problems need faster attention. Use a plant doctor app and inspect the plant closely if you notice:
- Black, mushy stems
- Rapidly spreading spots
- Sticky residue
- Webbing under leaves
- Sudden leaf drop
- A bad smell from the soil
- Wet soil that stays wet for many days
- White cotton-like pests
These signs may indicate pests, root rot, fungal issues, or serious stress.
## What a Plant Doctor App Cannot See
A photo-based app can analyze visible signs, but some problems are hidden. For example, root rot may only be confirmed by checking roots. Soil compaction may require touching the soil. Drainage issues may require checking the pot.
That is why the best approach is to combine scanning with physical inspection.
Use GreenLens Pro to narrow down likely causes, then check the environment.
## FAQ
- What does a plant doctor app do?
- Can GreenLens Pro diagnose sick plants?
- Is a plant doctor app better than a watering reminder?
- Can a plant doctor app detect pests?
- Should I repot a sick plant immediately?
- How accurate is an AI plant doctor?
- Plant doctor app vs Google Lens — what's the difference?
- Can I diagnose my plant without a vet?
- How does GreenLens Pro work as a plant doctor?
- When should I use a plant doctor app?
---
# Blog Post 4
## Focus Keyword
**how to revive a dying plant**
## URL Slug
`/how-to-revive-a-dying-plant`
## Page Title
How to Revive a Dying Plant: Simple Rescue Steps
## Meta Description
Learn how to revive a dying plant step by step. Check water, roots, light, pests, and scan symptoms with GreenLens Pro before guessing.
## H1
How to Revive a Dying Plant: Simple Rescue Steps
## Image Filename
`how-to-revive-a-dying-plant-greenlens-pro.jpg`
## Image Alt Tag
How to revive a dying plant with GreenLens Pro plant doctor app
## Internal Links
- `/plant-disease-identifier`
- `/plant-doctor-app`
- `/why-are-my-plant-leaves-yellow`
- `/plant-pest-identification`
## Technical SEO Requirement
Add FAQPage Schema.org JSON-LD for all FAQ questions. Also add BreadcrumbList schema and SoftwareApplication schema for GreenLens Pro.
---
## How to Revive a Dying Plant: Simple Rescue Steps
Learning **how to revive a dying plant** starts with one important idea: do not guess too quickly. When a plant looks weak, yellow, brown, droopy, crispy, or almost dead, most people react immediately. They water it again. They move it to another window. They add fertilizer. They cut leaves. They repot it. They spray something on it. Sometimes that helps, but very often it creates even more stress.
A dying plant does not need random care. It needs the right diagnosis first.
That is where GreenLens Pro can help. GreenLens Pro works like an AI plant doctor app that helps you scan your plant, check visible symptoms, and understand possible causes before you take action. If you are asking, "My plant is dying, what do I do?", the answer is not always more water. The answer is to slow down, inspect the signs, and fix the real problem.
In this guide, you will learn how to revive a dying plant step by step. We will go through water, roots, light, pests, soil, leaves, repotting, and recovery signs, so you can make a smarter plant rescue decision.
## First: Is Your Plant Really Dying?
Before you try to revive a dying plant, check whether the plant is truly dying or just stressed. Some plants look dramatic even when the problem is fixable. A peace lily, for example, can droop badly when thirsty and recover after watering. Other plants may drop older leaves naturally while still growing healthy new leaves.
Your plant may still be saveable if:
- Some leaves are still green
- The stems are firm
- The roots are not completely rotten
- There is new growth
- Only a few leaves are yellow
- The plant reacts after watering or better light
- The main stem is still alive
Your plant may be in serious trouble if:
- Most stems are mushy
- The soil smells rotten
- All leaves are dry or black
- Roots are dark, soft, and falling apart
- The plant collapses even though the soil is wet
- Pests have spread heavily
- No healthy growth points remain
Even then, do not throw it away immediately. Some plants can recover from a small healthy stem, node, or cutting.
## Step 1: Scan Your Plant Before You Guess
If you want to know **how to revive a dying plant**, the first step is observation. GreenLens Pro can help you scan visible symptoms and narrow down possible causes. This is useful because many plant problems look similar.
Yellow leaves can mean overwatering, underwatering, low light, pests, nutrient problems, or natural aging. Brown spots can mean sunburn, fungus, dry air, bacterial issues, or root stress. Drooping can mean the plant is thirsty, but it can also mean the roots are damaged from too much water.
To scan your plant with GreenLens Pro:
1. Open the app.
2. Take one clear photo of the full plant.
3. Take one close-up of the damaged leaves.
4. Use natural light if possible.
5. Make sure the image is not blurry.
6. Review the possible diagnosis and care guidance.
A scan is not magic, but it gives you a better starting point than guessing.
## Step 2: Check the Soil Moisture
Soil moisture is one of the biggest clues when trying to revive a dying plant. Most houseplant problems are connected to water in some way. But the key question is not simply, "Did I water it?" The better question is, "What does the soil feel like right now?"
Push your finger into the soil, not just on the surface. The top layer may feel dry while the deeper soil is still wet. You can also lift the pot. A very light pot often means the soil is dry. A heavy pot often means the soil is still holding water.
**If the soil is wet** — If your plant is drooping and the soil is wet, do not water again. This is one of the most common mistakes. A wet, drooping plant may have root stress. The roots may not be able to absorb oxygen properly because the soil has stayed wet for too long. Stop watering, move the plant to bright indirect light, make sure the pot has drainage holes, remove standing water, and inspect roots if the plant keeps declining.
**If the soil is bone dry** — If the soil is completely dry and the plant is limp, crispy, or curling, underwatering may be the issue. Water slowly and thoroughly, let extra water drain out, consider bottom watering if the soil rejects water, remove fully dead leaves, and keep the plant in stable light while it recovers.
## Step 3: Check Drainage and Pot Problems
A dying plant may not have a water problem because you watered too much. It may have a water problem because the pot traps water. Many decorative pots do not have drainage holes. If water collects at the bottom, the roots can sit in wet soil for too long.
Check whether the pot has drainage holes, whether the plant is sitting inside a decorative pot filled with water, whether water flows out when you water, whether the soil is compacted and hard, and whether the pot feels heavy for many days after watering.
If the pot has no drainage, move the plant into a nursery pot with holes. You can still place that nursery pot inside a decorative pot, but always empty extra water after watering.
## Step 4: Inspect the Roots
If you are trying to revive a dying plant and the symptoms keep getting worse, you may need to check the roots. Roots are the hidden part of the diagnosis. Leaves show the symptom, but roots often reveal the cause.
Healthy roots are usually firm and may be white, cream, tan, or light brown. Rotten roots are usually dark, mushy, slimy, and may smell bad.
Inspect the roots if the soil stays wet for too long, the plant droops despite wet soil, stems become soft, leaves turn yellow quickly, the soil smells rotten, you see fungus gnats, or the plant keeps declining after basic care changes.
If you find root rot:
1. Remove the plant from the pot.
2. Gently shake away wet, compacted soil.
3. Cut off mushy roots with clean scissors.
4. Keep firm, healthy roots.
5. Repot into fresh, well-draining soil.
6. Use a pot with drainage.
7. Water carefully after repotting.
8. Keep the plant in bright indirect light.
Do not fertilize immediately after root rot. Let the plant stabilize first.
## Step 5: Check the Light Situation
Light is one of the most underrated reasons plants slowly decline. Many indoor plants die because they receive too little light for too long. Low light also makes soil dry more slowly, which increases the risk of overwatering.
Signs your plant may need more light: slow growth, small new leaves, long stretched stems, yellowing lower leaves, leaning toward the window, soil staying wet too long, weak pale growth.
Signs your plant may get too much direct sun: crispy brown patches, pale burned spots, yellow patches on sun-facing leaves, dry edges, faded leaf color.
Most houseplants prefer bright indirect light. If your plant is in a dark corner, move it closer to a window gradually.
## Step 6: Inspect for Pests
If you want to know **how to save a dying plant**, pests must be part of the checklist. Many pest problems start small and become obvious only after the plant is already weak.
Look closely at undersides of leaves, new growth, stems, leaf joints, soil surface, and sticky areas around the plant.
Common pest signs include fine webbing, tiny moving dots, sticky residue, white cotton-like spots, small brown bumps, yellow speckling, silver streaks, and deformed new leaves.
If you find pests: isolate the plant, remove heavily damaged leaves, rinse or wipe leaves carefully, treat based on the pest type, repeat treatment as needed, and keep checking for new pests.
GreenLens Pro can help you scan suspicious symptoms, but physical inspection is still important because pests often hide under leaves.
## Step 7: Do Not Fertilize Too Early
Fertilizer is not medicine. If your plant is struggling because of root rot, pests, low light, or underwatering, fertilizer will not fix the real issue. In some cases, fertilizer can make things worse by adding stress to damaged roots.
Only fertilize when the plant is stable, roots are healthy, light conditions are good, the plant is actively growing, the soil is not too wet, and you are not dealing with serious pests.
## Step 8: Remove Dead Leaves Carefully
Dead leaves will not turn green again. Removing them can help the plant look cleaner and can reduce places where pests or rot may hide. But do not cut too much at once if the plant is already weak.
Remove leaves that are fully yellow, fully brown, crispy and dead, mushy or rotting, or covered in pests. Leave leaves that are still partly green if the plant does not have many healthy leaves left. Even damaged green leaves can still help the plant produce energy. Use clean scissors and avoid tearing the plant.
## Step 9: Make One Change at a Time
This step matters more than most people think. When your plant is dying, it is tempting to fix everything at once. But if you water it, repot it, move it, fertilize it, prune it, and treat pests all in one day, the plant may become even more stressed.
Instead, make one main change based on the most likely cause. Then observe. Plants recover slowly. New healthy growth is often a better sign than old damaged leaves improving.
## Step 10: Watch for Recovery Signs
A dying plant does not always look better immediately. Damaged leaves may stay damaged. Yellow leaves usually do not turn green again. The important question is whether the plant stops getting worse and starts producing healthier growth.
Good recovery signs include new leaves forming, stems becoming firmer, less drooping, soil drying at a normal pace, no new yellow leaves, no spreading brown spots, roots looking firmer, and pest activity decreasing.
Recovery may take days, weeks, or even months depending on the plant and the problem.
## Quick Diagnosis Guide
**Drooping plant with wet soil** — Possible cause: overwatering or root stress. Best next step: stop watering, check drainage, inspect roots.
**Drooping plant with dry soil** — Possible cause: underwatering. Best next step: water thoroughly and let excess water drain.
**Yellow leaves and wet soil** — Possible cause: overwatering. Best next step: pause watering and improve light or drainage.
**Yellow leaves and dry soil** — Possible cause: underwatering. Best next step: water properly and monitor.
**Brown crispy edges** — Possible cause: underwatering, dry air, sun stress, or salt buildup. Best next step: check soil, humidity, light, and watering consistency.
**Sticky leaves** — Possible cause: pests. Best next step: isolate plant and inspect under leaves.
**Black mushy stems** — Possible cause: rot. Best next step: inspect roots and remove rotten tissue.
## How GreenLens Pro Helps You Save a Dying Plant
GreenLens Pro helps you understand what may be happening before you act. Instead of searching through dozens of plant forums or guessing from one symptom, you can scan your plant and get simple guidance.
GreenLens Pro can help with yellow leaves, brown spots, drooping plants, pest-like damage, weak growth, plant identification, plant health checks, and plant rescue decisions. The app is especially useful for beginner plant owners because it turns confusing symptoms into a clearer next step.
## FAQ
- How do I revive a dying plant fast?
- Should I water a dying plant?
- Can a dying plant come back to life?
- Should I cut off dead leaves?
- Should I repot a dying plant?
- Can GreenLens Pro help me save a dying plant?
- Why is my plant dying even though I water it?
- How long does it take to revive a dying plant?
- How do I know if my plant has root rot?
- What is the best plant rescue app?
---
# Blog Post 5
## Focus Keyword
**plant identifier app**
## URL Slug
`/plant-identifier-app`
## Page Title
Plant Identifier App: Identify Plants by Photo With AI
## Meta Description
Identify plants by photo with GreenLens Pro. Scan houseplants, learn their name, and get simple care guidance in seconds.
## H1
Plant Identifier App: Identify Plants by Photo With AI
## Image Filename
`plant-identifier-app-by-photo-greenlens-pro.jpg`
## Image Alt Tag
Plant identifier app identifying a houseplant by photo
## Internal Links
- `/plant-disease-identifier`
- `/plant-doctor-app`
- `/free-plant-id-app`
- `/why-are-my-plant-leaves-yellow`
## Technical SEO Requirement
Add FAQPage Schema.org JSON-LD for all FAQ questions. Also add BreadcrumbList schema and SoftwareApplication schema for GreenLens Pro.
---
## Plant Identifier App: Identify Plants by Photo With AI
A **plant identifier app** helps you identify plants by photo and understand what kind of care they may need. If you have ever bought a plant without a label, received a cutting from a friend, or found a beautiful plant online and wanted to know its name, a plant identifier app can save a lot of time.
GreenLens Pro is designed to help you scan a plant, identify it, and get useful care guidance. But it goes one step further. Many plant owners do not only want to know the plant's name. They also want to know why the plant looks sick, why the leaves are yellow, or what to do if it starts declining.
This guide explains how plant identifier apps work, what to look for in a good plant identification app, and how GreenLens Pro can help you identify and care for your plants.
## What Is a Plant Identifier App?
A plant identifier app is a tool that uses a photo to help recognize a plant species. You take a picture of the plant, leaf, flower, stem, or overall shape, and the app compares visual patterns to provide an identification.
A plant identifier app can be useful for houseplants, garden plants, flowers, tropical plants, succulents, cuttings, unknown plants from a store, and plants without labels. The goal is simple: take a photo and learn what plant you are looking at.
## Why Plant Identification Matters
Plant identification matters because different plants need different care. A pothos, orchid, cactus, fern, and fiddle leaf fig do not all want the same conditions.
If you do not know what plant you have, it is hard to answer basic care questions:
- How much light does it need?
- How often should I water it?
- Does it like humidity?
- Is it sensitive to direct sun?
- Is it toxic to pets?
- Is it normal for leaves to drop?
A plant identifier app gives you the starting point. Once you know the plant, you can care for it more intelligently.
## How Does a Plant Identifier App Work?
Most plant identifier apps use image recognition. When you upload or take a photo, the app analyzes visible features such as leaf shape, leaf color, leaf texture, growth pattern, flower shape, stem structure, plant size, and arrangement of leaves.
The app then compares the image with known plant patterns and suggests possible matches.
For best results, the photo should be clear and focused. A blurry photo of a single damaged leaf may not be enough to identify the plant accurately. A wider photo showing the full plant can help.
## How To Identify a Plant by Photo
To identify a plant by photo with GreenLens Pro:
1. Open GreenLens Pro.
2. Place the plant in good light.
3. Take a clear photo of the full plant.
4. Add a close-up of the leaf if needed.
5. Scan the plant.
6. Review the suggested identification.
7. Read the care guidance.
If the plant has flowers, include them in the photo. Flowers can make identification easier. If the plant has unique leaves, photograph those clearly.
## Best Photo Tips for Plant Identification
Use natural light to help the app see the true color and shape of the plant. Show the whole plant in a wide photo to help with growth pattern and structure. Add a close-up of the leaf to show shape, edges, veins, and texture. Avoid cluttered backgrounds so the plant stands out clearly. Do not photograph only damaged leaves — if the goal is identification, photograph healthy parts too.
## Plant Identifier App vs Plant Disease Identifier
A plant identifier app tells you what the plant is. A plant disease identifier helps you understand what might be wrong with it.
Both are useful, but they solve different problems.
If you found an unknown plant, use a plant identifier app.
If your plant has yellow leaves, brown spots, pests, or wilting, use a plant disease identifier.
GreenLens Pro is useful because it supports both plant recognition and plant problem guidance.
## Why GreenLens Pro Is Useful for Houseplant Owners
Houseplant owners often face two main problems: they do not know what plant they have, and they do not know what is wrong when the plant looks sick.
GreenLens Pro helps with both. You can identify the plant by photo and then use the app to understand possible care needs or symptoms. This is especially helpful for beginner plant owners who may not know the difference between pothos, philodendron, scindapsus, monstera, or other common indoor plants.
## What To Do After Identifying a Plant
Once you identify your plant, do not stop there. The next step is understanding its care needs.
Check light requirements, watering preferences, soil type, humidity needs, growth speed, common problems, and toxicity information.
If your plant is healthy, use this information to build a good care routine. If your plant looks sick, scan the symptoms with GreenLens Pro and compare them with common problems for that plant.
## Why "Free Plant ID App" Searches Are Popular
Many people search for free plant ID apps because they want a quick answer without paying upfront. Plant identification often starts as a simple curiosity.
But the deeper value comes after identification. Once the app tells you the plant name, you still need to care for it. That is where GreenLens Pro can become more helpful than a basic identification tool. It connects plant identification with plant care and plant health guidance.
## Limitations of Plant Identification Apps
Plant identifier apps are powerful, but no app is perfect. Identification may be harder when the photo is blurry, the plant is very young, the plant is damaged, the plant has no flowers, the photo shows only one leaf, several species look very similar, or the lighting changes the plant color.
That is why it is smart to take multiple photos and compare the result with the plant's visible features.
## FAQ
- What is a plant identifier app?
- Can I identify a plant from a photo?
- What photo should I use for plant identification?
- Is GreenLens Pro only for identifying plants?
- Why should I identify my plant?
- Can GreenLens Pro help with plant health too?
- How accurate is a plant identifier app?
- Is there a free plant identification app?
- What plants can GreenLens Pro identify?
- How many plant species can GreenLens Pro recognize?

View File

@@ -90,3 +90,23 @@ The production-style stack lives in `greenlns-landing/docker-compose.yml` and in
- Nested plant metadata such as `categories` and `careInfo` uses `JSONB`.
- Billing idempotency responses also use `JSONB`.
- SQL placeholders use PostgreSQL syntax: `$1`, `$2`, ...
## Skill routing
When the user's request matches an available skill, ALWAYS invoke it using the Skill
tool as your FIRST action. Do NOT answer directly, do NOT use other tools first.
The skill has specialized workflows that produce better results than ad-hoc answers.
Key routing rules:
- Product ideas, "is this worth building", brainstorming -> invoke office-hours
- Bugs, errors, "why is this broken", 500 errors -> invoke investigate
- Ship, deploy, push, create PR -> invoke ship
- QA, test the site, find bugs -> invoke qa
- Code review, check my diff -> invoke review
- Update docs after shipping -> invoke document-release
- Weekly retro -> invoke retro
- Design system, brand -> invoke design-consultation
- Visual audit, design polish -> invoke design-review
- Architecture review -> invoke plan-eng-review
- Save progress, checkpoint, resume -> invoke checkpoint
- Code quality, health check -> invoke health

17
TODOS.md Normal file
View File

@@ -0,0 +1,17 @@
# TODOS
## Review
### Surface user-facing rescue proof once outcome data is trustworthy
**What:** Add a follow-up feature that exposes selective Plant ER proof to users, such as recovery progress or similar successful rescue cases.
**Why:** Outcome proof can become a major trust advantage, but only after GreenLns has enough honest rescue data to avoid fake or thin social proof.
**Context:** The CEO review for the Plant ER expansion decided that rescue outcomes should be tracked internally first and only become user-facing once the data is reliable. The immediate scope includes triage, rescue episodes, follow-ups, and internal outcome tracking. This TODO is the next step after those systems produce trustworthy recovery signals.
**Effort:** M
**Priority:** P2
**Depends on:** Rescue episode model, follow-up re-checks, and internal outcome tracking shipping first
## Completed

View File

@@ -0,0 +1,97 @@
import React from 'react';
import { render, waitFor } from '@testing-library/react-native';
import LexiconScreen from '../../app/lexicon';
const mockSearchPlants = jest.fn().mockResolvedValue([]);
jest.mock('expo-router', () => ({
useRouter: () => ({
back: jest.fn(),
}),
useLocalSearchParams: () => ({
categoryId: 'easy',
}),
}));
jest.mock('react-native-safe-area-context', () => {
const React = require('react');
const { View } = require('react-native');
return {
SafeAreaView: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
};
});
jest.mock('../../context/AppContext', () => ({
useApp: () => ({
isDarkMode: false,
colorPalette: 'forest',
language: 'de',
t: {
lexiconTitle: 'Pflanzen-Lexikon',
lexiconSearchPlaceholder: 'Lexikon durchsuchen...',
noResults: 'Keine Pflanzen gefunden.',
searchHistory: 'Suchverlauf',
clearHistory: 'Verlauf löschen',
},
savePlant: jest.fn(),
getLexiconSearchHistory: jest.fn(() => []),
saveLexiconSearchQuery: jest.fn(),
clearLexiconSearchHistory: jest.fn(),
}),
}));
jest.mock('../../constants/Colors', () => ({
useColors: () => ({
background: '#ffffff',
text: '#111111',
textSecondary: '#444444',
textMuted: '#666666',
cardBg: '#ffffff',
cardBorder: '#dddddd',
inputBorder: '#cccccc',
cardShadow: '#000000',
danger: '#b91c1c',
primaryDark: '#1f5a37',
surface: '#f5f5f5',
chipBg: '#f3f4f6',
chipBorder: '#d1d5db',
}),
}));
jest.mock('../../components/ThemeBackdrop', () => ({
ThemeBackdrop: () => null,
}));
jest.mock('../../components/ResultCard', () => ({
ResultCard: () => null,
}));
jest.mock('../../components/SafeImage', () => ({
SafeImage: () => null,
}));
jest.mock('../../services/plantDatabaseService', () => ({
PlantDatabaseService: {
searchPlants: (...args: unknown[]) => mockSearchPlants(...args),
},
}));
describe('LexiconScreen category initialization', () => {
beforeEach(() => {
mockSearchPlants.mockClear();
});
it('uses an empty query with the selected category filter', async () => {
const { getByPlaceholderText } = render(<LexiconScreen />);
await waitFor(() => {
expect(mockSearchPlants).toHaveBeenCalledWith('', 'de', {
category: 'easy',
limit: 500,
});
});
expect(getByPlaceholderText('Lexikon durchsuchen...').props.value).toBe('');
});
});

View File

@@ -0,0 +1,112 @@
import React from 'react';
import { fireEvent, render } from '@testing-library/react-native';
import SearchScreen from '../../app/(tabs)/search';
const mockPush = jest.fn();
jest.mock('expo-router', () => ({
useRouter: () => ({
push: mockPush,
}),
}));
jest.mock('react-native-safe-area-context', () => {
const React = require('react');
const { View } = require('react-native');
return {
SafeAreaView: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
};
});
jest.mock('../../context/AppContext', () => ({
useApp: () => ({
plants: [],
isDarkMode: false,
colorPalette: 'forest',
language: 'de',
billingSummary: { credits: { available: 5 } },
refreshBillingSummary: jest.fn().mockResolvedValue(undefined),
t: {
searchTitle: 'Suche',
searchPlaceholder: 'Pflanzen suchen...',
catCareEasy: 'Pflegeleicht',
catLowLight: 'Wenig Licht',
catBrightLight: 'Helles Licht',
catSun: 'Sonnig',
catPetFriendly: 'Tierfreundlich',
catAirPurifier: 'Luftreiniger',
catHighHumidity: 'Hohe Luftfeuchte',
catHanging: 'Hängend',
catPatterned: 'Gemustert',
catFlowering: 'Blühend',
catSucculents: 'Sukkulenten',
catTree: 'Bäume',
catLarge: 'Groß',
catMedicinal: 'Heilpflanzen',
lexiconTitle: 'Pflanzen-Lexikon',
lexiconDesc: 'Lexikon Beschreibung',
browseLexicon: 'Im Lexikon stöbern',
},
}),
}));
jest.mock('../../constants/Colors', () => ({
useColors: () => ({
background: '#ffffff',
text: '#111111',
textSecondary: '#444444',
textMuted: '#666666',
cardBg: '#ffffff',
cardBorder: '#dddddd',
cardShadow: '#000000',
chipBorder: '#dddddd',
successTint: '#dff5e3',
success: '#2d8a4b',
infoTint: '#d9f1ff',
info: '#2469a7',
primaryTint: '#e8f2ea',
primaryDark: '#1f5a37',
warningTint: '#fff3d6',
warning: '#b7791f',
dangerTint: '#fde3e3',
danger: '#b91c1c',
surfaceStrong: '#eeeeee',
surface: '#f5f5f5',
heroButtonBorder: '#cad7cc',
iconOnImage: '#ffffff',
textOnImage: '#ffffff',
heroButton: '#dce9df',
primary: '#2f855a',
onPrimary: '#ffffff',
fabShadow: '#000000',
}),
}));
jest.mock('../../components/ThemeBackdrop', () => ({
ThemeBackdrop: () => null,
}));
jest.mock('../../services/plantDatabaseService', () => ({
PlantDatabaseService: {
searchPlants: jest.fn().mockResolvedValue([]),
},
}));
describe('SearchScreen category navigation', () => {
beforeEach(() => {
mockPush.mockClear();
});
it('navigates to lexicon with categoryId only when a category chip is tapped', () => {
const { getByText } = render(<SearchScreen />);
fireEvent.press(getByText('Pflegeleicht'));
expect(mockPush).toHaveBeenCalledWith({
pathname: '/lexicon',
params: {
categoryId: 'easy',
},
});
});
});

View File

@@ -0,0 +1,40 @@
jest.mock('../../server/lib/postgres', () => ({
get: jest.fn(),
run: jest.fn(),
}));
const { get, run } = require('../../server/lib/postgres');
const { deleteAccount, signUp } = require('../../server/lib/auth');
describe('server auth account deletion', () => {
beforeEach(() => {
jest.clearAllMocks();
get.mockResolvedValue(null);
run.mockResolvedValue({ lastId: null, changes: 1, rows: [] });
});
it('removes auth and billing rows so the same email can sign up again', async () => {
const email = 'same@example.com';
await signUp({}, email, 'First User', 'password-1');
await deleteAccount({}, 'usr_deleted');
await signUp({}, email, 'Second User', 'password-2');
const authDeletes = run.mock.calls.filter(([, sql]) => (
typeof sql === 'string' && sql.includes('DELETE FROM auth_users')
));
expect(authDeletes).toHaveLength(1);
const billingAccountDeletes = run.mock.calls.filter(([, sql]) => (
typeof sql === 'string' && sql.includes('DELETE FROM billing_accounts')
));
expect(billingAccountDeletes).toHaveLength(1);
const signupChecks = get.mock.calls.filter(([, sql], params) => (
typeof sql === 'string'
&& sql.includes('SELECT id FROM auth_users WHERE LOWER(email)')
&& params?.[0] === email
));
expect(signupChecks).toHaveLength(2);
});
});

View File

@@ -1,5 +1,6 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { mockBackendService } from '../../services/backend/mockBackendService';
import { openAiScanService } from '../../services/backend/openAiScanService';
jest.mock('@react-native-async-storage/async-storage', () => ({
getItem: jest.fn(),
@@ -28,6 +29,32 @@ const runScan = async (userId: string, idempotencyKey: string) => {
return settled.value;
};
const runHealthCheck = async (userId: string, idempotencyKey: string) => {
const settledPromise = mockBackendService.healthCheck({
userId,
idempotencyKey,
imageUri: `data:image/jpeg;base64,${idempotencyKey}`,
language: 'en',
plantContext: {
name: 'Monstera',
botanicalName: 'Monstera deliciosa',
careInfo: {
waterIntervalDays: 7,
light: 'Bright indirect light',
temp: '18-24C',
},
},
}).then(
value => ({ ok: true as const, value }),
error => ({ ok: false as const, error }),
);
await Promise.resolve();
await jest.runAllTimersAsync();
const settled = await settledPromise;
if (!settled.ok) throw settled.error;
return settled.value;
};
describe('mockBackendService billing simulation', () => {
beforeEach(() => {
jest.useFakeTimers();
@@ -50,6 +77,7 @@ describe('mockBackendService billing simulation', () => {
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
jest.clearAllMocks();
});
@@ -68,12 +96,17 @@ describe('mockBackendService billing simulation', () => {
productId: 'topup_small',
});
expect(first.billing.credits.topupBalance).toBe(25);
expect(second.billing.credits.topupBalance).toBe(25);
expect(first.billing.credits.topupBalance).toBe(30);
expect(second.billing.credits.topupBalance).toBe(30);
});
it('consumes plan credits before topup credits', async () => {
const userId = 'test-user-credit-order';
await mockBackendService.simulatePurchase({
userId,
idempotencyKey: 'sub-order-1',
productId: 'monthly_pro',
});
await mockBackendService.simulatePurchase({
userId,
idempotencyKey: 'topup-order-1',
@@ -82,14 +115,25 @@ describe('mockBackendService billing simulation', () => {
let lastScan = await runScan(userId, 'scan-order-0');
expect(lastScan.billing.credits.usedThisCycle).toBe(1);
expect(lastScan.billing.credits.topupBalance).toBe(25);
expect(lastScan.billing.credits.topupBalance).toBe(30);
for (let i = 1; i <= 15; i += 1) {
lastScan = await runScan(userId, `scan-order-${i}`);
let scanIndex = 1;
while (
lastScan.billing.credits.usedThisCycle < 100
&& lastScan.billing.credits.topupBalance === 30
&& scanIndex < 150
) {
lastScan = await runScan(userId, `scan-order-${scanIndex}`);
scanIndex += 1;
}
expect(lastScan.billing.credits.usedThisCycle).toBe(15);
expect(lastScan.billing.credits.topupBalance).toBe(24);
if (lastScan.billing.credits.topupBalance === 30) {
lastScan = await runScan(userId, `scan-order-${scanIndex}`);
}
expect(lastScan.billing.credits.usedThisCycle).toBe(100);
expect(lastScan.billing.credits.topupBalance).toBeLessThan(30);
expect(lastScan.billing.credits.topupBalance).toBeGreaterThanOrEqual(0);
});
it('can deplete all available credits via webhook simulation', async () => {
@@ -113,32 +157,67 @@ describe('mockBackendService billing simulation', () => {
it('does not double-charge scan when idempotency key is reused', async () => {
const userId = 'test-user-scan-idempotency';
await mockBackendService.simulatePurchase({
userId,
idempotencyKey: 'sub-scan-idempotency',
productId: 'monthly_pro',
});
const first = await runScan(userId, 'scan-abc');
const second = await runScan(userId, 'scan-abc');
expect(first.creditsCharged).toBe(1);
expect(second.creditsCharged).toBe(1);
expect(first.creditsCharged).toBeGreaterThan(0);
expect(second.creditsCharged).toBe(first.creditsCharged);
expect(second.billing.credits.available).toBe(first.billing.credits.available);
});
it('enforces free monthly credit limit', async () => {
it('charges one credit for a normal scan after a two-credit health check', async () => {
const userId = 'test-user-health-then-scan-cost';
jest.spyOn(openAiScanService, 'isConfigured').mockReturnValue(true);
jest.spyOn(openAiScanService, 'analyzePlantHealth').mockResolvedValue({
overallHealthScore: 72,
status: 'watch',
analysisSummary: 'Mild stress signs are visible.',
likelyIssues: [
{
title: 'Watering stress',
confidence: 0.62,
details: 'The leaf texture suggests inconsistent watering.',
},
],
actionsNow: ['Check soil moisture before watering.'],
plan7Days: ['Take a comparison photo in one week.'],
});
await mockBackendService.simulatePurchase({
userId,
idempotencyKey: 'sub-health-then-scan-cost',
productId: 'monthly_pro',
});
const healthCheck = await runHealthCheck(userId, 'health-cost-1');
expect(healthCheck.creditsCharged).toBe(2);
expect(healthCheck.billing.credits.usedThisCycle).toBe(2);
const scan = await runScan(userId, 'scan-after-health-cost-1');
expect(scan.modelPath).toContain('mock-review');
expect(scan.creditsCharged).toBe(1);
expect(scan.billing.credits.usedThisCycle).toBe(3);
});
it('blocks free users from real scans', async () => {
const userId = 'test-user-credit-limit';
let successfulScans = 0;
let errorCode: string | null = null;
for (let i = 0; i < 30; i += 1) {
try {
await runScan(userId, `scan-${i}`);
successfulScans += 1;
} catch (error) {
errorCode = (error as { code?: string }).code || null;
break;
}
try {
await runScan(userId, 'scan-free-hard-paywall');
successfulScans += 1;
} catch (error) {
errorCode = (error as { code?: string }).code || null;
}
expect(errorCode).toBe('INSUFFICIENT_CREDITS');
expect(successfulScans).toBeGreaterThanOrEqual(7);
expect(successfulScans).toBeLessThanOrEqual(15);
expect(successfulScans).toBe(0);
});
it('syncs pro entitlement from RevenueCat customer info', async () => {
@@ -162,6 +241,70 @@ describe('mockBackendService billing simulation', () => {
expect(response.billing.entitlement.renewsAt).toBe('2026-04-30T00:00:00.000Z');
});
it('limits RevenueCat trial entitlement to trial credits', async () => {
const response = await mockBackendService.syncRevenueCatState({
userId: 'test-user-rc-trial',
customerInfo: {
entitlements: {
active: {
pro: {
productIdentifier: 'monthly_pro',
expirationDate: '2026-04-30T00:00:00.000Z',
periodType: 'TRIAL',
},
},
},
nonSubscriptions: {},
},
});
expect(response.billing.entitlement.plan).toBe('pro');
expect(response.billing.credits.monthlyAllowance).toBe(30);
expect(response.billing.credits.available).toBe(30);
});
it('resets trial usage when RevenueCat trial converts to paid pro', async () => {
const userId = 'test-user-rc-trial-converts';
await mockBackendService.syncRevenueCatState({
userId,
customerInfo: {
entitlements: {
active: {
pro: {
productIdentifier: 'monthly_pro',
expirationDate: '2026-04-30T00:00:00.000Z',
periodType: 'TRIAL',
},
},
},
nonSubscriptions: {},
},
});
const trialScan = await runScan(userId, 'trial-conversion-scan');
expect(trialScan.billing.credits.usedThisCycle).toBeGreaterThan(0);
const paidResponse = await mockBackendService.syncRevenueCatState({
userId,
customerInfo: {
entitlements: {
active: {
pro: {
productIdentifier: 'monthly_pro',
expirationDate: '2026-05-30T00:00:00.000Z',
periodType: 'NORMAL',
},
},
},
nonSubscriptions: {},
},
});
expect(paidResponse.billing.credits.monthlyAllowance).toBe(100);
expect(paidResponse.billing.credits.usedThisCycle).toBe(0);
expect(paidResponse.billing.credits.available).toBe(100);
});
it('credits RevenueCat top-up transactions only once', async () => {
const userId = 'test-user-rc-topup';
await mockBackendService.syncRevenueCatState({
@@ -194,7 +337,7 @@ describe('mockBackendService billing simulation', () => {
},
});
expect(second.billing.credits.topupBalance).toBe(25);
expect(second.billing.credits.topupBalance).toBe(30);
});
it('ignores malformed pro entitlements coming from top-up customer info', async () => {
@@ -223,8 +366,8 @@ describe('mockBackendService billing simulation', () => {
expect(response.billing.entitlement.plan).toBe('free');
expect(response.billing.entitlement.status).toBe('inactive');
expect(response.billing.credits.topupBalance).toBe(25);
expect(response.billing.credits.available).toBe(40);
expect(response.billing.credits.topupBalance).toBe(30);
expect(response.billing.credits.available).toBe(0);
});
it('does not downgrade an existing pro user during a top-up sync', async () => {
@@ -270,7 +413,7 @@ describe('mockBackendService billing simulation', () => {
});
expect(response.billing.entitlement.plan).toBe('pro');
expect(response.billing.credits.available).toBe(275);
expect(response.billing.credits.topupBalance).toBe(25);
expect(response.billing.credits.available).toBe(130);
expect(response.billing.credits.topupBalance).toBe(30);
});
});

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "GreenLens",
"slug": "greenlens",
"version": "2.2.2",
"version": "2.2.7",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -17,8 +17,9 @@
],
"ios": {
"supportsTablet": true,
"usesAppleSignIn": true,
"bundleIdentifier": "com.greenlens.app",
"buildNumber": "36",
"buildNumber": "42",
"infoPlist": {
"NSCameraUsageDescription": "GreenLens needs camera access to identify plants.",
"NSPhotoLibraryUsageDescription": "GreenLens needs photo library access to identify plants from your gallery.",
@@ -31,7 +32,7 @@
"backgroundColor": "#111813"
},
"package": "com.greenlens.app",
"versionCode": 3,
"versionCode": 5,
"permissions": [
"android.permission.CAMERA",
"android.permission.RECORD_AUDIO"
@@ -46,7 +47,23 @@
"plugins": [
"expo-dev-client",
"expo-router",
[
"expo-share-intent",
{
"iosActivationRules": {
"NSExtensionActivationSupportsText": true,
"NSExtensionActivationSupportsWebURLWithMaxCount": 1,
"NSExtensionActivationSupportsWebPageWithMaxCount": 1,
"NSExtensionActivationSupportsImageWithMaxCount": 1
},
"androidIntentFilters": ["text/*", "image/*"],
"iosShareExtensionName": "GreenLens Share",
"iosAppGroupIdentifier": "group.com.greenlens.app",
"preprocessorInjectJS": "try{function glAddCandidate(list,value){if(value&&typeof value==='string'&&list.indexOf(value)===-1){list.push(value)}} function glSrcsetCandidate(value){if(!value||typeof value!=='string')return null;var parts=value.split(',').map(function(item){return item.trim().split(/\\s+/)[0]}).filter(Boolean);return parts.length?parts[parts.length-1]:null} function glEach(selector,callback){var nodes=document.querySelectorAll(selector);for(var i=0;i<nodes.length;i++){callback(nodes[i])}} var glCandidates=[];['og:image','og:image:url','og:image:secure_url','twitter:image','twitter:image:src','image'].forEach(function(key){glAddCandidate(glCandidates,metas[key])}); glEach('meta[itemprop=\"image\"]',function(meta){glAddCandidate(glCandidates,meta.getAttribute('content'))}); glEach('img',function(img){glAddCandidate(glCandidates,img.currentSrc);glAddCandidate(glCandidates,img.src);glAddCandidate(glCandidates,img.getAttribute('data-src'));glAddCandidate(glCandidates,img.getAttribute('data-original'));glAddCandidate(glCandidates,img.getAttribute('data-lazy-src'));glAddCandidate(glCandidates,glSrcsetCandidate(img.getAttribute('srcset')))}); glEach('picture source,source',function(source){glAddCandidate(glCandidates,source.src);glAddCandidate(glCandidates,source.getAttribute('src'));glAddCandidate(glCandidates,glSrcsetCandidate(source.getAttribute('srcset')))}); glEach('video',function(video){glAddCandidate(glCandidates,video.getAttribute('poster'))}); glEach('[style*=\"background-image\"]',function(el){var bg=(el.style&&el.style.backgroundImage)||'';var match=bg.match(/url\\([\"']?([^\"')]+)[\"']?\\)/);if(match){glAddCandidate(glCandidates,match[1])}});metas['greenlens:imageCandidates']=JSON.stringify(glCandidates.slice(0,12));metas['greenlens:imageCandidateCount']=String(glCandidates.length);metas['og:image']=metas['og:image']||glCandidates[0]}catch(e){metas['greenlens:preprocessorError']=String(e)}"
}
],
"expo-camera",
"expo-apple-authentication",
"expo-image-picker",
"expo-secure-store",
"expo-asset",

View File

@@ -12,14 +12,17 @@ import {
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { useFocusEffect } from '@react-navigation/native';
import { Ionicons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useSafeAnalytics } from '../../services/analytics';
import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { SafeImage } from '../../components/SafeImage';
import { Plant } from '../../types';
import { useCoachMarks } from '../../context/CoachMarksContext';
import { OnboardingProgressService } from '../../services/onboardingProgressService';
const { width: SCREEN_W, height: SCREEN_H } = Dimensions.get('window');
@@ -29,32 +32,141 @@ const DAY_MS = 24 * 60 * 60 * 1000;
const CONTENT_BOTTOM_PADDING = 12;
const FAB_BOTTOM_OFFSET = 16;
function OnboardingChecklist({ plantsCount, colors, router, t }: { plantsCount: number; colors: any; router: any; t: any }) {
type OnboardingStepId = 'scan' | 'reminder' | 'lexicon' | 'theme' | 'collection';
function OnboardingChecklist({
plants,
appearanceMode,
colorPalette,
lexiconExplored,
customizationDone,
colors,
router,
t,
getLexiconSearchHistory,
registerLayout,
posthog,
}: {
plants: Plant[];
appearanceMode: string;
colorPalette: string;
lexiconExplored: boolean;
customizationDone: boolean;
colors: any;
router: any;
t: any;
getLexiconSearchHistory: () => string[];
registerLayout: (key: string, layout: { x: number; y: number; width: number; height: number }) => void;
posthog: any;
}) {
const cardRef = useRef<View>(null);
const previousProgressRef = useRef<number | null>(null);
const lexiconHistoryCount = getLexiconSearchHistory().length;
const plantsCount = plants.length;
const reminderReady = plants.some((plant) => Boolean(plant.notificationsEnabled));
const themeCustomized = customizationDone || appearanceMode !== 'system' || colorPalette !== 'forest';
const lexiconCompleted = lexiconExplored || lexiconHistoryCount > 0;
const checklist = [
{ id: 'scan', label: t.stepScan, completed: plantsCount > 0, icon: 'camera-outline', route: '/scanner' },
{ id: 'lexicon', label: t.stepLexicon, completed: false, icon: 'search-outline', route: '/lexicon' },
{ id: 'theme', label: t.stepTheme, completed: false, icon: 'color-palette-outline', route: '/profile/preferences' },
{ id: 'scan' as const, label: t.stepScan, completed: plantsCount > 0, icon: 'camera-outline' },
{ id: 'reminder' as const, label: t.stepReminder, completed: reminderReady, icon: 'notifications-outline' },
{ id: 'lexicon' as const, label: t.stepLexicon, completed: lexiconCompleted, icon: 'search-outline' },
{ id: 'theme' as const, label: t.stepTheme, completed: themeCustomized, icon: 'color-palette-outline' },
{ id: 'collection' as const, label: t.stepCollection, completed: plantsCount >= 3, icon: 'albums-outline' },
];
const completedCount = checklist.filter((item) => item.completed).length;
const progressRatio = completedCount / checklist.length;
const progressWidth: `${number}%` = completedCount === 0 ? '0%' : `${Math.max(progressRatio * 100, 12)}%`;
const nextItem = checklist.find((item) => !item.completed) ?? null;
useEffect(() => {
if (previousProgressRef.current === completedCount) return;
previousProgressRef.current = completedCount;
posthog.capture('onboarding_progress_updated', {
completed_count: completedCount,
total_count: checklist.length,
next_step_id: nextItem?.id ?? 'complete',
});
}, [completedCount, checklist.length, nextItem?.id, posthog]);
if (completedCount === checklist.length) {
return null;
}
const navigateToStep = (stepId: OnboardingStepId) => {
posthog.capture('onboarding_step_opened', {
step_id: stepId,
completed_count: completedCount,
});
if (stepId === 'scan' || stepId === 'collection') {
router.push('/scanner');
return;
}
if (stepId === 'reminder') {
if (plants[0]?.id) {
router.push(`/plant/${plants[0].id}`);
} else {
router.push('/scanner');
}
return;
}
if (stepId === 'lexicon') {
router.push('/lexicon');
return;
}
router.push('/onboarding/customize');
};
return (
<View style={[styles.checklistCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.checklistTitle, { color: colors.text }]}>{t.nextStepsTitle}</Text>
<View
ref={cardRef}
style={[styles.checklistCard, { backgroundColor: colors.surface, borderColor: colors.border }]}
onLayout={() => {
cardRef.current?.measureInWindow((x, y, width, height) => {
registerLayout('onboarding_checklist', { x, y, width, height });
});
}}
>
<View style={styles.checklistHeader}>
<View style={styles.checklistHeaderCopy}>
<Text style={[styles.checklistEyebrow, { color: colors.primary }]}>
{t.onboardingChecklistIntro}
</Text>
<Text style={[styles.checklistTitle, { color: colors.text }]}>{t.onboardingChecklistTitle}</Text>
<Text style={[styles.checklistSubtitle, { color: colors.textSecondary }]}>
{(nextItem ? t.onboardingChecklistNextLabel : t.onboardingChecklistDone).replace('{0}', nextItem?.label ?? '')}
</Text>
</View>
<View style={[styles.checklistProgressPill, { backgroundColor: colors.primarySoft }]}>
<Text style={[styles.checklistProgressText, { color: colors.primaryDark }]}>
{t.onboardingChecklistProgress
.replace('{0}', completedCount.toString())
.replace('{1}', checklist.length.toString())}
</Text>
</View>
</View>
<View style={[styles.progressTrack, { backgroundColor: colors.surfaceMuted }]}>
<View
style={[
styles.progressFill,
{
backgroundColor: colors.primary,
width: progressWidth,
},
]}
/>
</View>
<View style={styles.checklistGrid}>
{checklist.map((item) => (
<TouchableOpacity
key={item.id}
style={styles.checklistItem}
onPress={() => {
if (item.id === 'theme') {
router.push('/profile/preferences');
} else if (item.id === 'scan') {
router.push('/scanner');
} else if (item.id === 'lexicon') {
router.push('/lexicon');
} else {
router.push(item.route);
}
}}
onPress={() => navigateToStep(item.id)}
disabled={item.completed}
>
<View style={[styles.checkIcon, { backgroundColor: item.completed ? colors.successSoft : colors.surfaceMuted }]}>
@@ -97,63 +209,119 @@ const getDaysUntilWatering = (plant: Plant): number => {
export default function HomeScreen() {
const {
session,
plants,
isLoadingPlants,
profileImageUri,
profileName,
billingSummary,
isLoadingBilling,
language,
t,
isDarkMode,
appearanceMode,
colorPalette,
getLexiconSearchHistory,
} = useApp();
const colors = useColors(isDarkMode, colorPalette);
const router = useRouter();
const insets = useSafeAreaInsets();
const [activeFilter, setActiveFilter] = useState<FilterKey>('all');
const { registerLayout, startTour } = useCoachMarks();
const [onboardingSignals, setOnboardingSignals] = useState({
lexiconExplored: false,
customizationDone: false,
});
const { layouts, registerLayout, startTour } = useCoachMarks();
const fabRef = useRef<View>(null);
const tourStartRequestedRef = useRef(false);
const posthog = useSafeAnalytics();
useFocusEffect(
React.useCallback(() => {
if (!session?.userId) {
setOnboardingSignals({
lexiconExplored: false,
customizationDone: false,
});
return;
}
setOnboardingSignals(OnboardingProgressService.getSignals(session.userId));
}, [session?.userId]),
);
// Tour nach Registrierung starten
useEffect(() => {
let cancelled = false;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
const tourSteps = [
{
elementKey: 'fab',
title: t.tourFabTitle,
description: t.tourFabDesc,
tooltipSide: 'above' as const,
},
{
elementKey: 'tab_search',
title: t.tourSearchTitle,
description: t.tourSearchDesc,
tooltipSide: 'above' as const,
},
{
elementKey: 'tab_profile',
title: t.tourProfileTitle,
description: t.tourProfileDesc,
tooltipSide: 'above' as const,
},
{
elementKey: 'onboarding_checklist',
title: t.tourChecklistTitle,
description: t.tourChecklistDesc,
tooltipSide: 'below' as const,
},
];
const registerTabLayouts = () => {
const tabBarBottom = SCREEN_H - 85;
const tabW = SCREEN_W / 3;
registerLayout('tab_search', { x: tabW, y: tabBarBottom + 8, width: tabW, height: 52 });
registerLayout('tab_profile', { x: tabW * 2, y: tabBarBottom + 8, width: tabW, height: 52 });
};
const checkTour = async () => {
if (tourStartRequestedRef.current) return;
const flag = await AsyncStorage.getItem('greenlens_show_tour');
if (flag !== 'true') return;
await AsyncStorage.removeItem('greenlens_show_tour');
tourStartRequestedRef.current = true;
// 1 Sekunde warten, dann Tour starten
setTimeout(() => {
// Tab-Positionen approximieren (gleichmäßig verteilt)
const tabBarBottom = SCREEN_H - 85;
const tabW = SCREEN_W / 3;
registerLayout('tab_search', { x: tabW, y: tabBarBottom + 8, width: tabW, height: 52 });
registerLayout('tab_profile', { x: tabW * 2, y: tabBarBottom + 8, width: tabW, height: 52 });
const startWhenReady = async (attempt = 0) => {
if (cancelled) return;
startTour([
{
elementKey: 'fab',
title: t.tourFabTitle,
description: t.tourFabDesc,
tooltipSide: 'above',
},
{
elementKey: 'tab_search',
title: t.tourSearchTitle,
description: t.tourSearchDesc,
tooltipSide: 'above',
},
{
elementKey: 'tab_profile',
title: t.tourProfileTitle,
description: t.tourProfileDesc,
tooltipSide: 'above',
},
]);
}, 1000);
registerTabLayouts();
if (!layouts.fab && attempt < 10) {
retryTimer = setTimeout(() => startWhenReady(attempt + 1), 250);
return;
}
startTour(tourSteps);
await AsyncStorage.removeItem('greenlens_show_tour');
tourStartRequestedRef.current = false;
};
retryTimer = setTimeout(() => startWhenReady(), 1000);
};
checkTour();
}, []);
return () => {
cancelled = true;
if (retryTimer) {
clearTimeout(retryTimer);
}
};
}, [layouts, registerLayout, startTour, t.tourChecklistDesc, t.tourChecklistTitle, t.tourFabDesc, t.tourFabTitle, t.tourProfileDesc, t.tourProfileTitle, t.tourSearchDesc, t.tourSearchTitle]);
const copy = t;
const greetingText = useMemo(() => {
@@ -326,9 +494,19 @@ export default function HomeScreen() {
/>
</View>
{plants.length === 0 && (
<OnboardingChecklist plantsCount={plants.length} colors={colors} router={router} t={t} />
)}
<OnboardingChecklist
plants={plants}
appearanceMode={appearanceMode}
colorPalette={colorPalette}
lexiconExplored={onboardingSignals.lexiconExplored}
customizationDone={onboardingSignals.customizationDone}
colors={colors}
router={router}
t={t}
getLexiconSearchHistory={getLexiconSearchHistory}
registerLayout={registerLayout}
posthog={posthog}
/>
<ScrollView
horizontal
@@ -818,11 +996,50 @@ const styles = StyleSheet.create({
padding: 20,
marginBottom: 20,
},
checklistHeader: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 12,
marginBottom: 14,
},
checklistHeaderCopy: {
flex: 1,
gap: 4,
},
checklistEyebrow: {
fontSize: 12,
fontWeight: '700',
letterSpacing: 0.4,
textTransform: 'uppercase',
},
checklistTitle: {
fontSize: 16,
fontWeight: '700',
},
checklistSubtitle: {
fontSize: 13,
lineHeight: 18,
},
checklistProgressPill: {
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 6,
},
checklistProgressText: {
fontSize: 12,
fontWeight: '700',
},
progressTrack: {
height: 8,
borderRadius: 999,
overflow: 'hidden',
marginBottom: 16,
},
progressFill: {
height: '100%',
borderRadius: 999,
},
checklistGrid: {
gap: 12,
},

View File

@@ -267,12 +267,11 @@ export default function SearchScreen() {
}
};
const openCategoryLexicon = (categoryId: string, categoryName: string) => {
const openCategoryLexicon = (categoryId: string) => {
router.push({
pathname: '/lexicon',
params: {
categoryId,
categoryLabel: encodeURIComponent(categoryName),
},
});
};
@@ -384,7 +383,7 @@ export default function SearchScreen() {
borderColor: colors.chipBorder,
},
]}
onPress={() => openCategoryLexicon(item.id, item.name)}
onPress={() => openCategoryLexicon(item.id)}
activeOpacity={0.8}
>
<Text style={[styles.catChipText, { color: getCategoryTextColor(item.bg, item.accent) }]}>

View File

@@ -1,11 +1,8 @@
import { useEffect, useRef, useState } from 'react';
import { Animated, AppState, Easing, Image, StyleSheet, Text, View } from 'react-native';
import React, { useEffect, useState } from 'react';
import { Text, View } from 'react-native';
import { Redirect, Stack, usePathname } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import AsyncStorage from '@react-native-async-storage/async-storage';
import Purchases, { LOG_LEVEL } from 'react-native-purchases';
import { Platform } from 'react-native';
import Constants from 'expo-constants';
import { AppProvider, useApp } from '../context/AppContext';
import { CoachMarksProvider } from '../context/CoachMarksContext';
import { CoachMarksOverlay } from '../components/CoachMarksOverlay';
@@ -14,13 +11,56 @@ import { initDatabase, AppMetaDb } from '../services/database';
import * as SecureStore from 'expo-secure-store';
import * as SplashScreen from 'expo-splash-screen';
import { AuthService } from '../services/authService';
import { PostHogProvider, usePostHog } from 'posthog-react-native';
import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen';
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync().catch(() => { });
const POSTHOG_API_KEY = process.env.EXPO_PUBLIC_POSTHOG_API_KEY || 'phc_FX6HRgx9NSpS5moxjMF6xyc37yMwjoeu6TbWUqNNKlk';
const SECURE_INSTALL_MARKER = 'greenlens_install_v1';
const SHARE_INTENT_CALLBACK_PATH = '/dataUrl=greenlensShareKey';
const isShareIntentCallbackPath = (path: string | null | undefined) => path === SHARE_INTENT_CALLBACK_PATH;
const toStartupErrorMessage = (error: unknown): string => {
if (!error) return 'Unknown startup error';
if (error instanceof Error) return error.message;
return String(error);
};
const StartupFallback = ({ details }: { details?: string | null }) => (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24, backgroundColor: '#111813' }}>
<Text style={{ color: '#fff', fontSize: 18, fontWeight: '700', marginBottom: 8, textAlign: 'center' }}>
GreenLens could not start.
</Text>
<Text style={{ color: '#D6DED7', fontSize: 14, lineHeight: 20, textAlign: 'center' }}>
Please send this startup error to support.
</Text>
{details ? (
<Text style={{ color: '#FFB4A8', fontSize: 12, lineHeight: 17, marginTop: 16, textAlign: 'center' }}>
{details}
</Text>
) : null}
</View>
);
class RootErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean; errorMessage: string | null }> {
state = { hasError: false, errorMessage: null };
static getDerivedStateFromError(error: unknown) {
return { hasError: true, errorMessage: toStartupErrorMessage(error) };
}
componentDidCatch(error: unknown) {
console.error('[RootErrorBoundary]', error);
}
render() {
if (this.state.hasError) {
return <StartupFallback details={this.state.errorMessage} />;
}
return this.props.children;
}
}
const ensureInstallConsistency = async (): Promise<boolean> => {
try {
@@ -50,89 +90,22 @@ const ensureInstallConsistency = async (): Promise<boolean> => {
}
};
import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen';
function RootLayoutInner() {
const { isDarkMode, colorPalette, signOut, session, isInitializing, isLoadingPlants, syncRevenueCatState } = useApp();
const {
isDarkMode,
colorPalette,
signOut,
session,
billingSummary,
isActivatingEntitlement,
isInitializing,
isLoadingPlants,
isLoadingBilling,
} = useApp();
const colors = useColors(isDarkMode, colorPalette);
const pathname = usePathname();
const [installCheckDone, setInstallCheckDone] = useState(false);
const [splashAnimationComplete, setSplashAnimationComplete] = useState(false);
const [revenueCatReady, setRevenueCatReady] = useState(Constants.appOwnership === 'expo');
const posthog = usePostHog();
useEffect(() => {
// RevenueCat requires native store access — not available in Expo Go
const isExpoGo = Constants.appOwnership === 'expo';
if (isExpoGo) {
console.log('[RevenueCat] Skipping configure: running in Expo Go');
return;
}
Purchases.setLogLevel(LOG_LEVEL.VERBOSE);
const iosApiKey = process.env.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY || 'appl_hrSpsuUuVstbHhYIDnOqYxPOnmR';
const androidApiKey = process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY || 'goog_placeholder';
if (Platform.OS === 'ios') {
Purchases.configure({ apiKey: iosApiKey });
} else if (Platform.OS === 'android') {
Purchases.configure({ apiKey: androidApiKey });
}
setRevenueCatReady(true);
}, []);
useEffect(() => {
const isExpoGo = Constants.appOwnership === 'expo';
if (isExpoGo || !revenueCatReady) {
return;
}
let cancelled = false;
(async () => {
try {
if (session?.serverUserId) {
await Purchases.logIn(session.serverUserId);
const customerInfo = await Purchases.getCustomerInfo();
if (!cancelled) {
await syncRevenueCatState(customerInfo as any, 'app_init');
}
} else {
await Purchases.logOut();
}
} catch (error) {
console.error('Failed to align RevenueCat identity', error);
}
})();
return () => {
cancelled = true;
};
}, [revenueCatReady, session?.serverUserId, syncRevenueCatState]);
useEffect(() => {
if (session?.serverUserId) {
posthog.identify(session.serverUserId, {
email: session.email,
name: session.name,
});
} else if (session === null) {
posthog.reset();
}
}, [session, posthog]);
useEffect(() => {
posthog.capture('screen_viewed', { screen: pathname });
}, [pathname, posthog]);
useEffect(() => {
posthog.capture('app_opened');
const subscription = AppState.addEventListener('change', (nextState) => {
if (nextState === 'active') {
posthog.capture('app_opened');
}
});
return () => subscription.remove();
}, [posthog]);
useEffect(() => {
(async () => {
@@ -145,14 +118,26 @@ function RootLayoutInner() {
}, [signOut]);
const isAppReady = installCheckDone && !isInitializing && !isLoadingPlants;
const hasActiveEntitlement = isActivatingEntitlement
|| (billingSummary?.entitlement?.plan === 'pro'
&& billingSummary?.entitlement?.status === 'active');
const isAllowedWithoutSession = pathname.includes('onboarding')
|| pathname.includes('auth/')
|| pathname.includes('scanner')
|| isShareIntentCallbackPath(pathname)
|| pathname.includes('profile/billing');
const isAllowedWithoutEntitlement = pathname.includes('auth/')
|| pathname.includes('onboarding')
|| pathname.includes('scanner')
|| isShareIntentCallbackPath(pathname)
|| pathname.includes('profile/billing');
let content = null;
if (isAppReady) {
if (!session) {
// Only redirect if we are not already on an auth-related page or the scanner
const isAuthPage = pathname.includes('onboarding') || pathname.includes('auth/') || pathname.includes('scanner') || pathname.includes('profile/billing');
if (!isAuthPage) {
if (!isAllowedWithoutSession) {
content = <Redirect href="/onboarding" />;
} else {
content = (
@@ -163,12 +148,17 @@ function RootLayoutInner() {
}}
>
<Stack.Screen name="onboarding" options={{ animation: 'none' }} />
<Stack.Screen name="onboarding/source" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/goal" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/experience" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/customize" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="auth/login" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="auth/signup" options={{ animation: 'slide_from_right' }} />
<Stack.Screen
name="scanner"
options={{ presentation: 'fullScreenModal', animation: 'slide_from_bottom' }}
/>
<Stack.Screen name="dataUrl=greenlensShareKey" options={{ animation: 'none' }} />
<Stack.Screen
name="profile/billing"
options={{ presentation: 'card', animation: 'slide_from_right' }}
@@ -176,6 +166,8 @@ function RootLayoutInner() {
</Stack>
);
}
} else if (!hasActiveEntitlement && !isLoadingBilling && !isAllowedWithoutEntitlement) {
content = <Redirect href="/onboarding" />;
} else {
content = (
<>
@@ -186,6 +178,10 @@ function RootLayoutInner() {
}}
>
<Stack.Screen name="onboarding" options={{ animation: 'none' }} />
<Stack.Screen name="onboarding/source" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/goal" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/experience" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/customize" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="auth/login" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="auth/signup" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="(tabs)" options={{ animation: 'none' }} />
@@ -193,6 +189,7 @@ function RootLayoutInner() {
name="scanner"
options={{ presentation: 'fullScreenModal', animation: 'slide_from_bottom' }}
/>
<Stack.Screen name="dataUrl=greenlensShareKey" options={{ animation: 'none' }} />
<Stack.Screen
name="plant/[id]"
options={{ presentation: 'card', animation: 'slide_from_right' }}
@@ -235,19 +232,24 @@ function RootLayoutInner() {
}
export default function RootLayout() {
initDatabase();
let dbInitError: string | null = null;
try {
initDatabase();
} catch (e) {
dbInitError = String(e);
}
if (dbInitError) {
return <StartupFallback details={`Database init failed: ${dbInitError}`} />;
}
return (
<PostHogProvider apiKey={POSTHOG_API_KEY} options={{
host: 'https://us.i.posthog.com',
enableSessionReplay: false,
debug: __DEV__,
}}>
<RootErrorBoundary>
<AppProvider>
<CoachMarksProvider>
<RootLayoutInner />
</CoachMarksProvider>
</AppProvider>
</PostHogProvider>
</RootErrorBoundary>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import {
View,
Text,
@@ -11,22 +11,54 @@ import {
ScrollView,
Image,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { AuthService } from '../../services/authService';
import * as AppleAuthentication from 'expo-apple-authentication';
import Constants from 'expo-constants';
import { useSafeAnalytics } from '../../services/analytics';
const ONBOARDING_AUTH_BACKGROUND = {
light: '#fbfaf3',
dark: '#0a110b',
};
export default function LoginScreen() {
const { isDarkMode, colorPalette, hydrateSession, t } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const posthog = useSafeAnalytics();
const screenBackground = isDarkMode
? ONBOARDING_AUTH_BACKGROUND.dark
: ONBOARDING_AUTH_BACKGROUND.light;
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [appleAvailable, setAppleAvailable] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isExpoGo = Constants.appOwnership === 'expo';
useEffect(() => {
if (isExpoGo) {
setAppleAvailable(false);
return;
}
let mounted = true;
AppleAuthentication.isAvailableAsync()
.then((available) => {
if (mounted) setAppleAvailable(available);
})
.catch(() => {
if (mounted) setAppleAvailable(false);
});
return () => {
mounted = false;
};
}, [isExpoGo]);
const handleLogin = async () => {
if (!email.trim() || !password) {
@@ -56,12 +88,59 @@ export default function LoginScreen() {
}
};
const handleAppleSignIn = async () => {
setLoading(true);
setError(null);
posthog.capture('apple_login_started', { surface: 'login' });
try {
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
if (!credential.identityToken) {
throw new Error('APPLE_AUTH_INVALID');
}
const fullName = [
credential.fullName?.givenName,
credential.fullName?.familyName,
].filter(Boolean).join(' ');
const session = await AuthService.signInWithApple({
identityToken: credential.identityToken,
appleUser: credential.user,
email: credential.email,
name: fullName || undefined,
});
await hydrateSession(session);
if (session.isNewUser) {
await AsyncStorage.setItem('greenlens_show_tour', 'true');
}
posthog.capture('apple_login_succeeded', { surface: 'login' });
router.replace(session.isNewUser ? '/onboarding/source' : '/(tabs)');
} catch (e: any) {
if (e?.code === 'ERR_REQUEST_CANCELED') {
return;
}
posthog.capture('apple_login_failed', {
surface: 'login',
error: e instanceof Error ? e.message : String(e),
});
setError(e?.message === 'APPLE_BACKEND_UNAVAILABLE'
? 'Apple Login ist auf dem Backend noch nicht aktiviert. Bitte Backend neu starten oder deployen.'
: t.errAuthError);
} finally {
setLoading(false);
}
};
return (
<KeyboardAvoidingView
style={[styles.flex, { backgroundColor: colors.background }]}
style={[styles.flex, { backgroundColor: screenBackground }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ThemeBackdrop colors={colors} />
<ScrollView
contentContainerStyle={styles.scroll}
keyboardShouldPersistTaps="handled"
@@ -69,6 +148,12 @@ export default function LoginScreen() {
>
{/* Logo / Header */}
<View style={styles.header}>
<TouchableOpacity
style={[styles.backBtn, { backgroundColor: colors.surface, borderColor: colors.border }]}
onPress={() => router.back()}
>
<Ionicons name="arrow-back" size={20} color={colors.text} />
</TouchableOpacity>
<Image
source={require('../../assets/icon.png')}
style={styles.logoIcon}
@@ -82,6 +167,26 @@ export default function LoginScreen() {
{/* Card */}
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}>
{appleAvailable ? (
<AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
buttonStyle={isDarkMode
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
cornerRadius={12}
style={styles.appleButton}
onPress={handleAppleSignIn}
/>
) : null}
{appleAvailable ? (
<View style={styles.dividerRowCompact}>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
<Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
</View>
) : null}
{/* Email */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>E-Mail</Text>
@@ -185,10 +290,21 @@ const styles = StyleSheet.create({
alignItems: 'center',
marginBottom: 32,
},
backBtn: {
position: 'absolute',
left: 0,
top: 0,
width: 40,
height: 40,
borderRadius: 20,
borderWidth: 1,
justifyContent: 'center',
alignItems: 'center',
},
logoIcon: {
width: 56,
height: 56,
borderRadius: 14,
width: 84,
height: 84,
borderRadius: 20,
marginBottom: 16,
},
appName: {
@@ -211,6 +327,17 @@ const styles = StyleSheet.create({
shadowRadius: 12,
elevation: 4,
},
appleButton: {
width: '100%',
height: 50,
marginBottom: 2,
},
dividerRowCompact: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
marginVertical: 2,
},
fieldGroup: {
gap: 6,
},

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import {
View,
Text,
@@ -15,14 +15,25 @@ import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { AuthService } from '../../services/authService';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as AppleAuthentication from 'expo-apple-authentication';
import Constants from 'expo-constants';
import { useSafeAnalytics } from '../../services/analytics';
const ONBOARDING_AUTH_BACKGROUND = {
light: '#fbfaf3',
dark: '#0a110b',
};
export default function SignupScreen() {
const { isDarkMode, colorPalette, hydrateSession, getPendingPlant, t } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const posthog = useSafeAnalytics();
const pendingPlant = getPendingPlant();
const screenBackground = isDarkMode
? ONBOARDING_AUTH_BACKGROUND.dark
: ONBOARDING_AUTH_BACKGROUND.light;
const [name, setName] = useState('');
const [email, setEmail] = useState('');
@@ -30,8 +41,28 @@ export default function SignupScreen() {
const [passwordConfirm, setPasswordConfirm] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showPasswordConfirm, setShowPasswordConfirm] = useState(false);
const [appleAvailable, setAppleAvailable] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isExpoGo = Constants.appOwnership === 'expo';
useEffect(() => {
if (isExpoGo) {
setAppleAvailable(false);
return;
}
let mounted = true;
AppleAuthentication.isAvailableAsync()
.then((available) => {
if (mounted) setAppleAvailable(available);
})
.catch(() => {
if (mounted) setAppleAvailable(false);
});
return () => {
mounted = false;
};
}, [isExpoGo]);
const validate = (): string | null => {
if (!name.trim()) return t.errNameRequired;
@@ -54,7 +85,7 @@ export default function SignupScreen() {
await hydrateSession(session);
// Flag setzen: Tour beim nächsten App-Öffnen anzeigen
await AsyncStorage.setItem('greenlens_show_tour', 'true');
router.replace('/(tabs)');
router.replace('/onboarding/source');
} catch (e: any) {
if (e.message === 'EMAIL_TAKEN') {
setError(t.errEmailTaken);
@@ -74,12 +105,57 @@ export default function SignupScreen() {
}
};
const handleAppleSignIn = async () => {
setLoading(true);
setError(null);
posthog.capture('apple_login_started', { surface: 'signup' });
try {
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
if (!credential.identityToken) {
throw new Error('APPLE_AUTH_INVALID');
}
const fullName = [
credential.fullName?.givenName,
credential.fullName?.familyName,
].filter(Boolean).join(' ');
const session = await AuthService.signInWithApple({
identityToken: credential.identityToken,
appleUser: credential.user,
email: credential.email,
name: fullName || undefined,
});
await hydrateSession(session);
await AsyncStorage.setItem('greenlens_show_tour', 'true');
posthog.capture('apple_login_succeeded', { surface: 'signup' });
router.replace(session.isNewUser ? '/onboarding/source' : '/(tabs)');
} catch (e: any) {
if (e?.code === 'ERR_REQUEST_CANCELED') {
return;
}
posthog.capture('apple_login_failed', {
surface: 'signup',
error: e instanceof Error ? e.message : String(e),
});
setError(e?.message === 'APPLE_BACKEND_UNAVAILABLE'
? 'Apple Login ist auf dem Backend noch nicht aktiviert. Bitte Backend neu starten oder deployen.'
: t.errAuthError);
} finally {
setLoading(false);
}
};
return (
<KeyboardAvoidingView
style={[styles.flex, { backgroundColor: colors.background }]}
style={[styles.flex, { backgroundColor: screenBackground }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ThemeBackdrop colors={colors} />
<ScrollView
contentContainerStyle={styles.scroll}
keyboardShouldPersistTaps="handled"
@@ -116,6 +192,26 @@ export default function SignupScreen() {
{/* Card */}
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}>
{appleAvailable ? (
<AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
buttonStyle={isDarkMode
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
cornerRadius={12}
style={styles.appleButton}
onPress={handleAppleSignIn}
/>
) : null}
{appleAvailable ? (
<View style={styles.dividerRowCompact}>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
<Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
</View>
) : null}
{/* Name */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>Name</Text>
@@ -302,9 +398,9 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
logoIcon: {
width: 56,
height: 56,
borderRadius: 14,
width: 84,
height: 84,
borderRadius: 20,
marginBottom: 16,
},
appName: {
@@ -327,6 +423,25 @@ const styles = StyleSheet.create({
shadowRadius: 12,
elevation: 4,
},
appleButton: {
width: '100%',
height: 50,
marginBottom: 2,
},
dividerRowCompact: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
marginVertical: 2,
},
dividerLine: {
flex: 1,
height: 1,
},
dividerText: {
fontSize: 12,
fontWeight: '500',
},
fieldGroup: {
gap: 6,
},

View File

@@ -0,0 +1,204 @@
import React, { useEffect } from 'react';
import { ActivityIndicator, Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useRouter } from 'expo-router';
import { parseShareIntent, ShareIntentModule } from 'expo-share-intent';
import * as ExpoLinking from 'expo-linking';
import { SHARE_INTENT_KEY, storeSharedImageUri } from '../utils/shareHandoff';
import { resolveSharedImageUri, summarizeShareIntent } from '../utils/shareIntent';
const SHARE_INTENT_SCHEME = 'greenlens';
const SHARE_INTENT_OPTIONS = {
scheme: SHARE_INTENT_SCHEME,
};
const isShareIntentUrl = (url: string | null | undefined) => Boolean(url?.includes(`${SHARE_INTENT_SCHEME}://dataUrl=`));
export default function ShareIntentCallbackScreen() {
const router = useRouter();
const [isWaiting, setIsWaiting] = React.useState(true);
const [failureDetails, setFailureDetails] = React.useState<string | null>(null);
const [previewUri, setPreviewUri] = React.useState<string | null>(null);
const pendingKeyRef = React.useRef<string | null>(null);
const getIntentCalledRef = React.useRef(false);
const settledRef = React.useRef(false);
const linkingUrl = ExpoLinking.useLinkingURL();
useEffect(() => {
const fallback = setTimeout(() => {
if (settledRef.current) return;
setIsWaiting(false);
setFailureDetails((current) => current || 'Keine Antwort von der Share Extension.');
}, 15000);
return () => clearTimeout(fallback);
}, []);
useEffect(() => {
const showFailure = (message: string) => {
settledRef.current = true;
ShareIntentModule?.clearShareIntent(SHARE_INTENT_KEY);
setPreviewUri(null);
setIsWaiting(false);
setFailureDetails(message);
};
const changeSubscription = ShareIntentModule?.addListener('onChange', async (event) => {
try {
setIsWaiting(true);
setFailureDetails(null);
const shareIntent = parseShareIntent(event.value, SHARE_INTENT_OPTIONS);
if (__DEV__) {
console.debug('[ShareIntentCallback]', summarizeShareIntent(shareIntent));
}
const resolved = await resolveSharedImageUri(shareIntent);
if (!resolved) {
showFailure('Die Quelle hat keinen nutzbaren Bildanhang oder Bild-Link geliefert.');
return;
}
settledRef.current = true;
const sharedImageKey = storeSharedImageUri(resolved.uri);
ShareIntentModule?.clearShareIntent(SHARE_INTENT_KEY);
if (resolved.requiresConfirmation) {
pendingKeyRef.current = sharedImageKey;
setIsWaiting(false);
setPreviewUri(resolved.uri);
return;
}
router.replace({
pathname: '/scanner',
params: { sharedImageKey },
});
} catch (error) {
console.error('[ShareIntentCallback] failed to parse share intent', error);
showFailure('Die Share-Daten konnten nicht gelesen werden.');
}
});
const errorSubscription = ShareIntentModule?.addListener('onError', (event) => {
console.error('[ShareIntentCallback] native error', event.value);
showFailure(event.value || 'Die Share Extension hat einen Fehler gemeldet.');
});
const url = linkingUrl || ExpoLinking.getLinkingURL();
if (url && isShareIntentUrl(url) && !getIntentCalledRef.current) {
getIntentCalledRef.current = true;
ShareIntentModule?.getShareIntent(url);
}
return () => {
changeSubscription?.remove();
errorSubscription?.remove();
};
}, [linkingUrl, router]);
return (
<View style={styles.container}>
{isWaiting ? (
<ActivityIndicator color="#F4F7F1" size="large" />
) : previewUri ? (
<View style={styles.messageBox}>
<Text style={styles.title}>Pflanze gefunden</Text>
<Text style={styles.body}>Ist das die Pflanze, die du scannen möchtest?</Text>
<Image source={{ uri: previewUri }} style={styles.previewImage} resizeMode="cover" />
<Text style={styles.hint}>
Wenn das nicht die Pflanze ist, tippe Scanner öffnen" und teile das Bild direkt in Safari.
</Text>
<TouchableOpacity
style={styles.button}
onPress={() => {
if (pendingKeyRef.current) {
router.replace({ pathname: '/scanner', params: { sharedImageKey: pendingKeyRef.current } });
}
}}
>
<Text style={styles.buttonText}>Diese Pflanze scannen</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.buttonSecondary} onPress={() => router.replace('/scanner')}>
<Text style={styles.buttonSecondaryText}>Scanner öffnen</Text>
</TouchableOpacity>
</View>
) : (
<View style={styles.messageBox}>
<Text style={styles.title}>Bild konnte nicht geladen werden.</Text>
<Text style={styles.body}>
Tippe und halte das Bild in Safari oder Instagram, wähle „Bild teilen" und teile es direkt mit GreenLens.
</Text>
{failureDetails ? (
<Text style={styles.detail}>{failureDetails}</Text>
) : null}
<TouchableOpacity style={styles.button} onPress={() => router.replace('/scanner')}>
<Text style={styles.buttonText}>Scanner öffnen</Text>
</TouchableOpacity>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#111813',
padding: 24,
},
messageBox: {
alignItems: 'center',
gap: 14,
},
title: {
color: '#F4F7F1',
fontSize: 18,
fontWeight: '700',
textAlign: 'center',
},
body: {
color: '#CAD3CB',
fontSize: 14,
lineHeight: 20,
textAlign: 'center',
},
detail: {
color: '#8F9A91',
fontSize: 12,
lineHeight: 17,
textAlign: 'center',
},
previewImage: {
width: '100%',
aspectRatio: 4 / 3,
borderRadius: 16,
backgroundColor: '#1E2820',
},
button: {
marginTop: 8,
borderRadius: 12,
backgroundColor: '#D7F5A2',
paddingHorizontal: 18,
paddingVertical: 12,
},
buttonText: {
color: '#111813',
fontSize: 14,
fontWeight: '800',
},
hint: {
color: '#8F9A91',
fontSize: 11,
lineHeight: 16,
textAlign: 'center',
marginTop: -6,
},
buttonSecondary: {
marginTop: 4,
borderRadius: 12,
paddingHorizontal: 18,
paddingVertical: 12,
},
buttonSecondaryText: {
color: '#8F9A91',
fontSize: 14,
fontWeight: '600',
textAlign: 'center',
},
});

View File

@@ -14,31 +14,21 @@ import { ResultCard } from '../components/ResultCard';
import { ThemeBackdrop } from '../components/ThemeBackdrop';
import { SafeImage } from '../components/SafeImage';
import { resolveImageUri } from '../utils/imageUri';
import { OnboardingProgressService } from '../services/onboardingProgressService';
export default function LexiconScreen() {
const { isDarkMode, colorPalette, language, t, savePlant, getLexiconSearchHistory, saveLexiconSearchQuery, clearLexiconSearchHistory } = useApp();
const { session, isDarkMode, colorPalette, language, t, savePlant, getLexiconSearchHistory, saveLexiconSearchQuery, clearLexiconSearchHistory } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const insets = useSafeAreaInsets();
const router = useRouter();
const params = useLocalSearchParams();
const categoryIdParam = Array.isArray(params.categoryId) ? params.categoryId[0] : params.categoryId;
const categoryLabelParam = Array.isArray(params.categoryLabel) ? params.categoryLabel[0] : params.categoryLabel;
const decodeParam = (value?: string | string[]) => {
if (!value || typeof value !== 'string') return '';
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const initialCategoryId = typeof categoryIdParam === 'string' ? categoryIdParam : null;
const initialCategoryLabel = decodeParam(categoryLabelParam);
const topInsetFallback = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : 20;
const topInset = insets.top > 0 ? insets.top : topInsetFallback;
const [searchQuery, setSearchQuery] = useState(initialCategoryLabel);
const [searchQuery, setSearchQuery] = useState('');
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(initialCategoryId);
const [selectedItem, setSelectedItem] = useState<(IdentificationResult & { imageUri: string }) | null>(null);
const [isAiSearching, setIsAiSearching] = useState(false);
@@ -70,8 +60,8 @@ export default function LexiconScreen() {
React.useEffect(() => {
setActiveCategoryId(initialCategoryId);
setSearchQuery(initialCategoryLabel);
}, [initialCategoryId, initialCategoryLabel]);
setSearchQuery('');
}, [initialCategoryId]);
React.useEffect(() => {
const loadHistory = async () => {
@@ -81,6 +71,11 @@ export default function LexiconScreen() {
loadHistory();
}, []);
React.useEffect(() => {
if (!session?.userId) return;
OnboardingProgressService.completeStep(session.userId, 'lexicon');
}, [session?.userId]);
const handleResultClose = () => {
if (openedWithDetail) {
router.back();

View File

@@ -1,154 +1,165 @@
import React, { useEffect, useRef } from 'react';
import React from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Animated,
Dimensions,
Image,
ImageBackground,
SafeAreaView,
StyleSheet,
Text,
TouchableOpacity,
View,
useWindowDimensions,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import Svg, { Path } from 'react-native-svg';
import { useApp } from '../context/AppContext';
import { useColors } from '../constants/Colors';
import { ThemeBackdrop } from '../components/ThemeBackdrop';
const { height: SCREEN_H, width: SCREEN_W } = Dimensions.get('window');
type Feature = {
icon: keyof typeof Ionicons.glyphMap;
title: string;
description: string;
};
export default function OnboardingScreen() {
const { isDarkMode, colorPalette, t } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const { t } = useApp();
const { height, width } = useWindowDimensions();
const compact = height < 760;
const sheetTop = compact ? 142 : 156;
const waveHeight = compact ? 148 : 170;
const bodyOffset = waveHeight - 2;
const contentTop = compact ? 94 : 108;
const FEATURES = [
{ icon: 'camera-outline' as const, label: t.onboardingFeatureScan },
{ icon: 'notifications-outline' as const, label: t.onboardingFeatureReminder },
{ icon: 'book-outline' as const, label: t.onboardingFeatureLexicon },
const features: Feature[] = [
{
icon: 'scan-outline',
title: t.welcomeFeatureIdentifyTitle,
description: t.welcomeFeatureIdentifyDesc,
},
{
icon: 'notifications-outline',
title: t.welcomeFeatureReminderTitle,
description: t.welcomeFeatureReminderDesc,
},
{
icon: 'book-outline',
title: t.welcomeFeatureLibraryTitle,
description: t.welcomeFeatureLibraryDesc,
},
];
// Entrance animations
const logoAnim = useRef(new Animated.Value(0)).current;
const logoScale = useRef(new Animated.Value(0.85)).current;
const featuresAnim = useRef(new Animated.Value(0)).current;
const buttonsAnim = useRef(new Animated.Value(0)).current;
const featureAnims = useRef(FEATURES.map(() => new Animated.Value(0))).current;
useEffect(() => {
Animated.sequence([
Animated.parallel([
Animated.timing(logoAnim, { toValue: 1, duration: 700, useNativeDriver: true }),
Animated.spring(logoScale, { toValue: 1, tension: 50, friction: 8, useNativeDriver: true }),
]),
Animated.stagger(100, featureAnims.map(anim =>
Animated.timing(anim, { toValue: 1, duration: 400, useNativeDriver: true })
)),
Animated.timing(buttonsAnim, { toValue: 1, duration: 400, useNativeDriver: true }),
]).start();
}, []);
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<ThemeBackdrop colors={colors} />
{/* Logo-Bereich */}
<Animated.View
style={[
styles.heroSection,
{ opacity: logoAnim, transform: [{ scale: logoScale }] },
]}
<View style={styles.container}>
<ImageBackground
source={require('../assets/welcome_botanical_hero.png')}
style={styles.heroImage}
imageStyle={styles.heroImageContent}
resizeMode="cover"
>
<View style={[styles.iconContainer, { shadowColor: colors.primary }]}>
<Image
source={require('../assets/icon.png')}
style={styles.appIcon}
resizeMode="cover"
<View style={styles.heroShadeTop} />
<View style={styles.heroShadeBottom} />
<SafeAreaView style={styles.safeArea}>
<View style={[styles.brandRow, compact && styles.brandRowCompact]}>
<Image
source={require('../assets/icon.png')}
style={styles.logo}
resizeMode="cover"
/>
<Text style={styles.brandName}>
Green<Text style={styles.brandAccent}>Lens</Text>
</Text>
</View>
</SafeAreaView>
</ImageBackground>
<View style={[styles.sheet, { top: sheetTop }]}>
<Svg
width={width}
height={waveHeight}
viewBox={`0 0 ${width} ${waveHeight}`}
preserveAspectRatio="none"
style={styles.sheetWave}
>
<Path
d={`M0 34 C ${width * 0.08} 76 ${width * 0.14} 82 ${width * 0.24} 82 C ${width * 0.38} 82 ${width * 0.52} 82 ${width * 0.64} 82 C ${width * 0.78} 86 ${width * 0.88} 132 ${width} 156 L ${width} ${waveHeight} L 0 ${waveHeight} Z`}
fill="#fbfaf3"
/>
</View>
</Svg>
<View style={[styles.sheetBody, { top: bodyOffset }]} />
<View
style={[
styles.sheetContent,
{ top: contentTop },
compact && styles.sheetContentCompact,
]}
>
<Text style={[styles.headline, compact && styles.headlineCompact]}>
{t.welcomeHeadline}
</Text>
<Text style={styles.subheadline}>{t.welcomeSubheadline}</Text>
<Text style={[styles.appName, { color: colors.text }]}>GreenLens</Text>
<Text style={[styles.tagline, { color: colors.textSecondary }]}>
{t.onboardingTagline}
</Text>
</Animated.View>
<View style={styles.features}>
{features.map((feature, index) => (
<View
key={feature.title}
style={[
styles.featureRow,
index === features.length - 1 && styles.featureRowLast,
]}
>
<View style={styles.featureIcon}>
<Ionicons name={feature.icon} size={22} color="#a6d66f" />
</View>
<View style={styles.featureCopy}>
<Text style={styles.featureTitle}>{feature.title}</Text>
<Text style={styles.featureDescription}>{feature.description}</Text>
</View>
</View>
))}
</View>
{/* Feature-Liste */}
<View style={styles.featuresSection}>
{FEATURES.map((feat, i) => (
<Animated.View
key={feat.label}
style={[
styles.featureRow,
{
backgroundColor: colors.surface + '88', // Semi-transparent for backdrop effect
borderColor: colors.border,
opacity: featureAnims[i],
transform: [{
translateY: featureAnims[i].interpolate({
inputRange: [0, 1],
outputRange: [20, 0],
}),
}],
},
]}
<TouchableOpacity
style={styles.demoButton}
onPress={() => router.push('/scanner')}
activeOpacity={0.86}
>
<View style={[styles.featureIcon, { backgroundColor: colors.primary + '15' }]}>
<Ionicons name={feat.icon} size={18} color={colors.primary} />
</View>
<Text style={[styles.featureText, { color: colors.text }]}>{feat.label}</Text>
</Animated.View>
))}
<Ionicons name="scan" size={25} color="#f8f7ef" />
<Text style={styles.demoButtonText}>{t.welcomeDemoScan}</Text>
<Ionicons name="chevron-forward" size={26} color="#f8f7ef" />
</TouchableOpacity>
<View style={styles.authRow}>
<TouchableOpacity
style={styles.authButton}
onPress={() => router.push('/auth/signup')}
activeOpacity={0.82}
>
<Text style={styles.authButtonText}>{t.onboardingRegister}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.authButton, styles.loginButton]}
onPress={() => router.push('/auth/login')}
activeOpacity={0.82}
>
<Text style={[styles.authButtonText, styles.loginButtonText]}>
{t.onboardingLogin}
</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
style={styles.subscriptionLink}
onPress={() => router.push('/profile/billing')}
activeOpacity={0.8}
>
<Ionicons name="leaf-outline" size={21} color="#4b7c31" />
<Text style={styles.subscriptionText}>{t.welcomeSubscriptionPlans}</Text>
<Ionicons name="chevron-forward" size={20} color="#4b7c31" />
</TouchableOpacity>
<Text style={styles.legalText}>{t.welcomeLegal}</Text>
</View>
</View>
{/* Buttons */}
<Animated.View style={[styles.buttonsSection, { opacity: buttonsAnim }]}>
<TouchableOpacity
style={[styles.primaryBtn, { backgroundColor: colors.primary }]}
onPress={() => router.push('/scanner')}
activeOpacity={0.85}
>
<Ionicons name="scan" size={20} color={colors.onPrimary} />
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>
{t.onboardingScanBtn}
</Text>
</TouchableOpacity>
<View style={styles.authActions}>
<TouchableOpacity
style={[styles.secondaryBtn, { borderColor: colors.primary, backgroundColor: colors.surface }]}
onPress={() => router.push('/auth/signup')}
activeOpacity={0.82}
>
<Text style={[styles.secondaryBtnText, { color: colors.primary }]}>
{t.onboardingRegister}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.secondaryBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surface }]}
onPress={() => router.push('/auth/login')}
activeOpacity={0.82}
>
<Text style={[styles.secondaryBtnText, { color: colors.text }]}>
{t.onboardingLogin}
</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
style={[styles.plansBtn, { borderColor: colors.primary }]}
onPress={() => router.push('/profile/billing')}
activeOpacity={0.82}
>
<Ionicons name="pricetag-outline" size={16} color={colors.primary} />
<Text style={[styles.plansBtnText, { color: colors.primary }]}>
View Subscription Plans & Pricing
</Text>
</TouchableOpacity>
<Text style={[styles.disclaimer, { color: colors.textMuted }]}>
{t.onboardingDisclaimer}
</Text>
</Animated.View>
</View>
);
}
@@ -156,123 +167,201 @@ export default function OnboardingScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
paddingHorizontal: 32,
paddingTop: SCREEN_H * 0.12,
paddingBottom: 40,
backgroundColor: '#0a110b',
},
heroSection: {
alignItems: 'center',
marginBottom: 40,
heroImage: {
height: '60%',
minHeight: 430,
},
iconContainer: {
width: 120,
height: 120,
borderRadius: 28,
backgroundColor: '#fff',
elevation: 8,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
marginBottom: 24,
overflow: 'hidden',
heroImageContent: {
backgroundColor: '#0a110b',
transform: [{ scale: 1.04 }],
},
appIcon: {
width: '100%',
height: '100%',
heroShadeTop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.08)',
},
appName: {
fontSize: 40,
fontWeight: '900',
letterSpacing: -1.5,
marginBottom: 4,
heroShadeBottom: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: 190,
backgroundColor: 'rgba(7,12,7,0.2)',
},
tagline: {
fontSize: 17,
fontWeight: '500',
opacity: 0.8,
},
featuresSection: {
gap: 8,
safeArea: {
flex: 1,
justifyContent: 'center',
},
brandRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 15,
paddingHorizontal: 30,
paddingTop: 62,
},
brandRowCompact: {
paddingTop: 42,
},
logo: {
width: 68,
height: 68,
borderRadius: 16,
backgroundColor: '#fff',
},
brandName: {
color: '#f8f7ef',
fontSize: 36,
fontWeight: '900',
},
brandAccent: {
color: '#9bc76e',
},
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
},
sheetWave: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
},
sheetBody: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
backgroundColor: '#fbfaf3',
},
sheetContent: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
paddingHorizontal: 24,
paddingBottom: 10,
},
sheetContentCompact: {
paddingHorizontal: 22,
paddingBottom: 8,
},
headline: {
color: '#101c12',
fontSize: 40,
lineHeight: 43,
fontWeight: '900',
marginBottom: 6,
maxWidth: 310,
},
headlineCompact: {
fontSize: 34,
lineHeight: 37,
},
subheadline: {
color: '#5f625d',
fontSize: 15,
lineHeight: 19,
fontWeight: '500',
marginBottom: 11,
},
features: {
marginBottom: 10,
},
featureRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 16,
borderWidth: 1,
gap: 11,
paddingVertical: 6,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: 'rgba(16,28,18,0.14)',
},
featureRowLast: {
borderBottomWidth: 0,
},
featureIcon: {
width: 36,
height: 36,
width: 46,
height: 46,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#173817',
},
featureText: {
featureCopy: {
flex: 1,
},
featureTitle: {
color: '#101c12',
fontSize: 16,
fontWeight: '800',
marginBottom: 2,
},
featureDescription: {
color: '#696b65',
fontSize: 13,
fontWeight: '600',
letterSpacing: 0.1,
lineHeight: 16,
fontWeight: '500',
},
buttonsSection: {
gap: 16,
marginTop: 20,
},
primaryBtn: {
height: 58,
borderRadius: 20,
demoButton: {
height: 60,
borderRadius: 7,
backgroundColor: '#437824',
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 12,
elevation: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
justifyContent: 'space-between',
paddingHorizontal: 18,
marginBottom: 8,
},
primaryBtnText: {
demoButtonText: {
color: '#f8f7ef',
fontSize: 21,
fontWeight: '800',
},
authRow: {
flexDirection: 'row',
gap: 8,
marginBottom: 8,
},
authButton: {
flex: 1,
height: 50,
borderRadius: 7,
borderWidth: 1.4,
borderColor: '#4b7c31',
alignItems: 'center',
justifyContent: 'center',
},
loginButton: {
borderColor: '#101c12',
},
authButtonText: {
color: '#4b7c31',
fontSize: 17,
fontWeight: '700',
},
authActions: {
loginButtonText: {
color: '#101c12',
},
subscriptionLink: {
minHeight: 24,
flexDirection: 'row',
gap: 12,
},
secondaryBtn: {
flex: 1,
height: 54,
borderRadius: 20,
borderWidth: 1.5,
justifyContent: 'center',
alignItems: 'center',
},
secondaryBtnText: {
fontSize: 15,
fontWeight: '600',
},
plansBtn: {
height: 48,
borderRadius: 16,
borderWidth: 1.5,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 8,
marginBottom: 7,
},
plansBtnText: {
fontSize: 14,
fontWeight: '600',
},
disclaimer: {
fontSize: 12,
subscriptionText: {
color: '#4b7c31',
fontSize: 15,
fontWeight: '800',
textAlign: 'center',
},
legalText: {
color: '#6b6d68',
fontSize: 11,
lineHeight: 14,
fontWeight: '500',
textAlign: 'center',
opacity: 0.6,
marginTop: 8,
},
});

View File

@@ -0,0 +1,341 @@
import React from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useSafeAnalytics } from '../../services/analytics';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { useColors } from '../../constants/Colors';
import { useApp } from '../../context/AppContext';
import { OnboardingProgressService } from '../../services/onboardingProgressService';
import { AppearanceMode, ColorPalette, Language } from '../../types';
const PALETTE_SWATCHES: Record<ColorPalette, string[]> = {
forest: ['#5fa779', '#3d7f57'],
ocean: ['#5a90be', '#3d6f99'],
sunset: ['#c98965', '#a36442'],
mono: ['#7b8796', '#5b6574'],
};
export default function CustomizeOnboardingScreen() {
const router = useRouter();
const posthog = useSafeAnalytics();
const {
session,
isDarkMode,
appearanceMode,
colorPalette,
language,
setAppearanceMode,
setColorPalette,
changeLanguage,
t,
} = useApp();
const colors = useColors(isDarkMode, colorPalette);
const finishCustomization = () => {
if (session?.userId) {
OnboardingProgressService.completeStep(session.userId, 'customize');
}
posthog.capture('onboarding_customization_completed', {
appearance_mode: appearanceMode,
color_palette: colorPalette,
language,
});
router.back();
};
const skipCustomization = () => {
posthog.capture('onboarding_customization_skipped');
router.back();
};
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<ThemeBackdrop colors={colors} />
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right', 'bottom']}>
<View style={styles.header}>
<TouchableOpacity onPress={skipCustomization} style={[styles.iconBtn, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Ionicons name="close" size={20} color={colors.text} />
</TouchableOpacity>
<View style={styles.headerCopy}>
<Text style={[styles.eyebrow, { color: colors.primary }]}>{t.onboardingChecklistIntro}</Text>
<Text style={[styles.title, { color: colors.text }]}>{t.customizeOnboardingTitle}</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>{t.customizeOnboardingSubtitle}</Text>
</View>
</View>
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<View style={[styles.previewCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.previewLabel, { color: colors.textMuted }]}>{t.customizeOnboardingPreview}</Text>
<Text style={[styles.previewTitle, { color: colors.text }]}>{t.onboardingTagline}</Text>
<View style={styles.previewMeta}>
<View style={[styles.previewChip, { backgroundColor: colors.primarySoft }]}>
<Text style={[styles.previewChipText, { color: colors.primaryDark }]}>{appearanceMode}</Text>
</View>
<View style={[styles.previewChip, { backgroundColor: colors.surfaceMuted }]}>
<Text style={[styles.previewChipText, { color: colors.text }]}>{colorPalette}</Text>
</View>
<View style={[styles.previewChip, { backgroundColor: colors.surfaceMuted }]}>
<Text style={[styles.previewChipText, { color: colors.text }]}>{language.toUpperCase()}</Text>
</View>
</View>
</View>
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{t.appearanceMode}</Text>
<View style={styles.segmentedControl}>
{(['system', 'light', 'dark'] as AppearanceMode[]).map((mode) => {
const isActive = appearanceMode === mode;
const label = mode === 'system' ? t.themeSystem : mode === 'light' ? t.themeLight : t.themeDark;
return (
<TouchableOpacity
key={mode}
style={[styles.segmentBtn, isActive && { backgroundColor: colors.primary }]}
onPress={() => setAppearanceMode(mode)}
>
<Text style={[styles.segmentText, { color: isActive ? colors.onPrimary : colors.text }]}>
{label}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{t.colorPalette}</Text>
<View style={styles.swatchContainer}>
{(['forest', 'ocean', 'sunset', 'mono'] as ColorPalette[]).map((palette) => {
const isActive = colorPalette === palette;
const swatch = PALETTE_SWATCHES[palette];
const label =
palette === 'forest'
? t.paletteForest
: palette === 'ocean'
? t.paletteOcean
: palette === 'sunset'
? t.paletteSunset
: t.paletteMono;
return (
<TouchableOpacity
key={palette}
style={[styles.swatchWrap, isActive && { borderColor: colors.primary }]}
onPress={() => setColorPalette(palette)}
>
<View style={[styles.swatch, { backgroundColor: swatch[0] }]} />
<Text style={[styles.swatchLabel, { color: colors.text }]}>{label}</Text>
</TouchableOpacity>
);
})}
</View>
</View>
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{t.language}</Text>
<View style={styles.languageRow}>
{(['en', 'de', 'es'] as Language[]).map((lang) => {
const isActive = language === lang;
const label = lang === 'en' ? 'English' : lang === 'de' ? 'Deutsch' : 'Español';
return (
<TouchableOpacity
key={lang}
style={[styles.languageBtn, isActive && { backgroundColor: colors.primary }]}
onPress={() => changeLanguage(lang)}
>
<Text style={{ color: isActive ? colors.onPrimary : colors.text, fontWeight: '600' }}>
{label}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
</ScrollView>
<View style={styles.footer}>
<TouchableOpacity
style={[styles.secondaryBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surface }]}
onPress={skipCustomization}
>
<Text style={[styles.secondaryBtnText, { color: colors.text }]}>{t.customizeOnboardingSkip}</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.primaryBtn, { backgroundColor: colors.primary }]} onPress={finishCustomization}>
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>{t.customizeOnboardingContinue}</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
safeArea: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 14,
paddingHorizontal: 20,
paddingTop: 12,
},
iconBtn: {
width: 40,
height: 40,
borderRadius: 20,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
headerCopy: {
flex: 1,
gap: 6,
paddingTop: 2,
},
eyebrow: {
fontSize: 12,
fontWeight: '700',
letterSpacing: 0.4,
textTransform: 'uppercase',
},
title: {
fontSize: 28,
fontWeight: '800',
lineHeight: 32,
},
subtitle: {
fontSize: 14,
lineHeight: 20,
},
content: {
padding: 20,
gap: 16,
},
previewCard: {
borderWidth: 1,
borderRadius: 24,
padding: 18,
gap: 10,
},
previewLabel: {
fontSize: 11,
fontWeight: '700',
letterSpacing: 0.5,
textTransform: 'uppercase',
},
previewTitle: {
fontSize: 20,
fontWeight: '700',
},
previewMeta: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
previewChip: {
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 6,
},
previewChipText: {
fontSize: 12,
fontWeight: '700',
},
card: {
padding: 18,
borderRadius: 20,
borderWidth: 1,
gap: 14,
},
sectionTitle: {
fontSize: 15,
fontWeight: '700',
},
segmentedControl: {
flexDirection: 'row',
backgroundColor: '#00000010',
borderRadius: 14,
padding: 4,
},
segmentBtn: {
flex: 1,
paddingVertical: 12,
borderRadius: 10,
alignItems: 'center',
},
segmentText: {
fontSize: 14,
fontWeight: '600',
},
swatchContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 10,
},
swatchWrap: {
flex: 1,
alignItems: 'center',
paddingVertical: 8,
borderRadius: 16,
borderWidth: 2,
borderColor: 'transparent',
gap: 8,
},
swatch: {
width: 52,
height: 52,
borderRadius: 26,
},
swatchLabel: {
fontSize: 12,
fontWeight: '600',
},
languageRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
languageBtn: {
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 14,
backgroundColor: '#00000010',
},
footer: {
flexDirection: 'row',
gap: 12,
paddingHorizontal: 20,
paddingBottom: 20,
},
secondaryBtn: {
flex: 1,
height: 52,
borderRadius: 16,
borderWidth: 1.5,
alignItems: 'center',
justifyContent: 'center',
},
secondaryBtnText: {
fontSize: 15,
fontWeight: '600',
},
primaryBtn: {
flex: 1.3,
height: 52,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
},
primaryBtnText: {
fontSize: 15,
fontWeight: '700',
},
});

View File

@@ -0,0 +1,198 @@
import React, { useMemo, useState } from 'react';
import { ImageBackground, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAnalytics } from '../../services/analytics';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { useColors } from '../../constants/Colors';
import { useApp } from '../../context/AppContext';
import { OnboardingProgressService } from '../../services/onboardingProgressService';
const ONBOARDING_BACKGROUND = {
light: '#fbfaf3',
dark: '#0a110b',
};
const EXPERIENCE_OPTIONS = [
{ id: 'beginner', icon: 'leaf-outline' as const },
{ id: 'intermediate', icon: 'sunny-outline' as const },
{ id: 'advanced', icon: 'flask-outline' as const },
];
const getExperienceScreenCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'de') {
return {
step: 'Schritt 3 von 4',
heroBadge: 'Pflege-Tiefe',
subtitles: {
beginner: 'Klare Sprache, sichere Defaults, weniger Fachbegriffe.',
intermediate: 'Praktische Schritte mit genug Kontext.',
advanced: 'Mehr botanische Details und engere Diagnose.',
},
};
}
if (language === 'es') {
return {
step: 'Paso 3 de 4',
heroBadge: 'Nivel de cuidado',
subtitles: {
beginner: 'Lenguaje claro y recomendaciones seguras.',
intermediate: 'Pasos practicos con suficiente contexto.',
advanced: 'Mas detalle botanico y diagnostico preciso.',
},
};
}
return {
step: 'Step 3 of 4',
heroBadge: 'Care depth',
subtitles: {
beginner: 'Clear language, fewer assumptions, safer defaults.',
intermediate: 'Practical care steps with enough detail.',
advanced: 'More botanical context and tighter diagnosis.',
},
};
};
export default function OnboardingExperienceScreen() {
const router = useRouter();
const posthog = useSafeAnalytics();
const { session, isDarkMode, colorPalette, language, t } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light;
const [selectedLevel, setSelectedLevel] = useState<string | null>(null);
const copy = getExperienceScreenCopy(language);
const levelLabels = useMemo(
() => ({
beginner: t.experienceOptionBeginner,
intermediate: t.experienceOptionIntermediate,
advanced: t.experienceOptionAdvanced,
}),
[t.experienceOptionAdvanced, t.experienceOptionBeginner, t.experienceOptionIntermediate],
);
const finish = (level: string | null) => {
if (session?.userId && level) {
OnboardingProgressService.setExperienceLevel(session.userId, level);
}
posthog.capture('onboarding_experience_completed', {
experience_level: level ?? 'skipped',
});
router.replace('/onboarding/health-check');
};
return (
<View style={[styles.container, { backgroundColor: screenBackground }]}>
{isDarkMode ? <ThemeBackdrop colors={colors} /> : null}
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right', 'bottom']}>
<View style={styles.header}>
<View style={[styles.stepPill, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
<Text style={[styles.stepLabel, { color: colors.primaryDark }]}>{copy.step}</Text>
</View>
<ImageBackground
source={require('../../assets/onboarding_experience_mockup.png')}
style={[styles.heroPreview, { borderColor: colors.border }]}
imageStyle={styles.heroImage}
resizeMode="cover"
>
<View style={[styles.heroOverlay, { backgroundColor: isDarkMode ? 'rgba(8, 14, 9, 0.4)' : 'rgba(251, 250, 243, 0.24)' }]} />
<View style={[styles.heroMetric, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Ionicons name="sparkles-outline" size={18} color={colors.primary} />
<Text style={[styles.heroMetricText, { color: colors.text }]}>{copy.heroBadge}</Text>
</View>
</ImageBackground>
<Text style={[styles.title, { color: colors.text }]}>{t.experienceOnboardingTitle}</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>{t.experienceOnboardingSubtitle}</Text>
</View>
<View style={styles.options}>
{EXPERIENCE_OPTIONS.map((option) => {
const isActive = selectedLevel === option.id;
return (
<TouchableOpacity
key={option.id}
style={[
styles.optionCard,
{
backgroundColor: isActive ? colors.primarySoft : colors.surface,
borderColor: isActive ? colors.primary : colors.border,
},
]}
onPress={() => setSelectedLevel(option.id)}
activeOpacity={0.85}
>
<View style={[styles.optionIcon, { backgroundColor: isActive ? colors.primary : colors.surfaceMuted }]}>
<Ionicons name={option.icon} size={18} color={isActive ? colors.onPrimary : colors.textMuted} />
</View>
<View style={styles.optionCopy}>
<Text style={[styles.optionLabel, { color: colors.text }]}>{levelLabels[option.id as keyof typeof levelLabels]}</Text>
<Text style={[styles.optionSubtitle, { color: colors.textMuted }]}>
{copy.subtitles[option.id as keyof typeof copy.subtitles]}
</Text>
</View>
{isActive && <Ionicons name="checkmark-circle" size={18} color={colors.primary} />}
</TouchableOpacity>
);
})}
</View>
<View style={styles.footer}>
<TouchableOpacity
style={[styles.secondaryBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surface }]}
onPress={() => finish(null)}
>
<Text style={[styles.secondaryBtnText, { color: colors.text }]}>{t.experienceOnboardingSkip}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.primaryBtn, { backgroundColor: selectedLevel ? colors.primary : colors.surfaceMuted }]}
onPress={() => finish(selectedLevel)}
disabled={!selectedLevel}
>
<Text style={[styles.primaryBtnText, { color: selectedLevel ? colors.onPrimary : colors.textMuted }]}>
{t.experienceOnboardingContinue}
</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
safeArea: { flex: 1, paddingHorizontal: 20, paddingTop: 12, paddingBottom: 14 },
header: { alignItems: 'center', gap: 9, marginBottom: 14 },
stepPill: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 7 },
stepLabel: { fontSize: 12, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.4 },
heroPreview: { width: '100%', height: 175, borderRadius: 24, borderWidth: 1, overflow: 'hidden', justifyContent: 'flex-end', alignItems: 'flex-start' },
heroImage: { borderRadius: 24 },
heroOverlay: { ...StyleSheet.absoluteFillObject },
heroMetric: { margin: 12, borderRadius: 999, borderWidth: 1, paddingHorizontal: 11, paddingVertical: 7, flexDirection: 'row', alignItems: 'center', gap: 6 },
heroMetricText: { fontSize: 12, fontWeight: '800' },
title: { fontSize: 25, fontWeight: '800', textAlign: 'center', lineHeight: 29 },
subtitle: { fontSize: 13, textAlign: 'center', lineHeight: 18, maxWidth: 320 },
options: { gap: 8, flex: 1 },
optionCard: {
flex: 1,
borderRadius: 15,
borderWidth: 1.5,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 16,
gap: 10,
},
optionIcon: { width: 34, height: 34, borderRadius: 17, alignItems: 'center', justifyContent: 'center' },
optionCopy: { flex: 1, gap: 3 },
optionLabel: { fontSize: 14, fontWeight: '700' },
optionSubtitle: { fontSize: 10.5, lineHeight: 14 },
footer: { flexDirection: 'row', gap: 12, marginTop: 10 },
secondaryBtn: { flex: 1, height: 50, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' },
secondaryBtnText: { fontSize: 15, fontWeight: '600' },
primaryBtn: { flex: 1.2, height: 50, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
primaryBtnText: { fontSize: 15, fontWeight: '700' },
});

203
app/onboarding/goal.tsx Normal file
View File

@@ -0,0 +1,203 @@
import React, { useMemo, useState } from 'react';
import { ImageBackground, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAnalytics } from '../../services/analytics';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { useColors } from '../../constants/Colors';
import { useApp } from '../../context/AppContext';
import { OnboardingProgressService } from '../../services/onboardingProgressService';
const ONBOARDING_BACKGROUND = {
light: '#fbfaf3',
dark: '#0a110b',
};
const GOAL_OPTIONS = [
{ id: 'identify', icon: 'scan-outline' as const },
{ id: 'care', icon: 'water-outline' as const },
{ id: 'collection', icon: 'albums-outline' as const },
{ id: 'learn', icon: 'book-outline' as const },
];
const getGoalScreenCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'de') {
return {
step: 'Schritt 2 von 4',
heroBadge: 'Erstes Ziel',
subtitles: {
identify: 'Schnell erkennen, Pflege danach klaeren.',
care: 'Aus Symptomen konkrete Schritte machen.',
collection: 'Eine saubere Pflanzenbibliothek aufbauen.',
learn: 'Pflanzenwissen einfacher einsortieren.',
},
};
}
if (language === 'es') {
return {
step: 'Paso 2 de 4',
heroBadge: 'Primer objetivo',
subtitles: {
identify: 'Respuesta rapida primero, cuidado despues.',
care: 'Convertir sintomas en pasos claros.',
collection: 'Crear una biblioteca de plantas ordenada.',
learn: 'Aprender plantas con explicaciones simples.',
},
};
}
return {
step: 'Step 2 of 4',
heroBadge: 'First goal',
subtitles: {
identify: 'Fast answer first, care details after.',
care: 'Turn symptoms into a clear next step.',
collection: 'Build a tidy plant library over time.',
learn: 'Browse plants with simpler explanations.',
},
};
};
export default function OnboardingGoalScreen() {
const router = useRouter();
const posthog = useSafeAnalytics();
const { session, isDarkMode, colorPalette, language, t } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light;
const [selectedGoal, setSelectedGoal] = useState<string | null>(null);
const copy = getGoalScreenCopy(language);
const goalLabels = useMemo(
() => ({
identify: t.goalOptionIdentify,
care: t.goalOptionCare,
collection: t.goalOptionCollection,
learn: t.goalOptionLearn,
}),
[t.goalOptionCare, t.goalOptionCollection, t.goalOptionIdentify, t.goalOptionLearn],
);
const finish = (goal: string | null) => {
if (session?.userId && goal) {
OnboardingProgressService.setPrimaryGoal(session.userId, goal);
}
posthog.capture('onboarding_goal_completed', {
goal: goal ?? 'skipped',
});
router.replace('/onboarding/experience');
};
return (
<View style={[styles.container, { backgroundColor: screenBackground }]}>
{isDarkMode ? <ThemeBackdrop colors={colors} /> : null}
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right', 'bottom']}>
<View style={styles.header}>
<View style={[styles.stepPill, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
<Text style={[styles.stepLabel, { color: colors.primaryDark }]}>{copy.step}</Text>
</View>
<ImageBackground
source={require('../../assets/onboarding_goal_mockup.png')}
style={[styles.heroPreview, { borderColor: colors.border }]}
imageStyle={styles.heroImage}
resizeMode="cover"
>
<View style={[styles.heroOverlay, { backgroundColor: isDarkMode ? 'rgba(8, 14, 9, 0.22)' : 'rgba(251, 250, 243, 0.28)' }]} />
<View style={[styles.heroBadge, { backgroundColor: colors.primary }]}>
<Ionicons name="flag-outline" size={16} color={colors.onPrimary} />
<Text style={[styles.heroBadgeText, { color: colors.onPrimary }]}>{copy.heroBadge}</Text>
</View>
</ImageBackground>
<Text style={[styles.title, { color: colors.text }]}>{t.goalOnboardingTitle}</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>{t.goalOnboardingSubtitle}</Text>
</View>
<View style={styles.options}>
{GOAL_OPTIONS.map((option) => {
const isActive = selectedGoal === option.id;
return (
<TouchableOpacity
key={option.id}
style={[
styles.optionCard,
{
backgroundColor: isActive ? colors.primarySoft : colors.surface,
borderColor: isActive ? colors.primary : colors.border,
},
]}
onPress={() => setSelectedGoal(option.id)}
activeOpacity={0.85}
>
<View style={[styles.optionIcon, { backgroundColor: isActive ? colors.primary : colors.surfaceMuted }]}>
<Ionicons name={option.icon} size={18} color={isActive ? colors.onPrimary : colors.textMuted} />
</View>
<View style={styles.optionCopy}>
<Text style={[styles.optionLabel, { color: colors.text }]}>{goalLabels[option.id as keyof typeof goalLabels]}</Text>
<Text style={[styles.optionSubtitle, { color: colors.textMuted }]}>
{copy.subtitles[option.id as keyof typeof copy.subtitles]}
</Text>
</View>
{isActive && <Ionicons name="checkmark-circle" size={18} color={colors.primary} />}
</TouchableOpacity>
);
})}
</View>
<View style={styles.footer}>
<TouchableOpacity
style={[styles.secondaryBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surface }]}
onPress={() => finish(null)}
>
<Text style={[styles.secondaryBtnText, { color: colors.text }]}>{t.goalOnboardingSkip}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.primaryBtn, { backgroundColor: selectedGoal ? colors.primary : colors.surfaceMuted }]}
onPress={() => finish(selectedGoal)}
disabled={!selectedGoal}
>
<Text style={[styles.primaryBtnText, { color: selectedGoal ? colors.onPrimary : colors.textMuted }]}>
{t.goalOnboardingContinue}
</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
safeArea: { flex: 1, paddingHorizontal: 20, paddingTop: 12, paddingBottom: 14 },
header: { alignItems: 'center', gap: 9, marginBottom: 14 },
stepPill: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 7 },
stepLabel: { fontSize: 12, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.4 },
heroPreview: { width: '100%', height: 175, borderRadius: 24, borderWidth: 1, overflow: 'hidden', justifyContent: 'flex-end', alignItems: 'flex-start' },
heroImage: { borderRadius: 24 },
heroOverlay: { ...StyleSheet.absoluteFillObject },
heroBadge: { margin: 12, borderRadius: 999, paddingHorizontal: 11, paddingVertical: 7, flexDirection: 'row', alignItems: 'center', gap: 6 },
heroBadgeText: { fontSize: 12, fontWeight: '800' },
title: { fontSize: 25, fontWeight: '800', textAlign: 'center', lineHeight: 29 },
subtitle: { fontSize: 13, textAlign: 'center', lineHeight: 18, maxWidth: 320 },
options: { gap: 8, flex: 1 },
optionCard: {
flex: 1,
borderRadius: 15,
borderWidth: 1.5,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 12,
gap: 10,
},
optionIcon: { width: 34, height: 34, borderRadius: 17, alignItems: 'center', justifyContent: 'center' },
optionCopy: { flex: 1, gap: 3 },
optionLabel: { fontSize: 14, fontWeight: '700' },
optionSubtitle: { fontSize: 10.5, lineHeight: 14 },
footer: { flexDirection: 'row', gap: 12, marginTop: 10 },
secondaryBtn: { flex: 1, height: 50, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' },
secondaryBtnText: { fontSize: 15, fontWeight: '600' },
primaryBtn: { flex: 1.2, height: 50, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
primaryBtnText: { fontSize: 15, fontWeight: '700' },
});

View File

@@ -0,0 +1,202 @@
import React from 'react';
import { ImageBackground, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAnalytics } from '../../services/analytics';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { useColors } from '../../constants/Colors';
import { useApp } from '../../context/AppContext';
const ONBOARDING_BACKGROUND = {
light: '#fbfaf3',
dark: '#0a110b',
};
const getHealthOnboardingCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'de') {
return {
step: 'Schritt 4 von 4',
title: 'Wo ist der Health-Scan?',
subtitle: 'Du findest ihn auf jeder gespeicherten Pflanze, direkt unter der Beschreibung.',
buttonPreview: 'Health-Scan starten',
cta: 'Weiter',
skip: 'Spaeter',
flow: ['Pflanze scannen', 'Speichern', 'Detailseite oeffnen', 'Health-Scan starten'],
outputTitle: 'Was du danach bekommst',
outputs: [
'Gesundheits-Score mit Status: stabil, beobachten oder kritisch.',
'Ausfuehrliche Analyse mit sichtbaren Hinweisen und Unsicherheit.',
'Wahrscheinlichste Ursachen mit Confidence-Werten.',
'Sofortmassnahmen plus konkreter 7-Tage-Pflegeplan.',
],
guidanceNote: 'Tipp: Fotografiere die ganze Pflanze, die Blattunterseiten und die Erde. Je klarer das Foto, desto genauer wird der Plan.',
};
}
if (language === 'es') {
return {
step: 'Paso 4 de 4',
title: 'Donde esta el health-scan?',
subtitle: 'Lo encuentras en cada planta guardada, justo debajo de la descripcion.',
buttonPreview: 'Iniciar health-scan',
cta: 'Continuar',
skip: 'Mas tarde',
flow: ['Escanear planta', 'Guardar', 'Abrir detalle', 'Iniciar health-scan'],
outputTitle: 'Que recibes despues',
outputs: [
'Puntaje de salud con estado: estable, observar o critico.',
'Analisis detallado con senales visibles e incertidumbre.',
'Causas probables con valores de confianza.',
'Acciones inmediatas y plan concreto de 7 dias.',
],
guidanceNote: 'Consejo: fotografia la planta completa, el reverso de las hojas y el sustrato. Cuanto mas clara sea la foto, mas preciso sera el plan.',
};
}
return {
step: 'Step 4 of 4',
title: 'Where is the health scan?',
subtitle: 'It lives on every saved plant, directly below the plant description.',
buttonPreview: 'Start health scan',
cta: 'Continue',
skip: 'Later',
flow: ['Scan plant', 'Save', 'Open detail', 'Start health scan'],
outputTitle: 'What you get after',
outputs: [
'Health score with stable, watch, or critical status.',
'Detailed analysis with visible signals and uncertainty.',
'Most likely causes with confidence values.',
'Immediate actions plus a concrete 7-day care plan.',
],
guidanceNote: 'Tip: photograph the full plant, leaf undersides, and the soil. The clearer the photo, the more precise the plan.',
};
};
export default function HealthCheckOnboardingScreen() {
const router = useRouter();
const posthog = useSafeAnalytics();
const { isDarkMode, colorPalette, language, billingSummary } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light;
const copy = getHealthOnboardingCopy(language);
const finish = (skipped = false) => {
posthog.capture('onboarding_health_check_explained', {
skipped,
plan: billingSummary?.entitlement?.plan ?? 'free',
});
const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro'
&& billingSummary?.entitlement?.status === 'active';
router.replace(hasActiveEntitlement ? '/(tabs)' : '/profile/billing');
};
return (
<View style={[styles.container, { backgroundColor: screenBackground }]}>
{isDarkMode ? <ThemeBackdrop colors={colors} /> : null}
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right', 'bottom']}>
<View style={styles.header}>
<View style={[styles.stepPill, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
<Text style={[styles.stepLabel, { color: colors.primaryDark }]}>{copy.step}</Text>
</View>
<Text style={[styles.title, { color: colors.text }]}>{copy.title}</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>{copy.subtitle}</Text>
</View>
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<ImageBackground
source={require('../../assets/onboarding_health_scan_mockup.png')}
style={[styles.illustration, { borderColor: colors.border }]}
imageStyle={styles.illustrationImage}
resizeMode="cover"
>
<View style={[styles.illustrationOverlay, { backgroundColor: isDarkMode ? 'rgba(8, 14, 9, 0.08)' : 'rgba(251, 250, 243, 0.04)' }]} />
</ImageBackground>
<View style={[styles.flowCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
{copy.flow.map((item, index) => (
<View key={item} style={styles.flowRow}>
<View style={[styles.flowIndex, { backgroundColor: index === 3 ? colors.primary : colors.surfaceMuted }]}>
<Text style={[styles.flowIndexText, { color: index === 3 ? colors.onPrimary : colors.textMuted }]}>
{index + 1}
</Text>
</View>
<Text style={[styles.flowText, { color: colors.text }]}>{item}</Text>
</View>
))}
</View>
<View style={[styles.outputCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.outputTitle, { color: colors.text }]}>{copy.outputTitle}</Text>
{copy.outputs.map((item) => (
<View key={item} style={styles.outputRow}>
<Ionicons name="checkmark-circle" size={16} color={colors.success} />
<Text style={[styles.outputText, { color: colors.textSecondary }]}>{item}</Text>
</View>
))}
</View>
<View style={[styles.guidanceCard, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
<Ionicons name="camera-outline" size={18} color={colors.primaryDark} />
<Text style={[styles.guidanceText, { color: colors.primaryDark }]}>{copy.guidanceNote}</Text>
</View>
</ScrollView>
<View style={styles.footer}>
<TouchableOpacity
style={[styles.secondaryBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surface }]}
onPress={() => finish(true)}
>
<Text style={[styles.secondaryBtnText, { color: colors.text }]}>{copy.skip}</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.primaryBtn, { backgroundColor: colors.primary }]} onPress={() => finish(false)}>
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>{copy.cta}</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
safeArea: { flex: 1, paddingHorizontal: 20, paddingTop: 24, paddingBottom: 20 },
header: { gap: 9, marginBottom: 18 },
stepPill: { alignSelf: 'flex-start', borderWidth: 1, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 7 },
stepLabel: { fontSize: 12, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.4 },
title: { fontSize: 30, lineHeight: 34, fontWeight: '900' },
subtitle: { fontSize: 14, lineHeight: 20 },
content: { gap: 14, paddingBottom: 12 },
illustration: { height: 230, borderRadius: 28, borderWidth: 1, justifyContent: 'center', overflow: 'hidden' },
illustrationImage: { borderRadius: 28 },
illustrationOverlay: { ...StyleSheet.absoluteFillObject },
phone: { width: 178, minHeight: 156, borderRadius: 26, borderWidth: 1, padding: 12, gap: 10, marginLeft: 16 },
phoneHeader: { height: 58, borderRadius: 18, justifyContent: 'flex-end', padding: 10 },
phoneTitle: { fontSize: 13, fontWeight: '800' },
phoneRows: { gap: 8 },
phoneRowLong: { height: 8, borderRadius: 999 },
phoneRowShort: { width: '66%', height: 8, borderRadius: 999 },
healthButtonPreview: { height: 34, borderRadius: 14, borderWidth: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 5 },
healthButtonText: { fontSize: 10, fontWeight: '800' },
scanCard: { position: 'absolute', right: 16, bottom: 20, width: 136, borderRadius: 20, borderWidth: 1, padding: 14, gap: 7 },
scanScore: { fontSize: 25, lineHeight: 29, fontWeight: '900' },
scanLabel: { fontSize: 11, fontWeight: '800', textTransform: 'uppercase' },
scanLine: { height: 8, borderRadius: 999 },
scanLineShort: { width: '68%', height: 8, borderRadius: 999 },
flowCard: { borderRadius: 18, borderWidth: 1, padding: 14, gap: 10 },
flowRow: { flexDirection: 'row', alignItems: 'center', gap: 10 },
flowIndex: { width: 26, height: 26, borderRadius: 13, alignItems: 'center', justifyContent: 'center' },
flowIndexText: { fontSize: 12, fontWeight: '900' },
flowText: { flex: 1, fontSize: 14, fontWeight: '700' },
outputCard: { borderRadius: 18, borderWidth: 1, padding: 16, gap: 11 },
outputTitle: { fontSize: 15, fontWeight: '800' },
outputRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 9 },
outputText: { flex: 1, fontSize: 13, lineHeight: 18 },
guidanceCard: { borderRadius: 18, borderWidth: 1, padding: 14, flexDirection: 'row', alignItems: 'flex-start', gap: 10 },
guidanceText: { flex: 1, fontSize: 12, lineHeight: 18, fontWeight: '600' },
footer: { flexDirection: 'row', gap: 12, marginTop: 12 },
secondaryBtn: { flex: 1, height: 52, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' },
secondaryBtnText: { fontSize: 15, fontWeight: '600' },
primaryBtn: { flex: 1.3, height: 52, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
primaryBtnText: { fontSize: 15, fontWeight: '700' },
});

358
app/onboarding/source.tsx Normal file
View File

@@ -0,0 +1,358 @@
import React, { useMemo, useState } from 'react';
import { ImageBackground, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAnalytics } from '../../services/analytics';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { useColors } from '../../constants/Colors';
import { useApp } from '../../context/AppContext';
import { OnboardingProgressService } from '../../services/onboardingProgressService';
const ONBOARDING_BACKGROUND = {
light: '#fbfaf3',
dark: '#0a110b',
};
const SOURCE_OPTIONS = [
{ id: 'app_store', icon: 'storefront-outline' as const, signal: 'organic_store' },
{ id: 'instagram', icon: 'logo-instagram' as const, signal: 'social_visual' },
{ id: 'tiktok', icon: 'musical-notes-outline' as const, signal: 'social_video' },
{ id: 'friend', icon: 'people-outline' as const, signal: 'referral' },
{ id: 'search', icon: 'search-outline' as const, signal: 'high_intent_search' },
{ id: 'other', icon: 'ellipsis-horizontal-circle-outline' as const, signal: 'unclassified' },
];
const getSourceOnboardingCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'de') {
return {
step: 'Schritt 1 von 4',
heroTitle: 'Dein Start wird danach personalisiert.',
heroMeta: 'Scan, Sammlung und Health-Check passen sich deinem Ziel an.',
valueTitle: 'Warum wir fragen',
valueBody: 'Die Antwort hilft, deinen Einstieg auf das auszurichten, was dich wirklich hierher gebracht hat.',
subtitles: {
app_store: 'Du hast aktiv nach Pflanzen- oder Pflegehilfe gesucht.',
instagram: 'Du kamst ueber visuelle Pflanzen-Inhalte.',
tiktok: 'Du kamst ueber kurze Videos oder Creator.',
friend: 'Persoenliche Empfehlung, hoher Vertrauens-Intent.',
search: 'Konkretes Problem oder schneller Pflanzen-Check.',
other: 'Passt nicht sauber in die anderen Quellen.',
},
};
}
if (language === 'es') {
return {
step: 'Paso 1 de 4',
heroTitle: 'Tu inicio se adapta despues.',
heroMeta: 'Escaneo, coleccion y health-check segun tu objetivo.',
valueTitle: 'Por que preguntamos',
valueBody: 'La respuesta ayuda a adaptar el inicio a lo que realmente te trajo aqui.',
subtitles: {
app_store: 'Buscaste ayuda para plantas o cuidado.',
instagram: 'Llegaste desde contenido visual de plantas.',
tiktok: 'Llegaste desde videos cortos o creadores.',
friend: 'Recomendacion personal con alta confianza.',
search: 'Problema concreto o chequeo rapido.',
other: 'No encaja en las demas fuentes.',
},
};
}
return {
step: 'Step 1 of 4',
heroTitle: 'Your first run adapts next.',
heroMeta: 'Scanner, collection, and health check based on your goal.',
valueTitle: 'Why we ask',
valueBody: 'This helps tailor the first steps to what actually brought you here.',
subtitles: {
app_store: 'You actively searched for plant or care help.',
instagram: 'You came from visual plant content.',
tiktok: 'You came from short videos or creators.',
friend: 'Personal referral with high trust intent.',
search: 'Concrete problem or quick plant check intent.',
other: 'Does not fit the other sources cleanly.',
},
};
};
export default function OnboardingSourceScreen() {
const router = useRouter();
const posthog = useSafeAnalytics();
const { session, isDarkMode, colorPalette, language, t } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light;
const [selectedSource, setSelectedSource] = useState<string | null>(null);
const copy = getSourceOnboardingCopy(language);
const sourceLabels = useMemo(
() => ({
app_store: t.sourceOptionAppStore,
instagram: t.sourceOptionInstagram,
tiktok: t.sourceOptionTikTok,
friend: t.sourceOptionFriend,
search: t.sourceOptionSearch,
other: t.sourceOptionOther,
}),
[
t.sourceOptionAppStore,
t.sourceOptionFriend,
t.sourceOptionInstagram,
t.sourceOptionOther,
t.sourceOptionSearch,
t.sourceOptionTikTok,
],
);
const finish = (source: string | null) => {
if (session?.userId && source) {
OnboardingProgressService.setAcquisitionSource(session.userId, source);
}
posthog.capture('onboarding_source_completed', {
source: source ?? 'skipped',
revops_signal: SOURCE_OPTIONS.find((option) => option.id === source)?.signal ?? 'skipped',
});
router.replace('/onboarding/goal');
};
return (
<View style={[styles.container, { backgroundColor: screenBackground }]}>
{isDarkMode ? <ThemeBackdrop colors={colors} /> : null}
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right', 'bottom']}>
<View style={styles.header}>
<View style={[styles.stepPill, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
<Text style={[styles.stepLabel, { color: colors.primaryDark }]}>{copy.step}</Text>
</View>
<ImageBackground
source={require('../../assets/onboarding_source_mockup.png')}
style={[styles.heroPreview, { borderColor: colors.border }]}
imageStyle={styles.heroImage}
resizeMode="cover"
>
<View style={[styles.heroOverlay, { backgroundColor: isDarkMode ? 'rgba(8, 14, 9, 0.46)' : 'rgba(251, 250, 243, 0.32)' }]} />
<View style={styles.heroContent}>
<View style={[styles.heroIcon, { backgroundColor: colors.primary }]}>
<Ionicons name="scan-outline" size={20} color={colors.onPrimary} />
</View>
<View style={styles.heroCopy}>
<Text style={[styles.heroTitle, { color: isDarkMode ? colors.textOnImage : colors.text }]}>
{copy.heroTitle}
</Text>
<Text style={[styles.heroMeta, { color: isDarkMode ? '#d7ded9' : colors.textSecondary }]}>
{copy.heroMeta}
</Text>
</View>
</View>
</ImageBackground>
<Text style={[styles.title, { color: colors.text }]}>{t.sourceOnboardingTitle}</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>{t.sourceOnboardingSubtitle}</Text>
</View>
<View style={styles.options}>
{SOURCE_OPTIONS.map((option) => {
const isActive = selectedSource === option.id;
return (
<TouchableOpacity
key={option.id}
style={[
styles.optionCard,
{
backgroundColor: isActive ? colors.primarySoft : colors.surface,
borderColor: isActive ? colors.primary : colors.border,
},
]}
onPress={() => setSelectedSource(option.id)}
activeOpacity={0.85}
>
<View style={[styles.optionIcon, { backgroundColor: isActive ? colors.primary : colors.surfaceMuted }]}>
<Ionicons name={option.icon} size={18} color={isActive ? colors.onPrimary : colors.textMuted} />
</View>
<View style={styles.optionCopy}>
<Text style={[styles.optionLabel, { color: colors.text }]}>{sourceLabels[option.id as keyof typeof sourceLabels]}</Text>
<Text style={[styles.optionSubtitle, { color: colors.textMuted }]}>
{copy.subtitles[option.id as keyof typeof copy.subtitles]}
</Text>
</View>
{isActive && <Ionicons name="checkmark-circle" size={18} color={colors.primary} style={styles.optionCheck} />}
</TouchableOpacity>
);
})}
</View>
<View style={styles.footer}>
<TouchableOpacity
style={[styles.secondaryBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surface }]}
onPress={() => finish(null)}
>
<Text style={[styles.secondaryBtnText, { color: colors.text }]}>{t.sourceOnboardingSkip}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.primaryBtn,
{ backgroundColor: selectedSource ? colors.primary : colors.surfaceMuted },
]}
onPress={() => finish(selectedSource)}
disabled={!selectedSource}
>
<Text
style={[
styles.primaryBtnText,
{ color: selectedSource ? colors.onPrimary : colors.textMuted },
]}
>
{t.sourceOnboardingContinue}
</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
safeArea: {
flex: 1,
paddingHorizontal: 20,
paddingTop: 12,
paddingBottom: 14,
justifyContent: 'space-between',
},
header: {
alignItems: 'center',
gap: 9,
},
stepPill: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 7,
},
stepLabel: {
fontSize: 12,
fontWeight: '800',
textTransform: 'uppercase',
letterSpacing: 0.4,
},
heroPreview: {
width: '100%',
height: 175,
borderRadius: 24,
borderWidth: 1,
justifyContent: 'flex-end',
overflow: 'hidden',
},
heroImage: {
borderRadius: 24,
},
heroOverlay: {
...StyleSheet.absoluteFillObject,
},
heroContent: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: 12,
padding: 12,
},
heroIcon: {
width: 36,
height: 36,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
heroCopy: {
flex: 1,
gap: 3,
},
heroTitle: {
fontSize: 15,
lineHeight: 18,
fontWeight: '800',
},
heroMeta: {
fontSize: 10.5,
lineHeight: 14,
fontWeight: '600',
},
title: {
fontSize: 25,
fontWeight: '800',
textAlign: 'center',
lineHeight: 29,
},
subtitle: {
fontSize: 13,
textAlign: 'center',
lineHeight: 18,
maxWidth: 320,
},
options: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
optionCard: {
width: '48.8%',
minHeight: 68,
borderRadius: 15,
borderWidth: 1.5,
padding: 9,
gap: 8,
position: 'relative',
},
optionIcon: {
width: 34,
height: 34,
borderRadius: 17,
alignItems: 'center',
justifyContent: 'center',
},
optionCopy: {
gap: 3,
},
optionLabel: {
fontSize: 13,
fontWeight: '700',
},
optionSubtitle: {
fontSize: 10,
lineHeight: 13,
},
optionCheck: {
position: 'absolute',
right: 9,
top: 9,
},
footer: {
flexDirection: 'row',
gap: 12,
},
secondaryBtn: {
flex: 1,
height: 50,
borderRadius: 16,
borderWidth: 1.5,
alignItems: 'center',
justifyContent: 'center',
},
secondaryBtnText: {
fontSize: 15,
fontWeight: '600',
},
primaryBtn: {
flex: 1.2,
height: 50,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
},
primaryBtnText: {
fontSize: 15,
fontWeight: '700',
},
});

View File

@@ -58,9 +58,10 @@ const getHealthCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'de') {
return {
title: 'Health Check',
action: 'Neues Foto + Health-Check',
action: 'Health-Scan starten',
running: 'Neues Foto wird analysiert...',
cost: `Kosten: ${HEALTH_CHECK_CREDIT_COST} Credits`,
intro: 'Fotografiere die ganze Pflanze plus auffaellige Blaetter. Danach bekommst du Diagnose, Dringlichkeit und einen konkreten Pflegeplan.',
creditsLabel: 'Credits',
managePlan: 'Plan verwalten',
noCreditsTitle: 'Nicht genug Credits',
@@ -68,7 +69,9 @@ const getHealthCopy = (language: 'de' | 'en' | 'es') => {
insufficientInline: 'Nicht genug Credits fuer den Health-Check.',
timeoutInline: 'Health-Check Timeout. Bitte erneut versuchen.',
providerInline: 'Health-Check ist gerade nicht verfuegbar.',
issuesTitle: 'Moegliche Ursachen',
analysisTitle: 'Analyse',
analysisFallback: 'Die Pflanze wirkt insgesamt beurteilbar, aber die gespeicherte Analyse enthaelt noch keine ausformulierte Zusammenfassung. Orientiere dich deshalb an Score, Ursachen und Sofortmassnahmen. Pruefe zuerst die auffaelligsten Blaetter, danach Substratfeuchte und Standort. Wenn die Blaetter innerhalb von 48 Stunden weiter haengen, gelb werden oder Flecken ausbreiten, solltest du ein neues Foto bei hellem indirektem Licht aufnehmen. Ein neuer Health-Scan kann dann genauer zwischen Wasserstress, Lichtstress, Schaedlingen und normaler Blattalterung unterscheiden.',
issuesTitle: 'Wahrscheinlichste Ursachen',
actionsTitle: 'Sofortmassnahmen',
planTitle: '7-Tage-Plan',
scoreLabel: 'Gesundheits-Score',
@@ -82,9 +85,10 @@ const getHealthCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'es') {
return {
title: 'Health Check',
action: 'Foto nuevo + Health-check',
action: 'Iniciar health-scan',
running: 'Analizando foto nueva...',
cost: `Costo: ${HEALTH_CHECK_CREDIT_COST} creditos`,
intro: 'Fotografia la planta completa y las hojas llamativas. Luego recibes diagnostico, urgencia y un plan de cuidado concreto.',
creditsLabel: 'Creditos',
managePlan: 'Gestionar plan',
noCreditsTitle: 'Creditos insuficientes',
@@ -92,7 +96,9 @@ const getHealthCopy = (language: 'de' | 'en' | 'es') => {
insufficientInline: 'No hay creditos suficientes para el health-check.',
timeoutInline: 'Health-check agotado por tiempo. Intenta de nuevo.',
providerInline: 'Health-check no disponible ahora.',
issuesTitle: 'Posibles causas',
analysisTitle: 'Analisis',
analysisFallback: 'La planta se puede evaluar en general, pero este chequeo guardado todavia no contiene un resumen completo. Usa el puntaje, las causas y las acciones inmediatas como guia principal. Revisa primero las hojas mas llamativas, despues la humedad del sustrato y la ubicacion. Si las hojas empeoran en 48 horas, amarillean o las manchas se expanden, toma una foto nueva con luz indirecta clara. Un nuevo health-scan podra diferenciar mejor entre exceso o falta de agua, luz, plagas y envejecimiento normal.',
issuesTitle: 'Causas mas probables',
actionsTitle: 'Acciones inmediatas',
planTitle: 'Plan de 7 dias',
scoreLabel: 'Puntaje de salud',
@@ -105,9 +111,10 @@ const getHealthCopy = (language: 'de' | 'en' | 'es') => {
return {
title: 'Health Check',
action: 'New Photo + Health Check',
action: 'Start health scan',
running: 'Analyzing new photo...',
cost: `Cost: ${HEALTH_CHECK_CREDIT_COST} credits`,
intro: 'Photograph the full plant plus any suspicious leaves. You will get a diagnosis, urgency level, and a concrete care plan.',
creditsLabel: 'Credits',
managePlan: 'Manage plan',
noCreditsTitle: 'Not enough credits',
@@ -115,7 +122,9 @@ const getHealthCopy = (language: 'de' | 'en' | 'es') => {
insufficientInline: 'Not enough credits for the health check.',
timeoutInline: 'Health check timed out. Please try again.',
providerInline: 'Health check is unavailable right now.',
issuesTitle: 'Likely issues',
analysisTitle: 'Analysis',
analysisFallback: 'The plant is still assessable, but this saved check does not include a full written summary yet. Use the score, likely causes, and immediate actions as the primary guide. Start by inspecting the most unusual leaves, then check soil moisture and placement. If leaves droop further, yellowing spreads, or spots expand within 48 hours, take a new photo in bright indirect light. A fresh health scan can separate watering stress, light stress, pests, and normal leaf aging more accurately.',
issuesTitle: 'Most likely causes',
actionsTitle: 'Actions now',
planTitle: '7-day plan',
scoreLabel: 'Health score',
@@ -243,6 +252,9 @@ export default function PlantDetailScreen() {
: colors.danger
)
: colors.textMuted;
const latestAnalysisSummary = latestHealthCheck
? latestHealthCheck.analysisSummary || healthCopy.analysisFallback
: '';
const timelineEntries = useMemo(() => {
const history = plant.wateringHistory && plant.wateringHistory.length > 0
@@ -577,7 +589,7 @@ export default function PlantDetailScreen() {
<View style={styles.healthActionRow}>
<View style={styles.healthActionInfo}>
<Text style={[styles.healthActionTitle, { color: textOnSurface }]}>{healthCopy.title}</Text>
<Text style={[styles.healthActionMeta, { color: colors.textMuted }]}>{healthCopy.cost}</Text>
<Text style={[styles.healthActionMeta, { color: colors.textMuted }]}>{healthCopy.intro}</Text>
</View>
<TouchableOpacity
style={[
@@ -642,6 +654,15 @@ export default function PlantDetailScreen() {
{healthCopy.lastCheck}: {new Date(latestHealthCheck.generatedAt).toLocaleString(locale)}
</Text>
{latestAnalysisSummary ? (
<View style={[styles.healthAnalysisBox, { backgroundColor: colors.surfaceMuted, borderColor: colors.border }]}>
<Text style={[styles.healthListTitle, { color: textOnSurface }]}>{healthCopy.analysisTitle}</Text>
<Text style={[styles.healthAnalysisText, { color: colors.textSecondary }]}>
{latestAnalysisSummary}
</Text>
</View>
) : null}
<View style={styles.healthListBlock}>
<Text style={[styles.healthListTitle, { color: textOnSurface }]}>{healthCopy.issuesTitle}</Text>
{latestHealthCheck.likelyIssues.map((issue, index) => (
@@ -1113,6 +1134,16 @@ const styles = StyleSheet.create({
healthListBlock: {
gap: 8,
},
healthAnalysisBox: {
borderRadius: 16,
borderWidth: 1,
padding: 12,
gap: 6,
},
healthAnalysisText: {
fontSize: 12,
lineHeight: 19,
},
healthListTitle: {
fontSize: 13,
fontWeight: '700',

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { Language } from '../../types';
import { AuthService } from '../../services/authService';
const getDataCopy = (language: Language) => {
if (language === 'de') {
@@ -121,9 +122,13 @@ export default function DataScreen() {
text: copy.deleteActionBtn,
style: 'destructive',
onPress: async () => {
// Future implementation: call backend to wipe user data and cancel active app subscriptions
await signOut();
router.replace('/onboarding');
try {
await AuthService.deleteAccount();
await signOut();
router.replace('/onboarding');
} catch {
Alert.alert(copy.genericErrorTitle, copy.genericErrorMessage);
}
},
},
]);

View File

@@ -9,7 +9,10 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as ImagePicker from 'expo-image-picker';
import * as ImageManipulator from 'expo-image-manipulator';
import * as Haptics from 'expo-haptics';
import { usePostHog } from 'posthog-react-native';
import * as AppleAuthentication from 'expo-apple-authentication';
import Constants from 'expo-constants';
import { ShareIntentModule } from 'expo-share-intent';
import { useSafeAnalytics } from '../services/analytics';
import { useApp } from '../context/AppContext';
import { useColors } from '../constants/Colors';
import { PlantRecognitionService } from '../services/plantRecognitionService';
@@ -18,8 +21,12 @@ import { ResultCard } from '../components/ResultCard';
import { backendApiClient, isInsufficientCreditsError, isNetworkError, isTimeoutError } from '../services/backend/backendApiClient';
import { isBackendApiError } from '../services/backend/contracts';
import { createIdempotencyKey } from '../utils/idempotency';
import { AuthService } from '../services/authService';
import { getMockPlantByImage } from '../services/backend/mockCatalog';
import { consumeSharedImageUri, SHARE_INTENT_KEY } from '../utils/shareHandoff';
const HEALTH_CHECK_CREDIT_COST = 2;
const DEMO_SCAN_LIMIT = 5;
const getBillingCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'de') {
@@ -45,6 +52,14 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
healthDoneTitle: 'Health Check abgeschlossen',
healthDoneMessage: 'Neues Foto wurde geprueft und zur Galerie hinzugefuegt.',
signupLabel: 'Registrieren',
demoTitle: 'Rettungsplan bereit',
demoMessage: 'Wir haben mögliche Ursachen erkannt. Schalte die vollständige KI-Diagnose und deinen 7-Tage-Rettungsplan frei.',
demoNoCreditsTitle: 'Demo-Scans aufgebraucht',
demoNoCreditsMessage: 'Du hast deine 5 kostenlosen Demo-Scans auf diesem Gerät genutzt. Starte Pro, um weiter Pflanzen zu scannen.',
demoCreditsRemaining: (count: number) => `${count} Demo-Scans übrig`,
appleCta: 'Mit Apple fortfahren',
emailCta: 'Mit E-Mail fortfahren',
unlockCta: 'Vollständige Diagnose freischalten',
};
}
@@ -71,6 +86,14 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
healthDoneTitle: 'Health-check completado',
healthDoneMessage: 'La foto nueva fue analizada y guardada en la galeria.',
signupLabel: 'Registrarse',
demoTitle: 'Plan de rescate listo',
demoMessage: 'Detectamos posibles causas. Desbloquea el diagnóstico completo con IA y tu plan de rescate de 7 días.',
demoNoCreditsTitle: 'Escaneos demo agotados',
demoNoCreditsMessage: 'Ya usaste tus 5 escaneos demo gratuitos en este dispositivo. Inicia Pro para seguir escaneando plantas.',
demoCreditsRemaining: (count: number) => `${count} escaneos demo restantes`,
appleCta: 'Continuar con Apple',
emailCta: 'Continuar con email',
unlockCta: 'Desbloquear diagnóstico completo',
};
}
@@ -96,12 +119,20 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
healthDoneTitle: 'Health Check Complete',
healthDoneMessage: 'The new photo was analyzed and added to gallery.',
signupLabel: 'Sign Up',
demoTitle: 'Rescue plan ready',
demoMessage: 'We found possible causes. Unlock the full AI diagnosis and your 7-day rescue plan.',
demoNoCreditsTitle: 'Demo scans used',
demoNoCreditsMessage: 'You used your 5 free demo scans on this device. Start Pro to keep scanning plants.',
demoCreditsRemaining: (count: number) => `${count} demo scans left`,
appleCta: 'Continue with Apple',
emailCta: 'Continue with email',
unlockCta: 'Unlock full diagnosis',
};
};
export default function ScannerScreen() {
const params = useLocalSearchParams<{ mode?: string; plantId?: string }>();
const posthog = usePostHog();
const params = useLocalSearchParams<{ mode?: string; plantId?: string; sharedImageKey?: string; sharedImageUri?: string }>();
const posthog = useSafeAnalytics();
const {
isDarkMode,
colorPalette,
@@ -114,6 +145,7 @@ export default function ScannerScreen() {
refreshBillingSummary,
isLoadingBilling,
session,
hydrateSession,
setPendingPlant,
guestScanCount,
incrementGuestScanCount,
@@ -127,18 +159,53 @@ export default function ScannerScreen() {
const healthPlant = isHealthMode && healthPlantId
? plants.find((item) => item.id === healthPlantId)
: null;
const availableCredits = session
? (billingSummary?.credits.available ?? 0)
: Math.max(0, 5 - guestScanCount);
const sharedImageUri = Array.isArray(params.sharedImageUri)
? params.sharedImageUri[0]
: params.sharedImageUri;
const sharedImageKey = Array.isArray(params.sharedImageKey)
? params.sharedImageKey[0]
: params.sharedImageKey;
const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro'
&& billingSummary?.entitlement?.status === 'active';
const isDemoMode = !hasActiveEntitlement;
const availableCredits = hasActiveEntitlement ? (billingSummary?.credits.available ?? 0) : 0;
const demoScansRemaining = Math.max(0, DEMO_SCAN_LIMIT - guestScanCount);
const [permission, requestPermission] = useCameraPermissions();
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [isAuthLoading, setIsAuthLoading] = useState(false);
const [appleAvailable, setAppleAvailable] = useState(false);
const [analysisProgress, setAnalysisProgress] = useState(0);
const [analysisResult, setAnalysisResult] = useState<IdentificationResult | null>(null);
const [demoResultVisible, setDemoResultVisible] = useState(false);
const cameraRef = useRef<CameraView>(null);
const scanLineProgress = useRef(new Animated.Value(0)).current;
const scanPulse = useRef(new Animated.Value(0)).current;
const isExpoGo = Constants.appOwnership === 'expo';
useEffect(() => {
if (isExpoGo) {
setAppleAvailable(false);
return;
}
let mounted = true;
AppleAuthentication.isAvailableAsync()
.then((available) => {
if (mounted) setAppleAvailable(available);
})
.catch(() => {
if (mounted) setAppleAvailable(false);
});
return () => {
mounted = false;
};
}, [isExpoGo]);
const lastProcessedShareToken = useRef<string | null>(null);
const sharedAnalysisInFlightToken = useRef<string | null>(null);
const resizeForAnalysisRef = useRef<(uri: string) => Promise<string>>(async (uri) => uri);
const analyzeImageRef = useRef<(imageUri: string, galleryImageUri?: string) => Promise<void>>(async () => {});
useEffect(() => {
if (!isAnalyzing) {
@@ -187,8 +254,8 @@ export default function ScannerScreen() {
try {
const result = await ImageManipulator.manipulateAsync(
uri,
[{ resize: { width: 768 } }],
{ compress: 0.7, format: ImageManipulator.SaveFormat.JPEG, base64: true },
[{ resize: { width: 1280 } }],
{ compress: 0.9, format: ImageManipulator.SaveFormat.JPEG, base64: true },
);
return result.base64 ? `data:image/jpeg;base64,${result.base64}` : result.uri;
} catch {
@@ -199,12 +266,22 @@ export default function ScannerScreen() {
const analyzeImage = async (imageUri: string, galleryImageUri?: string) => {
if (isAnalyzing) return;
if (availableCredits <= 0) {
if (!session) {
// Guest: show paywall directly — no registration required to purchase
router.push('/profile/billing');
return;
}
if (isDemoMode && guestScanCount >= DEMO_SCAN_LIMIT) {
Alert.alert(
billingCopy.demoNoCreditsTitle,
billingCopy.demoNoCreditsMessage,
[
{ text: billingCopy.dismiss, style: 'cancel' },
{
text: billingCopy.managePlan,
onPress: () => router.replace('/profile/billing'),
},
],
);
return;
}
if (!isDemoMode && availableCredits <= 0) {
Alert.alert(
billingCopy.noCreditsTitle,
isHealthMode ? billingCopy.healthNoCreditsMessage : billingCopy.noCreditsMessage,
@@ -212,7 +289,7 @@ export default function ScannerScreen() {
{ text: billingCopy.dismiss, style: 'cancel' },
{
text: billingCopy.managePlan,
onPress: () => router.replace('/(tabs)/profile'),
onPress: () => router.replace('/profile/billing'),
},
],
);
@@ -222,6 +299,7 @@ export default function ScannerScreen() {
setIsAnalyzing(true);
setAnalysisProgress(0);
setAnalysisResult(null);
setDemoResultVisible(false);
const startTime = Date.now();
@@ -235,6 +313,33 @@ export default function ScannerScreen() {
}, 150);
try {
if (isDemoMode) {
posthog.capture('demo_scan_started', {
authenticated: Boolean(session),
scan_type: isHealthMode ? 'health_check' : 'identification',
demo_scans_used: guestScanCount,
demo_scans_remaining: demoScansRemaining,
});
await new Promise(resolve => setTimeout(resolve, 2100));
setAnalysisProgress(100);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
await new Promise(resolve => setTimeout(resolve, 350));
const demoResult = getMockPlantByImage(galleryImageUri || imageUri, language, true);
incrementGuestScanCount();
setAnalysisResult(demoResult);
posthog.capture('demo_scan_completed', {
authenticated: Boolean(session),
latency_ms: Date.now() - startTime,
demo_scans_used_after: guestScanCount + 1,
});
return;
}
posthog.capture('paid_scan_started', {
scan_type: isHealthMode ? 'health_check' : 'identification',
credits_available: availableCredits,
});
if (isHealthMode) {
if (!healthPlant) {
Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage);
@@ -261,10 +366,6 @@ export default function ScannerScreen() {
latency_ms: Date.now() - startTime,
});
if (!session) {
incrementGuestScanCount();
}
const currentGallery = healthPlant.gallery || [];
const existingChecks = healthPlant.healthChecks || [];
const updatedChecks = [response.healthCheck, ...existingChecks].slice(0, 6);
@@ -285,14 +386,14 @@ export default function ScannerScreen() {
latency_ms: Date.now() - startTime,
});
if (!session) {
incrementGuestScanCount();
}
setAnalysisResult(result);
}
setAnalysisProgress(100);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
posthog.capture('paid_scan_completed', {
scan_type: isHealthMode ? 'health_check' : 'identification',
latency_ms: Date.now() - startTime,
});
await new Promise(resolve => setTimeout(resolve, 500));
setIsAnalyzing(false);
if (isHealthMode && healthPlant) {
@@ -318,7 +419,7 @@ export default function ScannerScreen() {
{ text: billingCopy.dismiss, style: 'cancel' },
{
text: billingCopy.managePlan,
onPress: () => router.replace('/(tabs)/profile'),
onPress: () => router.replace('/profile/billing'),
},
],
);
@@ -362,16 +463,59 @@ export default function ScannerScreen() {
setIsAnalyzing(false);
} finally {
clearInterval(progressInterval);
await refreshBillingSummary();
setIsAnalyzing(false);
if (!isDemoMode) {
await refreshBillingSummary();
}
}
};
useEffect(() => {
resizeForAnalysisRef.current = resizeForAnalysis;
analyzeImageRef.current = analyzeImage;
});
useEffect(() => {
const shareToken = sharedImageKey || sharedImageUri;
if (!shareToken || isLoadingBilling || isAnalyzing) return;
if (lastProcessedShareToken.current === shareToken) return;
if (sharedAnalysisInFlightToken.current) return;
const handoffImageUri = consumeSharedImageUri(sharedImageKey);
const nextSharedImageUri = handoffImageUri || sharedImageUri;
if (!nextSharedImageUri) return;
lastProcessedShareToken.current = shareToken;
sharedAnalysisInFlightToken.current = shareToken;
ShareIntentModule?.clearShareIntent(SHARE_INTENT_KEY);
let cancelled = false;
(async () => {
try {
const analysisUri = await resizeForAnalysisRef.current(nextSharedImageUri);
if (cancelled || sharedAnalysisInFlightToken.current !== shareToken) return;
setDemoResultVisible(false);
setSelectedImage(analysisUri);
await analyzeImageRef.current(analysisUri, nextSharedImageUri);
} finally {
if (sharedAnalysisInFlightToken.current === shareToken) {
sharedAnalysisInFlightToken.current = null;
}
}
})();
return () => {
cancelled = true;
};
}, [sharedImageKey, sharedImageUri, isLoadingBilling, isAnalyzing]);
const takePicture = async () => {
if (!cameraRef.current || isAnalyzing) return;
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
const photo = await cameraRef.current.takePictureAsync({ base64: false, quality: 0.9 });
if (photo) {
const analysisUri = await resizeForAnalysis(photo.uri);
setDemoResultVisible(false);
setSelectedImage(analysisUri);
analyzeImage(analysisUri, photo.uri);
}
@@ -388,6 +532,7 @@ export default function ScannerScreen() {
if (!result.canceled && result.assets[0]) {
const asset = result.assets[0];
const analysisUri = await resizeForAnalysis(asset.uri);
setDemoResultVisible(false);
setSelectedImage(asset.uri);
analyzeImage(analysisUri, asset.uri);
}
@@ -404,7 +549,11 @@ export default function ScannerScreen() {
try {
await savePlant(analysisResult, selectedImage);
router.back();
if (router.canGoBack()) {
router.back();
} else {
router.replace('/(tabs)');
}
} catch (error) {
console.error('Saving identified plant failed', error);
Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage);
@@ -412,8 +561,74 @@ export default function ScannerScreen() {
}
};
const routeToHardPaywall = () => {
posthog.capture('auth_prompt_shown', {
authenticated: Boolean(session),
surface: 'demo_scan_result',
});
if (session) {
router.replace('/profile/billing');
return;
}
router.replace('/auth/signup');
};
const handleDemoAppleSignIn = async () => {
if (!appleAvailable) {
routeToHardPaywall();
return;
}
setIsAuthLoading(true);
posthog.capture('apple_login_started', { surface: 'scanner_demo' });
try {
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
if (!credential.identityToken) {
throw new Error('APPLE_AUTH_INVALID');
}
const fullName = [
credential.fullName?.givenName,
credential.fullName?.familyName,
].filter(Boolean).join(' ');
const nextSession = await AuthService.signInWithApple({
identityToken: credential.identityToken,
appleUser: credential.user,
email: credential.email,
name: fullName || undefined,
});
await hydrateSession(nextSession);
posthog.capture('apple_login_succeeded', { surface: 'scanner_demo' });
router.replace(nextSession.isNewUser ? '/onboarding/source' : '/(tabs)');
} catch (error: any) {
if (error?.code === 'ERR_REQUEST_CANCELED') {
return;
}
posthog.capture('apple_login_failed', {
surface: 'scanner_demo',
error: error instanceof Error ? error.message : String(error),
});
Alert.alert(
billingCopy.genericErrorTitle,
error instanceof Error && error.message === 'APPLE_BACKEND_UNAVAILABLE'
? 'Apple Login ist auf dem Backend noch nicht aktiviert. Bitte Backend neu starten oder deployen.'
: billingCopy.genericErrorMessage,
);
} finally {
setIsAuthLoading(false);
}
};
const handleClose = () => {
router.back();
if (router.canGoBack()) {
router.back();
return;
}
router.replace('/onboarding');
};
const controlsPaddingBottom = Math.max(20, insets.bottom + 10);
@@ -472,9 +687,9 @@ export default function ScannerScreen() {
{isHealthMode ? billingCopy.healthTitle : t.scanner}
</Text>
<View style={[styles.creditBadge, { backgroundColor: colors.heroButton, borderColor: colors.heroButtonBorder }]}>
<Ionicons name="wallet-outline" size={12} color={colors.text} />
<Ionicons name={isDemoMode ? 'sparkles-outline' : 'wallet-outline'} size={12} color={colors.text} />
<Text style={[styles.creditBadgeText, { color: colors.text }]}>
{billingCopy.creditsLabel}: {availableCredits}
{isDemoMode ? billingCopy.demoCreditsRemaining(demoScansRemaining) : `${billingCopy.creditsLabel}: ${availableCredits}`}
</Text>
</View>
</View>
@@ -562,6 +777,61 @@ export default function ScannerScreen() {
</View>
)}
{demoResultVisible && !isAnalyzing ? (
<View
style={[
styles.demoSheet,
{
backgroundColor: colors.background,
borderColor: colors.border,
bottom: analysisBottomOffset,
},
]}
>
<View style={[styles.demoIconWrap, { backgroundColor: colors.primarySoft }]}>
<Ionicons name="sparkles" size={22} color={colors.primary} />
</View>
<Text style={[styles.demoTitle, { color: colors.text }]}>{billingCopy.demoTitle}</Text>
<Text style={[styles.demoMessage, { color: colors.textSecondary }]}>{billingCopy.demoMessage}</Text>
{!session && appleAvailable ? (
<AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
buttonStyle={isDarkMode
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
cornerRadius={12}
style={styles.demoAppleButton}
onPress={handleDemoAppleSignIn}
/>
) : (
<TouchableOpacity
style={[styles.demoPrimaryBtn, { backgroundColor: colors.primary }]}
onPress={session ? routeToHardPaywall : handleDemoAppleSignIn}
disabled={isAuthLoading}
activeOpacity={0.85}
>
<Text style={[styles.demoPrimaryText, { color: colors.onPrimary }]}>
{isAuthLoading ? '...' : session ? billingCopy.unlockCta : appleAvailable ? billingCopy.appleCta : billingCopy.emailCta}
</Text>
</TouchableOpacity>
)}
{!session ? (
<TouchableOpacity
style={[styles.demoSecondaryBtn, { borderColor: colors.borderStrong }]}
onPress={() => {
posthog.capture('auth_prompt_shown', { surface: 'demo_scan_result', method: 'email' });
router.replace('/auth/signup');
}}
activeOpacity={0.85}
>
<Text style={[styles.demoSecondaryText, { color: colors.text }]}>{billingCopy.emailCta}</Text>
</TouchableOpacity>
) : null}
</View>
) : null}
{/* Bottom Controls */}
<View
style={[
@@ -701,6 +971,65 @@ const styles = StyleSheet.create({
shadowRadius: 14,
elevation: 14,
},
demoSheet: {
position: 'absolute',
left: 16,
right: 16,
borderRadius: 22,
borderWidth: 1,
padding: 18,
zIndex: 25,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.24,
shadowRadius: 12,
elevation: 12,
},
demoIconWrap: {
width: 42,
height: 42,
borderRadius: 21,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 10,
},
demoTitle: {
fontSize: 20,
fontWeight: '800',
marginBottom: 6,
},
demoMessage: {
fontSize: 14,
lineHeight: 20,
marginBottom: 14,
},
demoAppleButton: {
width: '100%',
height: 50,
marginBottom: 10,
},
demoPrimaryBtn: {
height: 50,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 10,
},
demoPrimaryText: {
fontSize: 15,
fontWeight: '800',
},
demoSecondaryBtn: {
height: 48,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
demoSecondaryText: {
fontSize: 14,
fontWeight: '700',
},
analysisHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 },
analysisBadge: {
flexDirection: 'row',

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -33,6 +33,7 @@ interface AppState {
profileName: string;
profileImageUri: string | null;
billingSummary: BillingSummary | null;
isActivatingEntitlement: boolean;
resolvedScheme: AppColorScheme;
isDarkMode: boolean;
isInitializing: boolean;
@@ -154,6 +155,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [isLoadingPlants, setIsLoadingPlants] = useState(true);
const [billingSummary, setBillingSummary] = useState<BillingSummary | null>(null);
const [isLoadingBilling, setIsLoadingBilling] = useState(true);
const [isActivatingEntitlement, setIsActivatingEntitlement] = useState(false);
const resolvedScheme: AppColorScheme =
appearanceMode === 'system'
@@ -389,20 +391,45 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
source: RevenueCatSyncSource = 'app_init',
) => {
if (source === 'topup_purchase') {
return;
return false;
}
const activeEntitlements = customerInfo?.entitlements?.active || {};
const rawProEntitlement = activeEntitlements[REVENUECAT_PRO_ENTITLEMENT_ID];
const proEntitlement = getValidProEntitlement(customerInfo);
const isPro = Boolean(proEntitlement);
const now = new Date();
const renewsAt = proEntitlement?.expirationDate || proEntitlement?.expiresDate || null;
const isTrial = (proEntitlement?.periodType || proEntitlement?.period_type || '').toUpperCase() === 'TRIAL';
const monthlyAllowance = isTrial ? 30 : 100;
setBillingSummary((prev) => {
if (!prev) return prev;
if (!proEntitlement && rawProEntitlement) {
return prev;
}
if (!prev && isPro) {
return {
entitlement: {
plan: 'pro',
provider: 'revenuecat',
status: 'active',
renewsAt,
},
credits: {
monthlyAllowance,
usedThisCycle: 0,
topupBalance: 0,
available: monthlyAllowance,
cycleStartedAt: now.toISOString(),
cycleEndsAt: renewsAt || new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString(),
},
availableProducts: ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large'],
};
}
if (!prev) return prev;
return {
...prev,
entitlement: {
@@ -414,6 +441,8 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
},
};
});
return isPro;
}, []);
const syncRevenueCatState = useCallback(async (
@@ -424,7 +453,11 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
source,
customerInfo: summarizeRevenueCatCustomerInfo(customerInfo),
});
applyRevenueCatCustomerInfoLocally(customerInfo, source);
const didActivatePro = applyRevenueCatCustomerInfoLocally(customerInfo, source);
const isSubscriptionActivation = source === 'subscription_purchase' && didActivatePro;
if (isSubscriptionActivation) {
setIsActivatingEntitlement(true);
}
try {
const response = await backendApiClient.syncRevenueCatState({ customerInfo, source });
setBillingSummary(response.billing);
@@ -432,6 +465,10 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
} catch (error) {
console.error('Failed to sync RevenueCat state with backend', error);
return null;
} finally {
if (isSubscriptionActivation) {
setIsActivatingEntitlement(false);
}
}
}, [applyRevenueCatCustomerInfoLocally]);
@@ -538,6 +575,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
profileName,
profileImageUri,
billingSummary,
isActivatingEntitlement,
resolvedScheme,
isDarkMode,
isInitializing,

View File

@@ -64,6 +64,8 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-greenlns}
POSTGRES_USER: ${POSTGRES_USER:-greenlns}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
ports:
- "5434:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:

241
google-ads-keywords.md Normal file
View File

@@ -0,0 +1,241 @@
# GreenLens — Google Ads Keyword Planner
## Plant Identification
**Von Ihnen eingegebene Begriffe**
| Keyword | Suchvolumen | Trend | Comp. | Volatilität | Gebot (oberer Bereich) | CPC |
|---|---|---|---|---|---|---|
| plant identifier app | 1.000 10.000 | 0% | 0% | Mittel | — | 0,95 € |
| identify plant by photo | 1.000 10.000 | 0% | 0% | — | — | — |
| plant scanner app | 1.000 10.000 | 0% | 0% | — | — | — |
| plant identification app | 1.000 10.000 | 0% | 0% | — | — | — |
| plant id app | 1.000 10.000 | 0% | 0% | — | — | — |
| plant recognition app | 1.000 10.000 | 0% | 0% | — | — | — |
| scan plant app | 1.000 10.000 | 0% | 0% | — | — | — |
| identify houseplants | 1.000 10.000 | 0% | 0% | — | — | — |
| plant species identifier | 1.000 10.000 | 0% | 0% | — | — | — |
| plant photo identifier | 1.000 10.000 | 0% | 0% | — | — | — |
---
## Plant Problems / Diagnosis
**Von Ihnen eingegebene Begriffe**
| Keyword | Suchvolumen | Trend | Comp. | Volatilität | Gebot (oberer Bereich) | CPC |
|---|---|---|---|---|---|---|
| plant disease identifier | 1.000 10.000 | +900% | 0% | Mittel | — | 0,95 € |
| why are my plant leaves yellow | 100 1.000 | 0% | 0% | Gering | — | 0,03 € |
| plant pest identification | 100 1.000 | 0% | 0% | Gering | — | 0,94 € |
| diagnose sick plant | 100 1.000 | 0% | -90% | Hoch | — | 0,96 € |
| plant problem solver | 10 100 | 0% | -100% | — | — | — |
| plant doctor app | 1.000 10.000 | 0% | -90% | Mittel | — | 1,25 € |
| plant health check | 100 1.000 | 0% | -90% | Hoch | — | 0,77 € |
| plant rescue app | 10 100 | 0% | 0% | Hoch | — | 0,59 € |
**Keyword-Ideen**
| Keyword | Suchvolumen | Trend | Comp. | Volatilität | Gebot (oberer Bereich) | CPC |
|---|---|---|---|---|---|---|
| plant disease identification | 1.000 10.000 | +900% | 0% | Mittel | — | 0,95 € |
| plant problem identifier | 100 1.000 | 0% | 0% | Hoch | — | 0,96 € |
| plant sickness identifier | 100 1.000 | 0% | 0% | Mittel | — | 1,62 € |
| identify plant disease | 1.000 10.000 | +900% | 0% | Mittel | — | 0,95 € |
---
## Plant Care
**Von Ihnen eingegebene Begriffe**
| Keyword | Suchvolumen | Trend | Comp. | Volatilität | Gebot (oberer Bereich) | CPC |
|---|---|---|---|---|---|---|
| plant care app | 1.000 10.000 | 0% | 0% | Mittel | — | 1,32 € |
| indoor plant care guide | 100 1.000 | 0% | 0% | Mittel | — | 0,54 € |
| plant watering reminders | 100 1.000 | 0% | 0% | Hoch | — | 0,48 € |
| houseplant care tips | 10.000 100.000 | -90% | -90% | Gering | — | 0,08 € |
| plant care reminder app | 10 100 | 0% | 0% | Gering | — | 0,75 € |
| how to care for plants | 1.000 10.000 | 0% | 0% | Gering | — | 0,85 € |
| plant water schedule | 100 1.000 | 0% | 0% | Mittel | — | 1,07 € |
| best plant care app | 1.000 10.000 | 0% | 0% | Mittel | — | 1,54 € |
| indoor plant maintenance | 100 1.000 | +900% | 0% | Mittel | — | 0,34 € |
| plant watering tracker | 10 100 | 0% | 0% | Hoch | — | 0,77 € |
**Keyword-Ideen**
| Keyword | Suchvolumen | Trend | Comp. | Volatilität | Gebot (oberer Bereich) | CPC |
|---|---|---|---|---|---|---|
| plant health app | 1.000 10.000 | 0% | 0% | Hoch | — | 1,49 € |
| free plant care app | 1.000 10.000 | 0% | 0% | Hoch | — | 0,52 € |
| plant app care | 100 1.000 | 0% | 0% | Hoch | — | 2,32 € |
| plant care app free | 1.000 10.000 | 0% | 0% | Hoch | — | 0,70 € |
| plant app care free | 10 100 | 0% | 0% | Hoch | — | 1,06 € |
| plant parent app | 1.000 10.000 | +900% | 0% | Mittel | — | — |
---
## Beginner Plant Owners
**Von Ihnen eingegebene Begriffe**
| Keyword | Suchvolumen | Trend | Comp. | Volatilität | Gebot (oberer Bereich) | CPC |
|---|---|---|---|---|---|---|
| plant care for beginners | 100 1.000 | 0% | 0% | Gering | — | 0,08 € |
| easy indoor plants for beginners | 100 1.000 | 0% | 0% | Hoch | — | 0,05 € |
| easy care plants | 1.000 10.000 | 0% | 0% | Hoch | — | 0,09 € |
**Keyword-Ideen**
| Keyword | Suchvolumen | Trend | Comp. | Volatilität | Gebot (oberer Bereich) | CPC |
|---|---|---|---|---|---|---|
| low maintenance indoor plants | 1.000 10.000 | 0% | 0% | Hoch | — | 0,05 € |
| easy houseplants | 1.000 10.000 | +900% | 0% | Hoch | — | 0,04 € |
| low maintenance outdoor plants | 1.000 10.000 | 0% | 0% | Hoch | — | 0,04 € |
| low maintenance house plants | 1.000 10.000 | 0% | 0% | — | — | — |
---
## AI Plant Tech
**Von Ihnen eingegebene Begriffe**
| Keyword | Suchvolumen | Trend | Comp. | Volatilität | Gebot (oberer Bereich) | CPC |
|---|---|---|---|---|---|---|
| ai plant identifier | 1.000 10.000 | 0% | 0% | Mittel | — | 0,67 € |
| ai plant doctor | 10 100 | 0% | 0% | Gering | — | 1,14 € |
| ai plant care app | 10 100 | 0% | 0% | Mittel | — | 0,34 € |
| plant ai app | 100 1.000 | 0% | 0% | Mittel | — | 0,96 € |
**Keyword-Ideen**
| Keyword | Suchvolumen | Trend | Comp. | Volatilität | Gebot (oberer Bereich) | CPC |
|---|---|---|---|---|---|---|
| ai plant identification app | 10 100 | 0% | 0% | Gering | — | 0,10 € |
| ai flower recognition | 10 100 | +inf | 0% | Mittel | — | 0,55 € |
| ai plant recognition | 10 100 | — | — | — | — | — |
---
## Comparison / Alternatives
**Von Ihnen eingegebene Begriffe**
| Keyword | Suchvolumen | Trend | Comp. | Volatilität | Gebot (oberer Bereich) | CPC |
|---|---|---|---|---|---|---|
| picturethis alternative | 10 100 | 0% | 0% | Gering | — | 0,24 € |
| plant identifier free app | 100 1.000 | 0% | 0% | Mittel | — | 0,31 € |
| best plant identification app | 1.000 10.000 | 0% | 0% | Mittel | — | 0,95 € |
| free plant id app | 10.000 100.000 | +900% | 0% | Mittel | — | 0,32 € |
**Keyword-Ideen**
| Keyword | Suchvolumen | Trend | Comp. | Volatilität | Gebot (oberer Bereich) | CPC |
|---|---|---|---|---|---|---|
| free plant identifier | 1.000 10.000 | 0% | 0% | Mittel | — | 0,32 € |
| free plant identification app | 1.000 10.000 | 0% | 0% | Mittel | — | 0,27 € |
| app to identify plants | 10.000 100.000 | 0% | 0% | Mittel | — | 0,62 € |
| plant app free | 1.000 10.000 | 0% | 0% | — | — | — |
---
## Emergency / Rescue
**Von Ihnen eingegebene Begriffe**
| Keyword | Suchvolumen | Trend | Comp. | Volatilität | Gebot (oberer Bereich) | CPC |
|---|---|---|---|---|---|---|
| revive dying plant | 100 1.000 | 0% | -90% | Gering | — | 0,66 € |
**Keyword-Ideen**
| Keyword | Suchvolumen | Trend | Comp. | Volatilität | Gebot (oberer Bereich) | CPC |
|---|---|---|---|---|---|---|
| my plant is dying what do i do | 10 100 | 0% | 0% | Mittel | — | 1,92 € |
| how to save dying plants indoor | 100 1.000 | 0% | 0% | Gering | — | 0,98 € |
| how to bring a plant back to life | 100 1.000 | 0% | 0% | Mittel | — | 1,07 € |
| how to revive a dying plant | 1.000 10.000 | 0% | -90% | Gering | — | 0,89 € |
| how to save my plant from dying | 10 100 | 0% | 0% | Mittel | — | 1,39 € |
| how to bring my plant back to life | 10 100 | 0% | — | — | — | — |
---
## Specific Plant Types
**Von Ihnen eingegebene Begriffe**
| Keyword | Suchvolumen | Trend | Comp. | Volatilität | Gebot (oberer Bereich) | CPC |
|---|---|---|---|---|---|---|
| succulent care app | 10 100 | 0% | 0% | Gering | — | — |
| monstera care tips | 100 1.000 | 0% | 0% | Gering | — | 0,01 € |
| pothos plant care | 10.000 100.000 | 0% | 0% | Gering | — | 0,02 € |
| snake plant identification | 10 100 | 0% | 0% | Gering | — | — |
| fiddle leaf fig problems | 100 1.000 | 0% | 0% | Gering | — | 0,01 € |
| orchid care app | 10 100 | 0% | 0% | Gering | — | 0,64 € |
| identify tropical plants | 10 100 | +900% | 0% | Mittel | — | 0,66 € |
**Keyword-Ideen**
| Keyword | Suchvolumen | Trend | Comp. | Volatilität | Gebot (oberer Bereich) | CPC |
|---|---|---|---|---|---|---|
| golden pothos care | 1.000 10.000 | 0% | 0% | Gering | — | 0,02 € |
| tropical plant identification | 10 100 | +900% | 0% | Mittel | — | 0,66 € |
| pothos care | 1.000 10.000 | 0% | 0% | Gering | — | 0,02 € |
| tropical house plant identification | 10 100 | +900% | 0% | Mittel | — | 0,27 € |
| pothos soil | 1.000 10.000 | 0% | 0% | Hoch | — | 0,16 € |
| pothos plant care indoor | 1.000 10.000 | 0% | 0% | Mittel | — | 0,02 € |
| silver pothos plant | 1.000 10.000 | 0% | 0% | Hoch | — | 0,25 € |
| tropical plant identification by leaf | 10 100 | 0% | 0% | Gering | — | — |
| scindapsus plant | 100 1.000 | 0% | 0% | Hoch | — | 0,02 € |
| plant pothos | 100.000 1 Mio. | 0% | 0% | Hoch | — | 0,03 € |
| best potting soil for pothos | 100 1.000 | 0% | 0% | Hoch | — | 0,22 € |
| potting soil for pothos | 1.000 10.000 | 0% | 0% | — | — | — |
---
## DACH Market (Deutsch)
**Von Ihnen eingegebene Begriffe**
| Keyword | Suchvolumen | Trend | Comp. | Volatilität | Gebot (oberer Bereich) | CPC |
|---|---|---|---|---|---|---|
| pflanze erkennen app | 1.000 10.000 | 0% | 0% | Mittel | — | 0,13 € |
| pflanzenkrankheit erkennen | 100 1.000 | 0% | 0% | Mittel | — | 0,25 € |
| pflanzendoktor app | 100 1.000 | 0% | 0% | Mittel | — | 0,25 € |
| pflanzenpflege app | 100 1.000 | 0% | 0% | Mittel | — | 0,30 € |
| zimmerpflanzen pflege tipps | 10 100 | 0% | 0% | Gering | — | — |
| pflanzen scanner app | 100 1.000 | +900% | 0% | Mittel | — | 0,25 € |
| zimmerpflanzen lexikon | 100 1.000 | 0% | 0% | Mittel | — | 0,02 € |
| pflanze retten app | 10 100 | 0% | +inf | Hoch | — | 0,31 € |
| kranke pflanze retten | 10 100 | 0% | 0% | Hoch | — | — |
**Keyword-Ideen**
| Keyword | Suchvolumen | Trend | Comp. | Volatilität | Gebot (oberer Bereich) | CPC |
|---|---|---|---|---|---|---|
| pflanzen erkennen | 10.000 100.000 | 0% | 0% | Mittel | — | 0,15 € |
| app pflanzen erkennen | 1.000 10.000 | 0% | 0% | Mittel | — | 0,13 € |
| pflanzen erkennen app kostenlos | 1.000 10.000 | +900% | 0% | Mittel | — | 0,11 € |
| pflanzen bestimmen app | 1.000 10.000 | +900% | 0% | Mittel | — | 0,11 € |
| pflanzen erkennen online kostenlos | 1.000 10.000 | +900% | 0% | Gering | — | 0,12 € |
| pflanzenerkennung app | 1.000 10.000 | +900% | 0% | Mittel | — | 0,12 € |
| beste app für pflanzenbestimmung kostenlos | 1.000 10.000 | +900% | 0% | Gering | — | 0,09 € |
| blumen erkennen app | 1.000 10.000 | 0% | 0% | Mittel | — | 0,10 € |
| pflanzenbestimmung app | 1.000 10.000 | 0% | 0% | Mittel | — | — |
---
## High-Volume Opportunities (Top Picks)
| Keyword | Suchvolumen | CPC (oberer Bereich) | CPC |
|---|---|---|---|
| free plant id app | 10.000 100.000 | 1,25 € | 0,32 € |
| plant pothos | 100.000 1 Mio. | 0,93 € | 0,03 € |
| app to identify plants | 10.000 100.000 | 2,57 € | 0,62 € |
| pflanzen erkennen | 10.000 100.000 | 0,61 € | 0,15 € |
| pothos plant care | 10.000 100.000 | 0,08 € | 0,02 € |
| plant health app | 1.000 10.000 | 4,72 € | 1,49 € |
| free plant care app | 1.000 10.000 | 1,68 € | 0,52 € |
| plant disease identifier | 1.000 10.000 | 3,04 € | 0,95 € |
| best plant care app | 1.000 10.000 | 4,23 € | 1,54 € |
| my plant is dying what do i do | 10 100 | 4,21 € | 1,92 € |

58
greenlens-promo/AGENTS.md Normal file
View File

@@ -0,0 +1,58 @@
# HyperFrames Composition Project
## Skills
This project uses AI agent skills for framework-specific patterns. Install them if not already present:
```bash
npx skills add heygen-com/hyperframes
```
Skills encode patterns like `window.__timelines` registration, `data-*` attribute semantics, and shader-compatible CSS rules that are not in generic web docs. Using them produces correct compositions from the start.
## Commands
```bash
npx hyperframes preview # preview in browser (studio editor)
npx hyperframes render # render to MP4
npx hyperframes lint # validate compositions (errors + warnings)
npx hyperframes lint --json # machine-readable output for CI
npx hyperframes docs <topic> # reference docs in terminal
```
## Project Structure
- `index.html` — main composition (root timeline)
- `compositions/` — sub-compositions referenced via `data-composition-src`
- `assets/` — media files (video, audio, images)
- `meta.json` — project metadata (id, name)
- `transcript.json` — whisper word-level transcript (if generated)
## Linting — Always Run After Changes
After creating or editing any `.html` composition, run the linter before considering the task complete:
```bash
npx hyperframes lint
```
Fix all errors before presenting the result.
## Key Rules
1. Every timed element needs `data-start`, `data-duration`, and `data-track-index`
2. Visible timed elements **must** have `class="clip"` — the framework uses this for visibility control
3. GSAP timelines must be paused and registered on `window.__timelines`:
```js
window.__timelines = window.__timelines || {};
window.__timelines["composition-id"] = gsap.timeline({ paused: true });
```
4. Videos use `muted` with a separate `<audio>` element for the audio track
5. Sub-compositions use `data-composition-src="compositions/file.html"`
6. Only deterministic logic — no `Date.now()`, no `Math.random()`, no network fetches
## Documentation
Full docs: https://hyperframes.heygen.com/introduction
Machine-readable index for AI tools: https://hyperframes.heygen.com/llms.txt

73
greenlens-promo/CLAUDE.md Normal file
View File

@@ -0,0 +1,73 @@
# HyperFrames Composition Project
## Skills — USE THESE FIRST
**Always invoke the relevant skill before writing or modifying compositions.** Skills encode framework-specific patterns (e.g., `window.__timelines` registration, `data-*` attribute semantics, shader-compatible CSS rules) that are NOT in generic web docs. Skipping them produces broken compositions.
| Skill | Command | When to use |
| -------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------- |
| **hyperframes** | `/hyperframes` | Creating or editing HTML compositions, captions, TTS, audio-reactive animation, marker highlights |
| **hyperframes-cli** | `/hyperframes-cli` | CLI commands: init, lint, preview, render, transcribe, tts |
| **hyperframes-registry** | `/hyperframes-registry` | Installing blocks and components via `hyperframes add` |
| **website-to-hyperframes** | `/website-to-hyperframes` | Capturing a URL and turning it into a video — full website-to-video pipeline |
| **gsap** | `/gsap` | GSAP animations for HyperFrames — tweens, timelines, easing, performance |
> **Skills not available?** Ask the user to run `npx hyperframes skills` and restart their
> agent session, or install manually: `npx skills add heygen-com/hyperframes`.
## Commands
```bash
npx hyperframes preview # preview in browser (studio editor)
npx hyperframes render # render to MP4
npx hyperframes lint # validate compositions (errors + warnings)
npx hyperframes lint --verbose # include info-level findings
npx hyperframes lint --json # machine-readable output for CI
npx hyperframes docs <topic> # reference docs in terminal
```
## Documentation
**For quick reference**, use the local CLI docs command (no network required):
```bash
npx hyperframes docs <topic>
```
Topics: `data-attributes`, `gsap`, `compositions`, `rendering`, `examples`, `troubleshooting`
**For full documentation**, discover pages via the machine-readable index — do NOT guess URLs:
```
https://hyperframes.heygen.com/llms.txt
```
## Project Structure
- `index.html` — main composition (root timeline)
- `compositions/` — sub-compositions referenced via `data-composition-src`
- `meta.json` — project metadata (id, name)
- `transcript.json` — whisper word-level transcript (if generated)
## Linting — ALWAYS RUN AFTER CHANGES
After creating or editing any `.html` composition, **always** run the linter before considering the task complete:
```bash
npx hyperframes lint
```
Fix all errors before presenting the result. Warnings are informational and usually safe to ignore.
## Key Rules
1. Every timed element needs `data-start`, `data-duration`, and `data-track-index`
2. Elements with timing **MUST** have `class="clip"` — the framework uses this for visibility control
3. Timelines must be paused and registered on `window.__timelines`:
```js
window.__timelines = window.__timelines || {};
window.__timelines["composition-id"] = gsap.timeline({ paused: true });
```
4. Videos use `muted` with a separate `<audio>` element for the audio track
5. Sub-compositions use `data-composition-src="compositions/file.html"` to reference other HTML files
6. Only deterministic logic — no `Date.now()`, no `Math.random()`, no network fetches

53
greenlens-promo/DESIGN.md Normal file
View File

@@ -0,0 +1,53 @@
# Design System
## Overview
GreenLens uses a premium botanical landing-page identity: dark forest surfaces, cream typography, warm coral CTAs, and layered plant imagery. The design combines editorial serif headlines with practical app UI cards, bento-style feature blocks, and cinematic plant photography. It should feel calm, intelligent, and tactile rather than loud or generic.
## Colors
- **Forest Surface**: `#131f16` - primary dark background.
- **Forest Alt**: `#1c2e21` - secondary panels and depth layers.
- **Leaf Green**: `#2a5c3f` - core brand green.
- **Leaf Mid**: `#3d7a56` - supporting accents and UI strokes.
- **Leaf Light**: `#56a074` - highlights, scan lines, and positive states.
- **Coral CTA**: `#e07a50` - primary action color.
- **Coral Hover**: `#c96840` - darker warm accent.
- **Cream**: `#f4f1e8` - main text on dark backgrounds.
- **Cream Alt**: `#eae6d8` - quiet surfaces and secondary text.
- **Muted Sage**: `#7a8c7d` - secondary labels.
## Typography
- **Display**: Playfair Display, Georgia, serif. Used for large brand headlines and emotional words. Weight 900 for confident title moments, italic for botanical emphasis.
- **Body/UI**: Inter, system sans-serif. Used for labels, feature chips, captions, and app-style interface cards. Weights 500-800 for product promo readability.
- **Hierarchy**: Promo hero text can sit between 86-132px. Scene headings should stay above 64px. Captions and UI labels should stay above 22px for encoded video clarity.
## Elevation
Depth comes from layered photos, soft radial glows, thin cream/green translucent borders, and rounded UI panels. Avoid generic hard drop shadows; use warm bloom, inset highlights, glassy overlays, and perspective tilt on app frames.
## Components
- **Cinematic Hero Split**: Editorial text on one side with a framed app/demo visual on the other.
- **Scan Badge**: Small rounded status label with a pulsing green dot.
- **Bento Feature Cards**: Rounded image cards with dark overlays, feature chips, and short action headlines.
- **Botanical Intelligence Panel**: AI analysis visual paired with compact capability rows.
- **Step Timeline**: Four numbered actions: photograph, identify, care plan, growth tracking.
- **Store CTA Row**: Two compact dark buttons with platform labels.
## Do's and Don'ts
### Do's
- Use `#131f16`, `#f4f1e8`, `#56a074`, and `#e07a50` as the recognizable brand anchors.
- Keep plant imagery visible and moving with slow zooms, pans, and layered parallax.
- Use Playfair Display for the largest words and Inter for product facts.
- Treat UI as crafted panels with borders, glows, and botanical scan details.
### Don'ts
- Do not switch into neon tech blues or generic SaaS purple.
- Do not make the promo text-only; the website depends on plant and app visuals.
- Do not use flat, static screenshots without motion treatment.
- Do not use hard black-white contrast when cream and forest tones are available.

11
greenlens-promo/SCRIPT.md Normal file
View File

@@ -0,0 +1,11 @@
# Script
## 20-second VO
What if every plant came with instructions?
Open GreenLens. Scan a leaf. Get the name, the care plan, and the next step in seconds.
Track watering, growth, notes, and plant health in one calm place.
GreenLens. Scan it. Track it. Grow it.

View File

@@ -0,0 +1,86 @@
# Storyboard
**Format:** 1920x1080 landscape
**Audio:** Voiceover-first promo. Light organic underscore can be added later.
**VO direction:** Calm, warm, confident, premium app-store register.
**Style basis:** DESIGN.md from the GreenLens landing page.
## Asset Audit
| Asset | Type | Assign to Beat | Role |
| --- | --- | --- | --- |
| `favicon.svg` | Logo | 1, 5 | Brand mark in opener and closer |
| `hero-plant.png` | Hero image | 1 | Full-bleed botanical hook |
| `greenlens.mp4` | Product video | 2 | Framed moving app/demo visual |
| `scan-feature.png` | Feature image | 2, 4 | Scan action and how-it-works panel |
| `ai-analysis.png` | Product image | 3 | Botanical intelligence / analysis |
| `track-feature.png` | Feature image | 4 | Tracking and reminders |
| `plant-collection.png` | Feature image | 4, 5 | Collection and CTA visual |
## Beat 1 - Hook, 0.0-4.0s
**Concept:** The viewer enters a calm botanical world. The plant photo breathes in the background while the question lands like a premium app-store promise.
**VO cue:** "What if every plant came with instructions?"
**Visual description:** Full-frame `hero-plant.png` slowly pushes forward. A dark forest overlay gives contrast. The GreenLens wordmark and favicon settle near the top. Large Playfair Display type fills the left-center. A thin scan line travels across the image and small leaf particles drift in the foreground.
**Transition:** Velocity-matched blur upward into the product demo.
## Beat 2 - Scan, 4.0-8.5s
**Concept:** The abstract promise becomes a concrete action: open, scan, identify. This beat should feel like the app is actively seeing the plant.
**VO cue:** "Open GreenLens. Scan a leaf. Get the name..."
**Visual description:** A tilted phone-like frame plays `greenlens.mp4`. `scan-feature.png` sits behind it as a botanical plate. Green scan brackets draw around the phone, a pulsing "AI Scan" badge appears, and compact result chips cascade in.
**Transition:** Whip-pan left into the care intelligence scene.
## Beat 3 - Care Plan, 8.5-13.0s
**Concept:** Identification becomes guidance. The frame shifts from seeing to understanding.
**VO cue:** "...the care plan, and the next step in seconds."
**Visual description:** `ai-analysis.png` becomes a large analysis panel. Three care cards slide in: Watering, Light, Health. The coral CTA accent draws a route from "scan" to "next step." Botanical labels count up and settle.
**Transition:** Blur-through into a wider tracking system.
## Beat 4 - Track, 13.0-17.0s
**Concept:** GreenLens is not a one-off scanner; it is the plant owner's quiet operating system.
**VO cue:** "Track watering, growth, notes, and plant health in one calm place."
**Visual description:** Three bento cards form a clean grid using `track-feature.png`, `plant-collection.png`, and `scan-feature.png`. Timeline ticks animate across the bottom. Four labels appear in rhythm: Watering, Growth, Notes, Health.
**Transition:** Soft zoom out into final CTA.
## Beat 5 - CTA, 17.0-20.0s
**Concept:** End on the product name and the landing page's tagline rhythm.
**VO cue:** "GreenLens. Scan it. Track it. Grow it."
**Visual description:** Cream background lightens the frame. The favicon and GreenLens wordmark center up. A coral button appears below with the final action line. `plant-collection.png` drifts as a soft rounded card in the background.
**Transition:** Hold to end.
## Production Architecture
```
greenlens-promo/
|-- index.html
|-- DESIGN.md
|-- SCRIPT.md
|-- STORYBOARD.md
|-- assets/
| |-- favicon.svg
| |-- hero-plant.png
| |-- greenlens.mp4
| |-- scan-feature.png
| |-- ai-analysis.png
| |-- track-feature.png
| |-- plant-collection.png
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 4C11 10 6 14 4 18C2 22 4 28 10 28C14 28 16 26 16 26C16 26 18 28 22 28C28 28 30 22 28 18C26 14 21 10 16 4Z" fill="#2A5C3F"/>
<path d="M16 4C14 8 13 12 14 16C15 20 18 22 16 26" stroke="#F4F1E8" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 360 B

View File

@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080" width="1920" height="1080">
<defs>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-opacity="0.2"/>
</filter>
<g id="cursor-arrow">
<path d="M0 0 L 16 16 L 9.5 16 L 6.5 23 L 6.5 16 L 0 16 Z" fill="white" stroke="#000" stroke-width="1.5" stroke-linejoin="round" filter="url(#shadow)"/>
</g>
</defs>
<rect width="1920" height="1080" fill="#f0f0f0" />
<g id="cursor-designer" transform="translate(600, 500)">
<use href="#cursor-arrow" x="0" y="0"/>
<rect x="16" y="20" width="90" height="24" rx="4" fill="#A259FF"/>
<text x="61" y="36" font-family="Inter, sans-serif" font-size="12" fill="white" text-anchor="middle" font-weight="bold">Designer</text>
</g>
<g id="cursor-engineer" transform="translate(900, 400)">
<use href="#cursor-arrow" x="0" y="0"/>
<rect x="16" y="20" width="90" height="24" rx="4" fill="#1ABCFE"/>
<text x="61" y="36" font-family="Inter, sans-serif" font-size="12" fill="white" text-anchor="middle" font-weight="bold">Engineer</text>
</g>
<g id="cursor-pm" transform="translate(1200, 600)">
<use href="#cursor-arrow" x="0" y="0"/>
<rect x="16" y="20" width="50" height="24" rx="4" fill="#F24E1E"/>
<text x="41" y="36" font-family="Inter, sans-serif" font-size="12" fill="white" text-anchor="middle" font-weight="bold">PM</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080" width="1920" height="1080">
<g transform="translate(880, 420)">
<!-- Top-left: rounded square -->
<path id="logo-tl" d="M 40 0 A 40 40 0 0 0 0 40 L 0 80 A 40 40 0 0 0 40 120 A 40 40 0 0 0 80 80 L 80 40 A 40 40 0 0 0 40 0 Z" fill="#F24E1E" />
<!-- Top-right: rounded square -->
<path id="logo-tr" d="M 120 0 A 40 40 0 0 0 80 40 L 80 80 A 40 40 0 0 0 120 120 A 40 40 0 0 0 160 80 L 160 40 A 40 40 0 0 0 120 0 Z" fill="#A259FF" />
<!-- Bottom-left: half-circle -->
<path id="logo-bl" d="M 0 120 A 40 120 0 0 0 80 120 Z" fill="#1ABCFE" />
<!-- Bottom-right: half-circle -->
<path id="logo-br" d="M 80 120 A 40 120 0 0 0 160 120 Z" fill="#0ACF83" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 771 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080" width="1920" height="1080">
<rect id="pill-tl" x="870" y="410" width="80" height="120" rx="40" fill="#F24E1E" />
<rect id="pill-tr" x="970" y="410" width="80" height="120" rx="40" fill="#A259FF" />
<rect id="pill-bl" x="870" y="550" width="80" height="120" rx="40" fill="#1ABCFE" />
<rect id="pill-br" x="970" y="550" width="80" height="120" rx="40" fill="#0ACF83" />
</svg>

After

Width:  |  Height:  |  Size: 446 B

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -0,0 +1,9 @@
{
"$schema": "https://hyperframes.heygen.com/schema/hyperframes.json",
"registry": "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry",
"paths": {
"blocks": "compositions",
"components": "compositions/components",
"assets": "assets"
}
}

758
greenlens-promo/index.html Normal file
View File

@@ -0,0 +1,758 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>GreenLens Product Promo</title>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Playfair+Display:ital,wght@0,700;0,900;1,700;1,900&display=block"
rel="stylesheet"
/>
<style>
:root {
--forest: #131f16;
--forest-alt: #1c2e21;
--green: #2a5c3f;
--green-mid: #3d7a56;
--green-light: #56a074;
--coral: #e07a50;
--coral-dark: #c96840;
--cream: #f4f1e8;
--cream-alt: #eae6d8;
--muted-sage: #7a8c7d;
--display: "Playfair Display", Georgia, serif;
--body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
width: 1920px;
height: 1080px;
margin: 0;
overflow: hidden;
background: var(--forest);
color: var(--cream);
font-family: var(--body);
}
[data-composition-id="greenlens-promo"] {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
isolation: isolate;
background: var(--forest);
}
.scene {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: hidden;
opacity: 0;
}
.scene-content {
position: relative;
z-index: 3;
width: 100%;
height: 100%;
display: flex;
box-sizing: border-box;
}
.brand {
display: flex;
align-items: center;
gap: 16px;
font-family: var(--display);
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.brand img {
width: 48px;
height: 48px;
}
.brand-mark {
width: 48px;
height: 48px;
border-radius: 999px;
background:
radial-gradient(circle at 52% 38%, var(--green-light), transparent 34%),
var(--green);
box-shadow: inset 0 0 0 2px rgba(244, 241, 232, 0.24), 0 0 34px rgba(86, 160, 116, 0.36);
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 12px;
color: var(--coral);
font-size: 18px;
font-weight: 800;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.eyebrow::before {
content: "";
width: 10px;
height: 10px;
border-radius: 999px;
background: var(--green-light);
box-shadow: 0 0 28px rgba(86, 160, 116, 0.9);
}
h1,
h2 {
margin: 0;
font-family: var(--display);
font-weight: 900;
line-height: 0.96;
}
h1 {
font-size: 124px;
max-width: 880px;
}
h2 {
font-size: 92px;
}
em {
color: var(--green-light);
font-style: italic;
}
p {
margin: 0;
}
.caption {
font-size: 34px;
line-height: 1.35;
color: rgba(244, 241, 232, 0.76);
max-width: 680px;
}
.glass {
border: 1px solid rgba(244, 241, 232, 0.14);
background: rgba(19, 31, 22, 0.72);
box-shadow: 0 30px 110px rgba(0, 0, 0, 0.34);
backdrop-filter: blur(18px);
}
.image-fill {
width: 100%;
height: 100%;
object-fit: cover;
}
.scene-1 {
background: var(--forest);
}
.hero-bg {
position: absolute;
inset: 0;
z-index: 0;
}
.hero-bg img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.9;
}
.hero-overlay {
position: absolute;
inset: 0;
z-index: 1;
background:
radial-gradient(circle at 72% 42%, rgba(86, 160, 116, 0.36), transparent 32%),
linear-gradient(90deg, rgba(19, 31, 22, 0.96), rgba(19, 31, 22, 0.72) 46%, rgba(19, 31, 22, 0.2));
}
.scan-line {
position: absolute;
z-index: 2;
left: 0;
right: 0;
top: 0;
height: 3px;
background: linear-gradient(90deg, transparent, var(--green-light), transparent);
box-shadow: 0 0 42px rgba(86, 160, 116, 0.88);
opacity: 0.8;
}
.particle {
position: absolute;
z-index: 2;
width: 10px;
height: 22px;
border-radius: 999px 0 999px 0;
background: rgba(244, 241, 232, 0.22);
}
.scene-1 .scene-content {
flex-direction: column;
justify-content: center;
gap: 34px;
padding: 96px 120px 110px;
}
.scene-1 .brand {
position: absolute;
top: 72px;
left: 120px;
}
.scene-2 .scene-content {
align-items: center;
justify-content: space-between;
gap: 80px;
padding: 86px 128px;
background:
radial-gradient(circle at 30% 20%, rgba(86, 160, 116, 0.22), transparent 26%),
var(--forest);
}
.copy-stack {
width: 680px;
display: flex;
flex-direction: column;
gap: 28px;
}
.phone-stage {
position: relative;
width: 780px;
height: 820px;
display: flex;
align-items: center;
justify-content: center;
}
.botanical-plate {
position: absolute;
width: 620px;
height: 720px;
border-radius: 42px;
overflow: hidden;
opacity: 0.45;
border: 1px solid rgba(244, 241, 232, 0.15);
}
.phone-frame {
position: relative;
z-index: 2;
width: 420px;
height: 744px;
border-radius: 48px;
padding: 18px;
background: #0d130f;
border: 2px solid rgba(244, 241, 232, 0.22);
box-shadow: 0 42px 110px rgba(0, 0, 0, 0.44);
overflow: hidden;
}
.phone-frame video {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 34px;
}
.scan-bracket {
position: absolute;
z-index: 3;
width: 108px;
height: 108px;
border-color: var(--green-light);
opacity: 0.9;
}
.scan-bracket.tl {
top: 112px;
left: 120px;
border-top: 5px solid;
border-left: 5px solid;
}
.scan-bracket.br {
right: 120px;
bottom: 112px;
border-right: 5px solid;
border-bottom: 5px solid;
}
.badge {
display: inline-flex;
align-items: center;
gap: 12px;
width: fit-content;
padding: 12px 18px;
border-radius: 999px;
color: var(--cream);
font-size: 18px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
border: 1px solid rgba(86, 160, 116, 0.45);
background: rgba(42, 92, 63, 0.42);
}
.badge::before {
content: "";
width: 12px;
height: 12px;
border-radius: 999px;
background: var(--green-light);
}
.result-card {
position: absolute;
z-index: 4;
right: 26px;
bottom: 156px;
width: 318px;
padding: 24px;
border-radius: 24px;
}
.result-card strong {
display: block;
font-family: var(--display);
font-size: 34px;
line-height: 1;
}
.result-card span {
display: block;
margin-top: 8px;
color: rgba(244, 241, 232, 0.68);
font-size: 20px;
}
.scene-3 .scene-content {
align-items: center;
gap: 74px;
padding: 82px 120px;
background:
radial-gradient(circle at 74% 48%, rgba(224, 122, 80, 0.18), transparent 26%),
var(--forest-alt);
}
.analysis-frame {
width: 820px;
height: 760px;
border-radius: 38px;
overflow: hidden;
position: relative;
}
.analysis-frame img {
width: 100%;
height: 100%;
object-fit: cover;
}
.analysis-frame::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg, transparent, rgba(19, 31, 22, 0.64));
}
.care-grid {
display: grid;
grid-template-columns: 1fr;
gap: 18px;
width: 540px;
}
.care-card {
display: flex;
align-items: center;
gap: 20px;
min-height: 122px;
padding: 26px;
border-radius: 24px;
}
.care-icon {
width: 58px;
height: 58px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 18px;
color: var(--forest);
background: var(--green-light);
font-size: 30px;
font-weight: 900;
}
.care-card strong {
display: block;
font-size: 28px;
}
.care-card span {
display: block;
margin-top: 6px;
color: rgba(244, 241, 232, 0.68);
font-size: 20px;
}
.route-line {
position: absolute;
left: 880px;
top: 562px;
z-index: 4;
width: 260px;
height: 5px;
border-radius: 999px;
background: var(--coral);
box-shadow: 0 0 36px rgba(224, 122, 80, 0.8);
}
.scene-4 .scene-content {
flex-direction: column;
justify-content: center;
gap: 34px;
padding: 82px 110px 96px;
background: var(--cream);
color: var(--forest);
}
.scene-4 .eyebrow {
color: var(--coral-dark);
}
.bento {
display: grid;
grid-template-columns: 1.2fr 1fr 1fr;
gap: 22px;
height: 560px;
}
.bento-card {
position: relative;
overflow: hidden;
border-radius: 30px;
border: 1px solid rgba(19, 31, 22, 0.14);
background: var(--forest);
}
.bento-card img {
width: 100%;
height: 100%;
object-fit: cover;
}
.bento-card.bg-track,
.bento-card.bg-collection,
.bento-card.bg-scan,
.final-image {
background-size: cover;
background-position: center;
}
.bento-card.bg-track {
background-image: url("assets/track-feature.png");
}
.bento-card.bg-collection,
.final-image {
background-image: url("assets/plant-collection.png");
}
.bento-card.bg-scan {
background-image: url("assets/scan-feature.png");
}
.bento-card::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(19, 31, 22, 0.05), rgba(19, 31, 22, 0.75));
}
.bento-label {
position: absolute;
z-index: 2;
left: 30px;
right: 30px;
bottom: 30px;
display: flex;
flex-direction: column;
gap: 8px;
color: var(--cream);
}
.bento-label strong {
font-family: var(--display);
font-size: 44px;
line-height: 1;
}
.bento-label span {
font-size: 20px;
color: rgba(244, 241, 232, 0.76);
}
.timeline {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 18px;
}
.timeline-pill {
min-height: 72px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 18px;
background: #ffffff;
border: 1px solid rgba(19, 31, 22, 0.1);
color: var(--green);
font-weight: 800;
font-size: 24px;
}
.scene-5 .scene-content {
align-items: center;
justify-content: center;
padding: 80px;
background:
radial-gradient(circle at 76% 38%, rgba(86, 160, 116, 0.2), transparent 28%),
var(--forest);
}
.final-card {
position: relative;
z-index: 3;
width: 980px;
min-height: 620px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 28px;
padding: 70px;
border-radius: 44px;
text-align: center;
}
.final-card .brand {
justify-content: center;
font-size: 34px;
}
.final-card h2 {
font-size: 110px;
}
.cta-button {
margin-top: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 300px;
height: 76px;
padding: 0 34px;
border-radius: 999px;
background: var(--coral);
color: white;
font-size: 24px;
font-weight: 800;
}
.final-image {
position: absolute;
z-index: 1;
right: 120px;
bottom: 92px;
width: 470px;
height: 580px;
border-radius: 36px;
overflow: hidden;
opacity: 0.34;
border: 1px solid rgba(244, 241, 232, 0.16);
}
.final-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="greenlens-promo"
data-start="0"
data-duration="20"
data-width="1920"
data-height="1080"
>
<section id="scene-1" class="scene clip scene-1" data-start="0" data-duration="3.82" data-track-index="1">
<div class="hero-bg"><img src="assets/hero-plant.png" alt="" /></div>
<div class="hero-overlay"></div>
<div class="scan-line"></div>
<div class="particle" style="left: 1400px; top: 210px"></div>
<div class="particle" style="left: 1620px; top: 660px"></div>
<div class="particle" style="left: 1040px; top: 780px"></div>
<div class="scene-content">
<div class="brand"><img src="assets/favicon.svg" alt="" /><span>GreenLens</span></div>
<div class="eyebrow">Plant care, decoded</div>
<h1>What if every plant came with <em>instructions?</em></h1>
<p class="caption">Instant identification and care guidance for the plants you live with.</p>
</div>
</section>
<section id="scene-2" class="scene clip scene-2" data-start="3.82" data-duration="4.48" data-track-index="1">
<div class="scene-content">
<div class="copy-stack">
<div class="eyebrow">AI Scan</div>
<h2>Open. Scan. <em>Know.</em></h2>
<p class="caption">Scan a leaf and get the name, context, and next step in seconds.</p>
<div class="badge">Live plant match</div>
</div>
<div class="phone-stage">
<div class="botanical-plate"><img class="image-fill" src="assets/scan-feature.png" alt="" /></div>
<div class="scan-bracket tl"></div>
<div class="scan-bracket br"></div>
<div class="phone-frame">
<video src="assets/greenlens.mp4" muted playsinline></video>
</div>
<div class="result-card glass">
<strong>Monstera</strong>
<span>Identified with care tips ready.</span>
</div>
</div>
</div>
</section>
<section id="scene-3" class="scene clip scene-3" data-start="8.3" data-duration="4.54" data-track-index="1">
<div class="scene-content">
<div class="analysis-frame glass"><img src="assets/ai-analysis.png" alt="" /></div>
<div class="copy-stack">
<div class="eyebrow">Botanical Intelligence</div>
<h2>From photo to <em>care plan.</em></h2>
<p class="caption">GreenLens turns identification into practical care decisions.</p>
<div class="care-grid">
<div class="care-card glass"><div class="care-icon">1</div><div><strong>Watering</strong><span>Personalized rhythm</span></div></div>
<div class="care-card glass"><div class="care-icon">2</div><div><strong>Light</strong><span>Location-aware guidance</span></div></div>
<div class="care-card glass"><div class="care-icon">3</div><div><strong>Health</strong><span>Early diagnosis cues</span></div></div>
</div>
</div>
<div class="route-line"></div>
</div>
</section>
<section id="scene-4" class="scene clip scene-4" data-start="12.84" data-duration="3.98" data-track-index="1">
<div class="scene-content">
<div class="eyebrow">Your plant system</div>
<h2>Track watering, growth, notes, and <em>health.</em></h2>
<div class="bento">
<div class="bento-card bg-track"><div class="bento-label"><strong>Track it.</strong><span>Care reminders stay organized.</span></div></div>
<div class="bento-card bg-collection"><div class="bento-label"><strong>Collect.</strong><span>Your plants in one place.</span></div></div>
<div class="bento-card bg-scan"><div class="bento-label"><strong>Grow.</strong><span>Build a better routine.</span></div></div>
</div>
<div class="timeline">
<div class="timeline-pill">Watering</div>
<div class="timeline-pill">Growth</div>
<div class="timeline-pill">Notes</div>
<div class="timeline-pill">Health</div>
</div>
</div>
</section>
<section id="scene-5" class="scene clip scene-5" data-start="16.82" data-duration="3.18" data-track-index="1">
<div class="final-image"></div>
<div class="scene-content">
<div class="final-card glass">
<div class="brand"><span class="brand-mark"></span><span>GreenLens</span></div>
<h2>Scan it.<br />Track it.<br /><em>Grow it.</em></h2>
<div class="cta-button">Start with one plant</div>
</div>
</div>
</section>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
gsap.set("#scene-1", { opacity: 1 });
gsap.set(".scene:not(#scene-1)", { opacity: 0 });
gsap.set(".phone-frame", { transformPerspective: 1200, rotationY: -8, rotationX: 3 });
gsap.set(".botanical-plate", { transformPerspective: 1200, rotationY: 8, rotationX: -2 });
gsap.set(".analysis-frame", { transformPerspective: 1200, rotationY: 7 });
gsap.set(".final-image", { transformPerspective: 1200, rotationY: -10 });
tl.from("#scene-1 .brand", { y: -28, opacity: 0, duration: 0.55, ease: "power3.out" }, 0);
tl.from("#scene-1 .eyebrow", { y: 34, opacity: 0, duration: 0.55, ease: "power3.out" }, 0.22);
tl.from("#scene-1 h1", { y: 58, opacity: 0, duration: 0.72, ease: "power3.out" }, 0.42);
tl.from("#scene-1 .caption", { y: 30, opacity: 0, duration: 0.48, ease: "power2.out" }, 1.02);
tl.fromTo(".hero-bg img", { scale: 1.04 }, { scale: 1.11, duration: 4.0, ease: "none" }, 0);
tl.fromTo(".scan-line", { y: -20 }, { y: 1120, duration: 2.8, ease: "power1.inOut" }, 0.45);
tl.to(".particle", { y: -38, x: 18, opacity: 0.55, duration: 2.2, stagger: 0.25, ease: "sine.inOut" }, 0.7);
tl.to("#scene-1", { y: -120, filter: "blur(18px)", opacity: 0, duration: 0.4, ease: "power2.in" }, 3.6);
tl.set("#scene-2", { y: 120, filter: "blur(18px)", opacity: 1 }, 3.82);
tl.to("#scene-2", { y: 0, filter: "blur(0px)", duration: 0.55, ease: "power3.out" }, 3.82);
tl.from("#scene-2 .copy-stack > *", { y: 42, opacity: 0, duration: 0.55, stagger: 0.12, ease: "power3.out" }, 4.08);
tl.from(".phone-stage", { x: 90, opacity: 0, duration: 0.75, ease: "power3.out" }, 4.2);
tl.from(".scan-bracket", { scale: 0.55, opacity: 0, duration: 0.48, stagger: 0.08, ease: "back.out(1.8)" }, 4.75);
tl.from(".result-card", { y: 46, opacity: 0, duration: 0.5, ease: "power3.out" }, 5.35);
tl.to(".phone-frame", { y: -12, duration: 1.7, repeat: 2, yoyo: true, ease: "sine.inOut" }, 5.0);
tl.to(".botanical-plate img", { scale: 1.07, duration: 4.2, ease: "none" }, 4.0);
tl.to("#scene-2", { x: -360, filter: "blur(22px)", opacity: 0, duration: 0.35, ease: "power3.in" }, 8.15);
tl.set("#scene-3", { x: 360, filter: "blur(22px)", opacity: 1 }, 8.3);
tl.to("#scene-3", { x: 0, filter: "blur(0px)", duration: 0.48, ease: "power3.out" }, 8.3);
tl.from(".analysis-frame", { scale: 0.9, opacity: 0, duration: 0.58, ease: "power3.out" }, 8.48);
tl.from("#scene-3 .copy-stack > *:not(.care-grid)", { y: 38, opacity: 0, duration: 0.5, stagger: 0.1, ease: "power3.out" }, 8.65);
tl.from(".care-card", { x: 70, opacity: 0, duration: 0.48, stagger: 0.15, ease: "power3.out" }, 9.25);
tl.from(".route-line", { scaleX: 0, opacity: 0, duration: 0.65, ease: "power3.out" }, 9.65);
tl.to(".analysis-frame img", { scale: 1.06, duration: 4.5, ease: "none" }, 8.5);
tl.to(".care-icon", { scale: 1.08, duration: 0.5, repeat: 5, yoyo: true, ease: "sine.inOut" }, 10.0);
tl.to("#scene-3", { scale: 1.08, filter: "blur(18px)", opacity: 0, duration: 0.38, ease: "power2.in" }, 12.62);
tl.set("#scene-4", { scale: 0.94, filter: "blur(18px)", opacity: 1 }, 12.84);
tl.to("#scene-4", { scale: 1, filter: "blur(0px)", duration: 0.48, ease: "power3.out" }, 12.84);
tl.from("#scene-4 .eyebrow, #scene-4 h2", { y: 34, opacity: 0, duration: 0.5, stagger: 0.1, ease: "power3.out" }, 13.0);
tl.from(".bento-card", { y: 80, opacity: 0, duration: 0.55, stagger: 0.12, ease: "power3.out" }, 13.45);
tl.from(".timeline-pill", { y: 24, opacity: 0, duration: 0.36, stagger: 0.07, ease: "power2.out" }, 14.32);
tl.to(".timeline-pill", { y: -8, duration: 0.55, repeat: 3, yoyo: true, stagger: 0.08, ease: "sine.inOut" }, 15.0);
tl.to("#scene-4", { scale: 0.88, filter: "blur(18px)", opacity: 0, duration: 0.38, ease: "power2.in" }, 16.62);
tl.set("#scene-5", { scale: 1.1, filter: "blur(16px)", opacity: 1 }, 16.82);
tl.to("#scene-5", { scale: 1, filter: "blur(0px)", duration: 0.52, ease: "power3.out" }, 16.82);
tl.from(".final-card", { y: 52, opacity: 0, duration: 0.68, ease: "power3.out" }, 17.0);
tl.from(".final-card h2", { y: 42, opacity: 0, duration: 0.62, ease: "power3.out" }, 17.22);
tl.from(".cta-button", { y: 30, opacity: 0, scale: 0.92, duration: 0.5, ease: "back.out(1.7)" }, 17.82);
tl.from(".final-image", { x: 100, opacity: 0, duration: 0.8, ease: "power3.out" }, 17.0);
tl.to(".cta-button", { boxShadow: "0 0 44px rgba(224, 122, 80, 0.55)", duration: 0.8, repeat: 2, yoyo: true, ease: "sine.inOut" }, 18.35);
window.__timelines["greenlens-promo"] = tl;
</script>
</body>
</html>

View File

@@ -0,0 +1,5 @@
{
"id": "greenlens-promo",
"name": "greenlens-promo",
"createdAt": "2026-04-26T21:56:37.923Z"
}

View File

@@ -0,0 +1,33 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import SeoCategoryPage from '@/components/SeoCategoryPage'
import { getSeoPageBySlug } from '@/lib/seoPages'
import { siteConfig } from '@/lib/site'
const profile = getSeoPageBySlug('best-plant-identification-app')
export const metadata: Metadata = !profile
? {}
: {
title: profile.metaTitle,
description: profile.metaDescription,
alternates: { canonical: profile.canonical },
openGraph: {
title: profile.metaTitle,
description: profile.metaDescription,
url: `${siteConfig.domain}${profile.canonical}`,
type: 'website',
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: profile.metaTitle }],
},
twitter: {
card: 'summary_large_image',
title: profile.metaTitle,
description: profile.metaDescription,
images: ['/og-image.png'],
},
}
export default function Page() {
if (!profile) notFound()
return <SeoCategoryPage profile={profile} />
}

View File

@@ -0,0 +1,33 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import SeoCategoryPage from '@/components/SeoCategoryPage'
import { getSeoPageBySlug } from '@/lib/seoPages'
import { siteConfig } from '@/lib/site'
const profile = getSeoPageBySlug('blumen-scanner')
export const metadata: Metadata = !profile
? {}
: {
title: profile.metaTitle,
description: profile.metaDescription,
alternates: { canonical: profile.canonical },
openGraph: {
title: profile.metaTitle,
description: profile.metaDescription,
url: `${siteConfig.domain}${profile.canonical}`,
type: 'website',
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: profile.metaTitle }],
},
twitter: {
card: 'summary_large_image',
title: profile.metaTitle,
description: profile.metaDescription,
images: ['/og-image.png'],
},
}
export default function Page() {
if (!profile) notFound()
return <SeoCategoryPage profile={profile} />
}

View File

@@ -0,0 +1,21 @@
import { notFound, permanentRedirect } from 'next/navigation'
import { getGermanSeoPageBySlug, germanSeoPageSlugs } from '@/lib/seoPages'
type GermanSeoRouteProps = {
params: Promise<{ slug: string }>
}
export function generateStaticParams() {
return germanSeoPageSlugs.map((slug) => ({ slug }))
}
export default async function GermanSeoRoute({ params }: GermanSeoRouteProps) {
const { slug } = await params
const profile = getGermanSeoPageBySlug(slug)
if (!profile) {
notFound()
}
permanentRedirect(`/${slug}`)
}

View File

@@ -0,0 +1,5 @@
import { permanentRedirect } from 'next/navigation'
export default function GermanHomeRedirect() {
permanentRedirect('/')
}

View File

@@ -0,0 +1,64 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import SeoCategoryPage from '@/components/SeoCategoryPage'
import { getSpanishSeoPageBySlug, spanishSeoPageSlugs } from '@/lib/spanishSeoPages'
import { siteConfig } from '@/lib/site'
type SpanishSeoRouteProps = {
params: Promise<{ slug: string }>
}
export function generateStaticParams() {
return spanishSeoPageSlugs
.filter((slug) => slug !== 'comparar-google-lens')
.map((slug) => ({ slug }))
}
export async function generateMetadata({ params }: SpanishSeoRouteProps): Promise<Metadata> {
const { slug } = await params
if (slug === 'comparar-google-lens') {
notFound()
}
const profile = getSpanishSeoPageBySlug(slug)
if (!profile) {
return {}
}
return {
title: profile.metaTitle,
description: profile.metaDescription,
alternates: {
canonical: profile.canonical,
languages: {
es: profile.canonical,
'x-default': '/',
},
},
openGraph: {
title: profile.metaTitle,
description: profile.metaDescription,
url: `${siteConfig.domain}${profile.canonical}`,
type: 'website',
locale: 'es_ES',
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: profile.metaTitle }],
},
twitter: {
card: 'summary_large_image',
title: profile.metaTitle,
description: profile.metaDescription,
images: ['/og-image.png'],
},
}
}
export default async function SpanishSeoRoute({ params }: SpanishSeoRouteProps) {
const { slug } = await params
const profile = getSpanishSeoPageBySlug(slug)
if (!profile) {
notFound()
}
return <SeoCategoryPage profile={profile} />
}

View File

@@ -0,0 +1,40 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import SeoCategoryPage from '@/components/SeoCategoryPage'
import { getSpanishSeoPageBySlug } from '@/lib/spanishSeoPages'
import { siteConfig } from '@/lib/site'
const profile = getSpanishSeoPageBySlug('comparar-google-lens')
export const metadata: Metadata = !profile
? {}
: {
title: profile.metaTitle,
description: profile.metaDescription,
alternates: {
canonical: profile.canonical,
languages: {
es: profile.canonical,
'x-default': '/',
},
},
openGraph: {
title: profile.metaTitle,
description: profile.metaDescription,
url: `${siteConfig.domain}${profile.canonical}`,
type: 'website',
locale: 'es_ES',
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: profile.metaTitle }],
},
twitter: {
card: 'summary_large_image',
title: profile.metaTitle,
description: profile.metaDescription,
images: ['/og-image.png'],
},
}
export default function SpanishGoogleLensComparisonPage() {
if (!profile) notFound()
return <SeoCategoryPage profile={profile} />
}

View File

@@ -0,0 +1,81 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import Navbar from '@/components/Navbar'
import Footer from '@/components/Footer'
import { siteConfig } from '@/lib/site'
import { spanishSeoPageProfiles } from '@/lib/spanishSeoPages'
export const metadata: Metadata = {
title: 'GreenLens en español - Identificar y cuidar plantas',
description:
'GreenLens en español: identifica plantas por foto, organiza cuidados, recibe recordatorios y diagnostica sintomas comunes.',
alternates: {
canonical: '/es',
languages: {
es: '/es',
'x-default': '/',
},
},
openGraph: {
title: 'GreenLens en español - Identificar y cuidar plantas',
description:
'Identifica plantas por foto, organiza cuidados, recibe recordatorios y diagnostica sintomas comunes con GreenLens.',
url: `${siteConfig.domain}/es`,
type: 'website',
locale: 'es_ES',
},
}
const pages = [
spanishSeoPageProfiles['identificador-de-plantas'],
spanishSeoPageProfiles['escaner-de-plantas'],
spanishSeoPageProfiles['app-para-cuidar-plantas'],
spanishSeoPageProfiles['diagnosticar-enfermedades-plantas'],
spanishSeoPageProfiles['comparar-google-lens'],
]
export default function SpanishHomePage() {
return (
<>
<Navbar />
<main className="comparison-page">
<section className="comparison-hero">
<div className="container comparison-hero-grid">
<div className="comparison-hero-copy">
<p className="tag">GreenLens en español</p>
<h1>Identifica, cuida y rescata tus plantas con mas claridad.</h1>
<p className="comparison-lead">
Escanea una planta, entiende que necesita y organiza cuidados, recordatorios y diagnostico desde una sola app.
</p>
<div className="comparison-actions">
<a href="#cta" className="btn-primary">Probar GreenLens</a>
<a href="#guias" className="btn-outline">Ver guias</a>
</div>
</div>
<aside className="comparison-hero-card">
<p className="comparison-card-label">Arquitectura en español</p>
<p>
Esta seccion agrupa las paginas principales para busquedas en español:
identificacion, escaneo, cuidado, diagnostico y comparacion con Google Lens.
</p>
</aside>
</div>
</section>
<section className="comparison-links" id="guias">
<div className="container comparison-links-grid">
{pages.map((page) => (
<Link key={page.slug} href={page.canonical} className="comparison-link-card">
<p className="comparison-mini-label">Guia</p>
<h2>{page.h1}</h2>
<p>{page.metaDescription}</p>
</Link>
))}
</div>
</section>
</main>
<Footer />
</>
)
}

View File

@@ -0,0 +1,33 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import SeoCategoryPage from '@/components/SeoCategoryPage'
import { getSeoPageBySlug } from '@/lib/seoPages'
import { siteConfig } from '@/lib/site'
const profile = getSeoPageBySlug('flower-scanner')
export const metadata: Metadata = !profile
? {}
: {
title: profile.metaTitle,
description: profile.metaDescription,
alternates: { canonical: profile.canonical },
openGraph: {
title: profile.metaTitle,
description: profile.metaDescription,
url: `${siteConfig.domain}${profile.canonical}`,
type: 'website',
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: profile.metaTitle }],
},
twitter: {
card: 'summary_large_image',
title: profile.metaTitle,
description: profile.metaDescription,
images: ['/og-image.png'],
},
}
export default function Page() {
if (!profile) notFound()
return <SeoCategoryPage profile={profile} />
}

View File

@@ -0,0 +1,33 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import SeoCategoryPage from '@/components/SeoCategoryPage'
import { getSeoPageBySlug } from '@/lib/seoPages'
import { siteConfig } from '@/lib/site'
const profile = getSeoPageBySlug('houseplant-identifier')
export const metadata: Metadata = !profile
? {}
: {
title: profile.metaTitle,
description: profile.metaDescription,
alternates: { canonical: profile.canonical },
openGraph: {
title: profile.metaTitle,
description: profile.metaDescription,
url: `${siteConfig.domain}${profile.canonical}`,
type: 'website',
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: profile.metaTitle }],
},
twitter: {
card: 'summary_large_image',
title: profile.metaTitle,
description: profile.metaDescription,
images: ['/og-image.png'],
},
}
export default function Page() {
if (!profile) notFound()
return <SeoCategoryPage profile={profile} />
}

View File

@@ -0,0 +1,33 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import SeoCategoryPage from '@/components/SeoCategoryPage'
import { getSeoPageBySlug } from '@/lib/seoPages'
import { siteConfig } from '@/lib/site'
const profile = getSeoPageBySlug('identify-plant-photo')
export const metadata: Metadata = !profile
? {}
: {
title: profile.metaTitle,
description: profile.metaDescription,
alternates: { canonical: profile.canonical },
openGraph: {
title: profile.metaTitle,
description: profile.metaDescription,
url: `${siteConfig.domain}${profile.canonical}`,
type: 'website',
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: profile.metaTitle }],
},
twitter: {
card: 'summary_large_image',
title: profile.metaTitle,
description: profile.metaDescription,
images: ['/og-image.png'],
},
}
export default function Page() {
if (!profile) notFound()
return <SeoCategoryPage profile={profile} />
}

View File

@@ -1,5 +1,6 @@
import type { Metadata } from 'next'
import { cookies } from 'next/headers'
import { headers } from 'next/headers'
import './globals.css'
import { LangProvider } from '@/context/LangContext'
import { siteConfig, hasIosStoreUrl } from '@/lib/site'
@@ -7,42 +8,49 @@ import { siteConfig, hasIosStoreUrl } from '@/lib/site'
export const metadata: Metadata = {
metadataBase: new URL(siteConfig.domain),
title: {
default: 'GreenLens - Plant Identifier and Care Planner',
template: '%s | GreenLens',
default: 'GreenLens - Pflanzen erkennen & Pflege planen',
template: '%s',
},
description:
'GreenLens helps you identify plants, organize your collection, and keep up with care routines in one app.',
'GreenLens erkennt Pflanzen per Foto in Sekunden und gibt dir Pflegepläne, Erinnerungen und Gesundheitschecks in einer App.',
keywords: [
'plant identifier by picture',
'Pflanzen erkennen App',
'Pflanzen bestimmen per Foto',
'Blumen Scanner',
'Pflanzen Pflege App',
'plant identifier app',
'plant care app',
'watering reminders',
'houseplant tracker',
'plant identification',
'plant health check',
'Pflanzen App',
'plant scanner',
'plant disease identifier',
'identificador de plantas',
'GreenLens',
],
authors: [{ name: siteConfig.name }],
openGraph: {
title: 'GreenLens - Plant Identifier and Care Planner',
description: 'Identify plants, get care guidance, and manage your collection with GreenLens.',
title: 'GreenLens - Pflanzen erkennen & Pflege planen',
description: 'Pflanzen per Foto erkennen, Pflegeplan erhalten und Pflanzenprobleme in einer App einordnen.',
type: 'website',
url: siteConfig.domain,
},
alternates: {
// Do not emit hreflang until each language has its own URL.
languages: {},
languages: {
de: '/',
es: '/es',
'x-default': '/',
},
},
twitter: {
card: 'summary_large_image',
title: 'GreenLens - Plant Identifier and Care Planner',
description: 'Identify plants, get care guidance, and manage your collection with GreenLens.',
title: 'GreenLens - Pflanzen erkennen & Pflege planen',
description: 'Pflanzen per Foto erkennen, Pflegeplan erhalten und Pflanzenprobleme in einer App einordnen.',
},
}
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 headerStore = await headers()
const routeLang = headerStore.get('x-greenlens-lang')
const lang = (routeLang ?? cookieStore.get('lang')?.value ?? 'de') as 'de' | 'en' | 'es'
const validLangs = ['de', 'en', 'es']
const htmlLang = validLangs.includes(lang) ? lang : 'de'
@@ -56,6 +64,13 @@ export default async function RootLayout({ children }: { children: React.ReactNo
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify([
{
'@context': 'https://schema.org',
'@type': 'WebSite',
name: siteConfig.name,
url: siteConfig.domain,
inLanguage: ['en', 'de', 'es'],
},
{
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
@@ -63,8 +78,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
operatingSystem: 'iOS, Android',
applicationCategory: 'LifestyleApplication',
description:
'Identify plants, track care schedules, and manage your collection with AI-powered scans.',
inLanguage: ['de', 'en', 'es'],
'Pflanzen per Foto erkennen, Pflegepläne nutzen und Pflanzenprobleme einordnen.',
inLanguage: ['en', 'de', 'es'],
...(hasIosStoreUrl && { downloadUrl: siteConfig.iosAppStoreUrl }),
offers: {
'@type': 'Offer',
@@ -78,7 +93,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
name: siteConfig.name,
url: siteConfig.domain,
description:
'GreenLens is a plant identification and care planning app for iOS and Android.',
'GreenLens ist eine App zur Pflanzenerkennung und Pflegeplanung r iOS und Android.',
contactPoint: {
'@type': 'ContactPoint',
contactType: 'customer support',

View File

@@ -1,4 +1,5 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import Navbar from '@/components/Navbar'
import Hero from '@/components/Hero'
import Ticker from '@/components/Ticker'
@@ -11,39 +12,47 @@ import CTA from '@/components/CTA'
import Footer from '@/components/Footer'
export const metadata: Metadata = {
title: 'GreenLens - Pflanzen erkennen & Pflege planen',
description:
'Scanne Pflanzen per Foto, verstehe ihre Bedürfnisse und organisiere Pflege, Erinnerungen und Sammlung in einer App.',
alternates: {
canonical: '/',
languages: {
de: '/',
es: '/es',
'x-default': '/',
},
},
}
const howToSchema = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: 'How to identify a plant with GreenLens',
name: 'So erkennst du eine Pflanze mit GreenLens',
step: [
{
'@type': 'HowToStep',
position: 1,
name: 'Photograph your plant',
text: 'Open the app, point the camera at your plant and tap Scan.',
name: 'Pflanze fotografieren',
text: 'Öffne die App, richte die Kamera auf deine Pflanze und tippe auf Scan.',
},
{
'@type': 'HowToStep',
position: 2,
name: 'AI identifies instantly',
text: 'In under a second you get the exact name, species and all key details.',
name: 'KI identifiziert sofort',
text: 'In unter einer Sekunde erhältst du den Namen, die Art und die wichtigsten Eckdaten.',
},
{
'@type': 'HowToStep',
position: 3,
name: 'Receive care plan',
text: 'GreenLens automatically creates a personalized care plan for your plant and location.',
name: 'Pflegeplan erhalten',
text: 'GreenLens erstellt automatisch einen Pflegeplan passend zu deiner Pflanze und deinem Standort.',
},
{
'@type': 'HowToStep',
position: 4,
name: 'Track growth',
text: 'Document photos, track watering and get reminded of important care dates.',
name: 'Wachstum verfolgen',
text: 'Dokumentiere Fotos, verfolge das Gießen und lass dich an wichtige Pflegetermine erinnern.',
},
],
}
@@ -54,42 +63,42 @@ const faqSchema = {
mainEntity: [
{
'@type': 'Question',
name: 'How does GreenLens identify a plant?',
name: 'Wie erkennt GreenLens eine Pflanze?',
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.',
text: 'GreenLens analysiert das Pflanzenfoto und verbindet das Ergebnis mit Pflegehinweisen in der App, damit du schneller zu klaren nächsten Schritten kommst.',
},
},
{
'@type': 'Question',
name: 'Is GreenLens free to use?',
name: 'Ist GreenLens kostenlos?',
acceptedAnswer: {
'@type': 'Answer',
text: 'GreenLens includes free functionality plus paid options such as subscriptions and credit top-ups for advanced AI features.',
text: 'GreenLens bietet kostenlose Funktionen und zusätzlich kostenpflichtige Optionen wie Abos und Credit-Top-ups für erweiterte KI-Funktionen.',
},
},
{
'@type': 'Question',
name: 'Can I use GreenLens offline?',
name: 'Kann ich GreenLens offline nutzen?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Plant identification and health checks require an internet connection. Your saved collection, care notes, and watering reminders are available offline.',
text: 'Pflanzenidentifikation und Gesundheitscheck benötigen eine Internetverbindung. Deine gespeicherte Sammlung, Pflegenotizen und Gieß-Erinnerungen sind offline verfügbar.',
},
},
{
'@type': 'Question',
name: 'What kind of plants can I use GreenLens for?',
name: 'Für welche Pflanzen kann ich GreenLens nutzen?',
acceptedAnswer: {
'@type': 'Answer',
text: 'GreenLens covers 450+ plant species including houseplants, garden plants, and succulents. It is built for everyday plant owners who want identification and care guidance in one place.',
text: 'GreenLens umfasst über 450 Pflanzenarten, darunter Zimmerpflanzen, Gartenpflanzen und Sukkulenten. Die App richtet sich an Pflanzenbesitzer, die Identifikation und Pflege an einem Ort wollen.',
},
},
{
'@type': 'Question',
name: 'How do I start my plant collection in GreenLens?',
name: 'Wie starte ich meine Pflanzensammlung?',
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.',
text: 'Starte mit einem Scan, prüfe das Ergebnis und speichere die Pflanze in deiner Sammlung, damit Notizen, Erinnerungen und Pflege an einem Ort bleiben.',
},
},
],
@@ -115,6 +124,51 @@ export default function Home() {
<Intelligence />
<HowItWorks />
<FAQ />
<section className="comparison-links" aria-labelledby="homepage-guides-heading">
<div className="container">
<div className="comparison-section-head">
<p className="tag">Guides</p>
<h2 id="homepage-guides-heading">Pflanzen schneller erkennen und richtig handeln.</h2>
</div>
<div className="comparison-links-grid">
<Link href="/blumen-scanner" className="comparison-link-card">
<p className="comparison-mini-label">Blumen</p>
<h3>Blumen Scanner</h3>
<p>Blumen per Foto erkennen, Namen erhalten und direkt Pflegehinweise nutzen.</p>
</Link>
<Link href="/blumen-scanner" className="comparison-link-card">
<p className="comparison-mini-label">Foto-Erkennung</p>
<h3>Blumen per Foto erkennen</h3>
<p>Ideal, wenn du eine Blume fotografiert hast und sofort wissen willst, was sie braucht.</p>
</Link>
<Link href="/pflanzen-bestimmen" className="comparison-link-card">
<p className="comparison-mini-label">Pflanzen</p>
<h3>Pflanzen bestimmen</h3>
<p>Pflanze scannen, Artname sehen und den passenden Pflegeplan erhalten.</p>
</Link>
<Link href="/pflanzen-pflege-app" className="comparison-link-card">
<p className="comparison-mini-label">Gießerinnerung</p>
<h3>Pflanzen gießen Erinnerung</h3>
<p>Pflegeplan, Push-Erinnerung und Gießrhythmus pro Pflanze verwalten.</p>
</Link>
<Link href="/vs/google-lens" className="comparison-link-card">
<p className="comparison-mini-label">Google Lens</p>
<h3>Google Pflanzen erkennen</h3>
<p>Vergleiche Google Lens mit GreenLens für Pflanzenerkennung, Pflege und Diagnose.</p>
</Link>
<Link href="/identify-plant-photo" className="comparison-link-card">
<p className="comparison-mini-label">English</p>
<h3>Identify plant by photo</h3>
<p>Use a plant photo or picture to get the name, care plan, and reminders.</p>
</Link>
<Link href="/pflanzen-krankheiten-erkennen" className="comparison-link-card">
<p className="comparison-mini-label">Diagnose</p>
<h3>Pflanzenkrankheiten erkennen</h3>
<p>Gelbe Blätter, braune Spitzen oder weiche Stiele einordnen und den nächsten Schritt finden.</p>
</Link>
</div>
</div>
</section>
<CTA />
</main>
<Footer />

View File

@@ -0,0 +1,33 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import SeoCategoryPage from '@/components/SeoCategoryPage'
import { getSeoPageBySlug } from '@/lib/seoPages'
import { siteConfig } from '@/lib/site'
const profile = getSeoPageBySlug('pflanzen-bestimmen')
export const metadata: Metadata = !profile
? {}
: {
title: profile.metaTitle,
description: profile.metaDescription,
alternates: { canonical: profile.canonical },
openGraph: {
title: profile.metaTitle,
description: profile.metaDescription,
url: `${siteConfig.domain}${profile.canonical}`,
type: 'website',
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: profile.metaTitle }],
},
twitter: {
card: 'summary_large_image',
title: profile.metaTitle,
description: profile.metaDescription,
images: ['/og-image.png'],
},
}
export default function Page() {
if (!profile) notFound()
return <SeoCategoryPage profile={profile} />
}

View File

@@ -0,0 +1,33 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import SeoCategoryPage from '@/components/SeoCategoryPage'
import { getSeoPageBySlug } from '@/lib/seoPages'
import { siteConfig } from '@/lib/site'
const profile = getSeoPageBySlug('pflanzen-erkennen-kostenlos')
export const metadata: Metadata = !profile
? {}
: {
title: profile.metaTitle,
description: profile.metaDescription,
alternates: { canonical: profile.canonical },
openGraph: {
title: profile.metaTitle,
description: profile.metaDescription,
url: `${siteConfig.domain}${profile.canonical}`,
type: 'website',
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: profile.metaTitle }],
},
twitter: {
card: 'summary_large_image',
title: profile.metaTitle,
description: profile.metaDescription,
images: ['/og-image.png'],
},
}
export default function Page() {
if (!profile) notFound()
return <SeoCategoryPage profile={profile} />
}

View File

@@ -0,0 +1,33 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import SeoCategoryPage from '@/components/SeoCategoryPage'
import { getSeoPageBySlug } from '@/lib/seoPages'
import { siteConfig } from '@/lib/site'
const profile = getSeoPageBySlug('pflanzen-krankheiten-erkennen')
export const metadata: Metadata = !profile
? {}
: {
title: profile.metaTitle,
description: profile.metaDescription,
alternates: { canonical: profile.canonical },
openGraph: {
title: profile.metaTitle,
description: profile.metaDescription,
url: `${siteConfig.domain}${profile.canonical}`,
type: 'website',
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: profile.metaTitle }],
},
twitter: {
card: 'summary_large_image',
title: profile.metaTitle,
description: profile.metaDescription,
images: ['/og-image.png'],
},
}
export default function Page() {
if (!profile) notFound()
return <SeoCategoryPage profile={profile} />
}

View File

@@ -0,0 +1,33 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import SeoCategoryPage from '@/components/SeoCategoryPage'
import { getSeoPageBySlug } from '@/lib/seoPages'
import { siteConfig } from '@/lib/site'
const profile = getSeoPageBySlug('pflanzen-pflege-app')
export const metadata: Metadata = !profile
? {}
: {
title: profile.metaTitle,
description: profile.metaDescription,
alternates: { canonical: profile.canonical },
openGraph: {
title: profile.metaTitle,
description: profile.metaDescription,
url: `${siteConfig.domain}${profile.canonical}`,
type: 'website',
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: profile.metaTitle }],
},
twitter: {
card: 'summary_large_image',
title: profile.metaTitle,
description: profile.metaDescription,
images: ['/og-image.png'],
},
}
export default function Page() {
if (!profile) notFound()
return <SeoCategoryPage profile={profile} />
}

View File

@@ -0,0 +1,33 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import SeoCategoryPage from '@/components/SeoCategoryPage'
import { getSeoPageBySlug } from '@/lib/seoPages'
import { siteConfig } from '@/lib/site'
const profile = getSeoPageBySlug('plant-health-app')
export const metadata: Metadata = !profile
? {}
: {
title: profile.metaTitle,
description: profile.metaDescription,
alternates: { canonical: profile.canonical },
openGraph: {
title: profile.metaTitle,
description: profile.metaDescription,
url: `${siteConfig.domain}${profile.canonical}`,
type: 'website',
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: profile.metaTitle }],
},
twitter: {
card: 'summary_large_image',
title: profile.metaTitle,
description: profile.metaDescription,
images: ['/og-image.png'],
},
}
export default function Page() {
if (!profile) notFound()
return <SeoCategoryPage profile={profile} />
}

View File

@@ -0,0 +1,33 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import SeoCategoryPage from '@/components/SeoCategoryPage'
import { getSeoPageBySlug } from '@/lib/seoPages'
import { siteConfig } from '@/lib/site'
const profile = getSeoPageBySlug('plant-scanner')
export const metadata: Metadata = !profile
? {}
: {
title: profile.metaTitle,
description: profile.metaDescription,
alternates: { canonical: profile.canonical },
openGraph: {
title: profile.metaTitle,
description: profile.metaDescription,
url: `${siteConfig.domain}${profile.canonical}`,
type: 'website',
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: profile.metaTitle }],
},
twitter: {
card: 'summary_large_image',
title: profile.metaTitle,
description: profile.metaDescription,
images: ['/og-image.png'],
},
}
export default function Page() {
if (!profile) notFound()
return <SeoCategoryPage profile={profile} />
}

View File

@@ -1,4 +1,6 @@
import { MetadataRoute } from 'next'
import { germanSeoPageSlugs, getGermanSeoPageBySlug } from '@/lib/seoPages'
import { spanishSeoPageProfiles, spanishSeoPageSlugs } from '@/lib/spanishSeoPages'
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = (process.env.NEXT_PUBLIC_SITE_URL || 'https://greenlenspro.com').trim()
@@ -6,7 +8,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: baseUrl,
lastModified: new Date('2026-04-08'),
lastModified: new Date('2026-04-27'),
changeFrequency: 'weekly',
priority: 1,
},
@@ -18,46 +20,88 @@ export default function sitemap(): MetadataRoute.Sitemap {
},
{
url: `${baseUrl}/plant-identifier-app`,
lastModified: new Date('2026-04-12'),
lastModified: new Date('2026-04-27'),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: `${baseUrl}/plant-disease-identifier`,
lastModified: new Date('2026-04-12'),
lastModified: new Date('2026-04-27'),
changeFrequency: 'monthly',
priority: 0.75,
},
{
url: `${baseUrl}/plant-care-app`,
lastModified: new Date('2026-04-12'),
changeFrequency: 'monthly',
priority: 0.75,
},
{
url: `${baseUrl}/pflanzen-erkennen-app`,
lastModified: new Date('2026-04-12'),
lastModified: new Date('2026-04-27'),
changeFrequency: 'monthly',
priority: 0.75,
},
{
url: `${baseUrl}/vs/picturethis`,
lastModified: new Date('2026-04-10'),
lastModified: new Date('2026-04-27'),
changeFrequency: 'monthly',
priority: 0.65,
},
{
url: `${baseUrl}/vs/plantum`,
lastModified: new Date('2026-04-10'),
lastModified: new Date('2026-04-27'),
changeFrequency: 'monthly',
priority: 0.65,
},
{
url: `${baseUrl}/vs/inaturalist`,
lastModified: new Date('2026-04-12'),
lastModified: new Date('2026-04-27'),
changeFrequency: 'monthly',
priority: 0.65,
},
{
url: `${baseUrl}/vs/google-lens`,
lastModified: new Date('2026-05-10'),
changeFrequency: 'monthly',
priority: 0.75,
},
{
url: `${baseUrl}/flower-scanner`,
lastModified: new Date('2026-04-27'),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: `${baseUrl}/identify-plant-photo`,
lastModified: new Date('2026-04-27'),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: `${baseUrl}/plant-scanner`,
lastModified: new Date('2026-04-27'),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: `${baseUrl}/houseplant-identifier`,
lastModified: new Date('2026-04-27'),
changeFrequency: 'monthly',
priority: 0.75,
},
{
url: `${baseUrl}/succulent-identifier`,
lastModified: new Date('2026-04-27'),
changeFrequency: 'monthly',
priority: 0.75,
},
{
url: `${baseUrl}/best-plant-identification-app`,
lastModified: new Date('2026-04-27'),
changeFrequency: 'monthly',
priority: 0.85,
},
{
url: `${baseUrl}/plant-health-app`,
lastModified: new Date('2026-04-27'),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: `${baseUrl}/imprint`,
lastModified: new Date('2026-04-08'),
@@ -76,5 +120,26 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: 'monthly',
priority: 0.3,
},
...germanSeoPageSlugs.map((slug) => {
const profile = getGermanSeoPageBySlug(slug)
return {
url: `${baseUrl}${profile?.canonical ?? `/${slug}`}`,
lastModified: new Date('2026-05-20'),
changeFrequency: 'monthly' as const,
priority: slug === 'pflanzen-erkennen-kostenlos' || slug === 'pflanzen-erkennen-app' ? 0.85 : 0.75,
}
}),
{
url: `${baseUrl}/es`,
lastModified: new Date('2026-05-20'),
changeFrequency: 'monthly',
priority: 0.85,
},
...spanishSeoPageSlugs.map((slug) => ({
url: `${baseUrl}${spanishSeoPageProfiles[slug].canonical}`,
lastModified: new Date('2026-05-20'),
changeFrequency: 'monthly' as const,
priority: slug === 'identificador-de-plantas' ? 0.85 : 0.75,
})),
]
}

View File

@@ -0,0 +1,33 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import SeoCategoryPage from '@/components/SeoCategoryPage'
import { getSeoPageBySlug } from '@/lib/seoPages'
import { siteConfig } from '@/lib/site'
const profile = getSeoPageBySlug('succulent-identifier')
export const metadata: Metadata = !profile
? {}
: {
title: profile.metaTitle,
description: profile.metaDescription,
alternates: { canonical: profile.canonical },
openGraph: {
title: profile.metaTitle,
description: profile.metaDescription,
url: `${siteConfig.domain}${profile.canonical}`,
type: 'website',
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: profile.metaTitle }],
},
twitter: {
card: 'summary_large_image',
title: profile.metaTitle,
description: profile.metaDescription,
images: ['/og-image.png'],
},
}
export default function Page() {
if (!profile) notFound()
return <SeoCategoryPage profile={profile} />
}

View File

@@ -0,0 +1,33 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import SeoCategoryPage from '@/components/SeoCategoryPage'
import { getSeoPageBySlug } from '@/lib/seoPages'
import { siteConfig } from '@/lib/site'
const profile = getSeoPageBySlug('zimmerpflanzen-bestimmen')
export const metadata: Metadata = !profile
? {}
: {
title: profile.metaTitle,
description: profile.metaDescription,
alternates: { canonical: profile.canonical },
openGraph: {
title: profile.metaTitle,
description: profile.metaDescription,
url: `${siteConfig.domain}${profile.canonical}`,
type: 'website',
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: profile.metaTitle }],
},
twitter: {
card: 'summary_large_image',
title: profile.metaTitle,
description: profile.metaDescription,
images: ['/og-image.png'],
},
}
export default function Page() {
if (!profile) notFound()
return <SeoCategoryPage profile={profile} />
}

View File

@@ -24,12 +24,35 @@ export default function ComparisonPage({ competitor, peers }: ComparisonPageProp
})),
}
const breadcrumbSchema = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Home',
item: siteConfig.domain,
},
{
'@type': 'ListItem',
position: 2,
name: `${siteConfig.name} vs ${competitor.name}`,
item: `${siteConfig.domain}/vs/${competitor.slug}`,
},
],
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
/>
<Navbar />
<main className="comparison-page">
<section className="comparison-hero">

View File

@@ -13,7 +13,7 @@ const faqs = [
},
answer: {
en: 'GreenLens analyzes the plant photo and combines that with app-side care guidance so you can move from scan to next steps faster.',
de: 'GreenLens analysiert das Pflanzenfoto und verbindet das Ergebnis mit Pflegehinweisen in der App, damit du schneller zu klaren naechsten Schritten kommst.',
de: 'GreenLens analysiert das Pflanzenfoto und verbindet das Ergebnis mit Pflegehinweisen in der App, damit du schneller zu klaren nächsten Schritten kommst.',
es: 'GreenLens analiza la foto de la planta y combina el resultado con indicaciones de cuidado dentro de la app para que avances mas rapido.'
}
},
@@ -25,7 +25,7 @@ const faqs = [
},
answer: {
en: 'GreenLens includes free functionality plus paid options such as subscriptions and credit top-ups for advanced AI features.',
de: 'GreenLens bietet kostenlose Funktionen und zusaetzlich kostenpflichtige Optionen wie Abos und Credit-Top-ups fuer erweiterte KI-Funktionen.',
de: 'GreenLens bietet kostenlose Funktionen und zusätzlich kostenpflichtige Optionen wie Abos und Credit-Top-ups für erweiterte KI-Funktionen.',
es: 'GreenLens incluye funciones gratuitas y tambien opciones de pago como suscripciones y creditos para funciones de IA mas umfangreiche.'
}
},
@@ -37,19 +37,19 @@ const faqs = [
},
answer: {
en: 'Plant identification and health checks require an internet connection. Your saved collection, care notes, and watering reminders are available offline.',
de: 'Pflanzenidentifikation und Gesundheitscheck benoetigen eine Internetverbindung. Deine gespeicherte Sammlung, Pflegenotizen und Giess-Erinnerungen sind offline verfuegbar.',
de: 'Pflanzenidentifikation und Gesundheitscheck benötigen eine Internetverbindung. Deine gespeicherte Sammlung, Pflegenotizen und Gieß-Erinnerungen sind offline verfügbar.',
es: 'La identificacion de plantas y el control de salud requieren conexion a internet. Tu coleccion guardada, notas de cuidado y recordatorios de riego estan disponibles sin conexion.'
}
},
{
question: {
en: 'What kind of plants can I use GreenLens for?',
de: 'Fuer welche Pflanzen kann ich GreenLens nutzen?',
de: 'Für welche Pflanzen kann ich GreenLens nutzen?',
es: 'Para que tipo de plantas puedo usar GreenLens?'
},
answer: {
en: 'GreenLens covers 450+ plant species including houseplants, garden plants, and succulents. It is built for everyday plant owners who want identification and care guidance in one place.',
de: 'GreenLens umfasst ueber 450 Pflanzenarten, darunter Zimmerpflanzen, Gartenpflanzen und Sukkulenten. Die App richtet sich an Pflanzenbesitzer, die Identifikation und Pflege an einem Ort wollen.',
de: 'GreenLens umfasst über 450 Pflanzenarten, darunter Zimmerpflanzen, Gartenpflanzen und Sukkulenten. Die App richtet sich an Pflanzenbesitzer, die Identifikation und Pflege an einem Ort wollen.',
es: 'GreenLens cubre mas de 450 especies de plantas, incluyendo plantas de interior, de jardin y suculentas. Esta pensada para quienes quieren identificacion y cuidado en un solo lugar.'
}
},
@@ -61,14 +61,14 @@ const faqs = [
},
answer: {
en: '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.',
de: 'Starte mit einem Scan, pruefe das Ergebnis und speichere die Pflanze in deiner Sammlung, damit Notizen, Erinnerungen und Pflege an einem Ort bleiben.',
de: 'Starte mit einem Scan, prüfe das Ergebnis und speichere die Pflanze in deiner Sammlung, damit Notizen, Erinnerungen und Pflege an einem Ort bleiben.',
es: 'Empieza con un escaneo, revisa el resultado y guarda la planta en tu coleccion para mantener notas, recordatorios y cuidado en un solo lugar.'
}
}
];
const TEXT = {
de: { tag: 'Fragen', h2: ['Haeufig gestellte', 'Fragen'], desc: 'Alles, was du ueber GreenLens und den Einstieg wissen musst.' },
de: { tag: 'Fragen', h2: ['Häufig gestellte', 'Fragen'], desc: 'Alles, was du über GreenLens und den Einstieg wissen musst.' },
en: { tag: 'Questions', h2: ['Frequently Asked', 'Questions'], desc: 'Everything you need to know about GreenLens and getting started.' },
es: { tag: 'Preguntas', h2: ['Preguntas', 'Frecuentes'], desc: 'Todo lo que necesitas saber sobre GreenLens y el inicio.' },
}

View File

@@ -34,19 +34,49 @@ export default function Footer() {
{label}
</Link>
))}
{ci === 1 && (
<>
<Link href="/plant-identifier-app">Plant Identifier App</Link>
<Link href="/plant-disease-identifier">Plant Disease Identifier</Link>
<Link href="/plant-care-app">Plant Care App</Link>
<Link href="/pflanzen-erkennen-app">Pflanzen erkennen</Link>
<Link href="/vs/picturethis">GreenLens vs PictureThis</Link>
<Link href="/vs/plantum">GreenLens vs Plantum</Link>
<Link href="/vs/inaturalist">GreenLens vs iNaturalist</Link>
</>
)}
</div>
))}
<div className="footer-col">
<div className="footer-col-title">Identify &amp; Care</div>
<Link href="/best-plant-identification-app">Best Plant ID App</Link>
<Link href="/plant-identifier-app">Plant Identifier App</Link>
<Link href="/plant-scanner">Plant Scanner</Link>
<Link href="/flower-scanner">Flower Scanner</Link>
<Link href="/houseplant-identifier">Houseplant Identifier</Link>
<Link href="/succulent-identifier">Succulent Identifier</Link>
<Link href="/identify-plant-photo">Identify Plant by Photo</Link>
<Link href="/plant-disease-identifier">Plant Disease Identifier</Link>
<Link href="/plant-health-app">Plant Health App</Link>
<Link href="/plant-care-app">Plant Care App</Link>
<Link href="/vs/picturethis">vs PictureThis</Link>
<Link href="/vs/plantum">vs Plantum</Link>
<Link href="/vs/inaturalist">vs iNaturalist</Link>
<Link href="/vs/google-lens">vs Google Lens</Link>
</div>
<div className="footer-col">
<div className="footer-col-title">Pflanzen &amp; Erkennen</div>
<Link href="/">GreenLens auf Deutsch</Link>
<Link href="/pflanzen-erkennen-kostenlos">Pflanzen erkennen kostenlos</Link>
<Link href="/pflanzen-erkennen-app">Pflanzen erkennen App</Link>
<Link href="/pflanzen-bestimmen">Pflanzen bestimmen</Link>
<Link href="/blumen-scanner">Blumen Scanner</Link>
<Link href="/blumen-scanner">Blumen per Foto erkennen</Link>
<Link href="/zimmerpflanzen-bestimmen">Zimmerpflanzen bestimmen</Link>
<Link href="/pflanzen-pflege-app">Pflanzen Pflege App</Link>
<Link href="/pflanzen-krankheiten-erkennen">Pflanzenkrankheiten erkennen</Link>
</div>
<div className="footer-col">
<div className="footer-col-title">Español</div>
<Link href="/es">GreenLens en español</Link>
<Link href="/es/identificador-de-plantas">Identificador de plantas</Link>
<Link href="/es/escaner-de-plantas">Escaner de plantas</Link>
<Link href="/es/app-para-cuidar-plantas">App para cuidar plantas</Link>
<Link href="/es/diagnosticar-enfermedades-plantas">Diagnosticar enfermedades</Link>
<Link href="/es/comparar/google-lens">GreenLens vs Google Lens</Link>
</div>
</div>
<div className="footer-brand-xl" aria-hidden="true">GREENLENS</div>

View File

@@ -81,6 +81,21 @@ export default function Hero() {
</a>
</div>
<a
className="product-hunt-badge reveal delay-4"
href="https://www.producthunt.com/products/greenlens?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-greenlens"
target="_blank"
rel="noopener noreferrer"
aria-label="GreenLens on Product Hunt"
>
<img
alt="GreenLens - Scan plants, understand care, and grow smarter | Product Hunt"
width="250"
height="54"
src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1160891&theme=light&t=1780336122877"
/>
</a>
{/* Segmentation widget */}
<div className="hero-seg reveal delay-4" role="group" aria-label={t.hero.segTitle}>
<p className="hero-seg-title">{t.hero.segTitle}</p>
@@ -121,6 +136,15 @@ export default function Hero() {
</div>
<style jsx>{`
.product-hunt-badge {
display: block;
width: fit-content;
margin-top: 1rem;
}
.product-hunt-badge img {
width: 250px;
height: 54px;
}
.hero-seg {
margin-top: 2rem;
background: rgba(244,241,232,0.06);
@@ -183,6 +207,12 @@ export default function Hero() {
background: var(--green-light);
box-shadow: 0 0 0 3px rgba(86,160,116,0.2);
}
@media (max-width: 1024px) {
.product-hunt-badge {
margin-left: auto;
margin-right: auto;
}
}
`}</style>
</section>
)

View File

@@ -24,7 +24,7 @@ const STEPS = {
],
}
const TAG = { de: 'So funktionierts', en: 'How it works', es: 'Cómo funciona' }
const TAG = { de: 'So funktioniert es', en: 'How it works', es: 'Cómo funciona' }
const H2 = {
de: ['Einfacher', 'als du', 'denkst.'],
en: ['Simpler', 'than you', 'think.'],

View File

@@ -23,7 +23,7 @@ const ICONS = [
const ITEMS = {
de: [
{ title: 'Scan-basierte Erkennung', desc: 'Vom Foto zur besseren Einordnung in wenigen Schritten.' },
{ title: 'Pflegeorientierte Hinweise', desc: 'Hilft dir, naechste Pflegeentscheidungen schneller zu treffen.' },
{ title: 'Pflegeorientierte Hinweise', desc: 'Hilft dir, nächste Pflegeentscheidungen schneller zu treffen.' },
{ title: 'Sammlung und Verlauf', desc: 'Behalte Scans, Pflanzen und Notizen an einem Ort.' },
{ title: 'Lexikon und Suche', desc: 'Suche Pflanzen und vergleiche Informationen in einer App.' },
],
@@ -43,7 +43,7 @@ const ITEMS = {
const TAG_TEXT = { de: 'Technologie', en: 'Technology', es: 'Tecnologia' }
const BODY_TEXT = {
de: 'GreenLens verbindet Scan-Ergebnisse, Pflegekontext und Sammlungsverwaltung in einer App. So kommst du schneller von einem Pflanzenfoto zu einer verstaendlichen Entscheidung.',
de: 'GreenLens verbindet Scan-Ergebnisse, Pflegekontext und Sammlungsverwaltung in einer App. So kommst du schneller von einem Pflanzenfoto zu einer verständlichen Entscheidung.',
en: 'GreenLens combines scan results, care context, and collection management in one app, helping you move from plant photo to a clearer decision faster.',
es: 'GreenLens combina resultados de escaneo, contexto de cuidado y gestion de coleccion en una sola app para ayudarte a pasar de una foto a una decision mas clara.',
}

View File

@@ -48,6 +48,15 @@ export default function Navbar() {
onClick={() => {
setLang(l.code)
setMenuOpen(false)
if (l.code === 'de' && pathname !== '/') {
window.location.href = '/'
}
if (l.code === 'es' && !pathname.startsWith('/es')) {
window.location.href = '/es'
}
if (l.code === 'en' && (pathname.startsWith('/de') || pathname.startsWith('/es'))) {
window.location.href = '/'
}
}}
aria-label={`Switch to ${l.label}`}
aria-pressed={lang === l.code}

View File

@@ -10,6 +10,63 @@ interface SeoCategoryPageProps {
}
export default function SeoCategoryPage({ profile }: SeoCategoryPageProps) {
const labels = {
en: {
heroTag: 'GreenLens',
primaryCta: 'Try GreenLens',
secondaryCta: 'See full comparison',
definition: 'Definition',
updated: 'Last updated:',
tableTag: 'At a glance',
bestFit: 'Best fit',
chooseIf: 'Choose GreenLens if:',
notBestFit: 'Not the best fit',
notRightIf: 'GreenLens is not the right tool if:',
faqTag: 'FAQ',
faqTitle: 'Common questions answered directly.',
related: 'Related',
supportTag: 'Need help?',
supportTitle: 'Talk to GreenLens support',
supportCopy: 'Questions about scans, care plans, billing, or features? Use the support page.',
},
de: {
heroTag: 'GreenLens',
primaryCta: 'GreenLens testen',
secondaryCta: 'Vergleich ansehen',
definition: 'Definition',
updated: 'Aktualisiert:',
tableTag: 'Überblick',
bestFit: 'Passt gut',
chooseIf: 'Wähle GreenLens, wenn:',
notBestFit: 'Nicht ideal',
notRightIf: 'GreenLens ist nicht die richtige Wahl, wenn:',
faqTag: 'FAQ',
faqTitle: 'Häufige Fragen direkt beantwortet.',
related: 'Verwandt',
supportTag: 'Brauchst du Hilfe?',
supportTitle: 'GreenLens Support kontaktieren',
supportCopy: 'Fragen zu Scans, Pflegeplänen, Abrechnung oder Funktionen? Nutze die Support-Seite.',
},
es: {
heroTag: 'GreenLens',
primaryCta: 'Probar GreenLens',
secondaryCta: 'Ver comparación',
definition: 'Definición',
updated: 'Actualizado:',
tableTag: 'Resumen',
bestFit: 'Mejor opción',
chooseIf: 'Elige GreenLens si:',
notBestFit: 'No es ideal',
notRightIf: 'GreenLens no es la herramienta adecuada si:',
faqTag: 'FAQ',
faqTitle: 'Preguntas frecuentes respondidas directamente.',
related: 'Relacionado',
supportTag: '¿Necesitas ayuda?',
supportTitle: 'Contacta con soporte de GreenLens',
supportCopy: '¿Preguntas sobre escaneos, planes de cuidado, facturación o funciones? Usa la página de soporte.',
},
}[profile.locale ?? 'en']
const faqSchema = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
@@ -40,6 +97,25 @@ export default function SeoCategoryPage({ profile }: SeoCategoryPageProps) {
}
: null
const breadcrumbSchema = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Home',
item: siteConfig.domain,
},
{
'@type': 'ListItem',
position: 2,
name: profile.h1,
item: `${siteConfig.domain}${profile.canonical}`,
},
],
}
return (
<>
<script
@@ -52,35 +128,60 @@ export default function SeoCategoryPage({ profile }: SeoCategoryPageProps) {
dangerouslySetInnerHTML={{ __html: JSON.stringify(appSchema) }}
/>
)}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
/>
<Navbar />
<main className="comparison-page">
{/* Hero */}
<section className="comparison-hero">
<div className="container comparison-hero-grid">
<div className="comparison-hero-copy">
<p className="tag">GreenLens</p>
<p className="tag">{labels.heroTag}</p>
<h1>{profile.h1}</h1>
<p className="comparison-lead">{profile.tagline}</p>
<p>{profile.directAnswer}</p>
<div className="comparison-actions">
<a href="#cta" className="btn-primary">Try GreenLens</a>
<a href="#feature-table" className="btn-outline">See full comparison</a>
<a href="#cta" className="btn-primary">{labels.primaryCta}</a>
<a href="#feature-table" className="btn-outline">{labels.secondaryCta}</a>
</div>
</div>
<aside className="comparison-hero-card">
<p className="comparison-card-label">Definition</p>
<p className="comparison-card-label">{labels.definition}</p>
<p>{profile.definitionBlock}</p>
<p className="comparison-verified">Last updated: {profile.lastUpdated}</p>
<p className="comparison-verified">{labels.updated} {profile.lastUpdated}</p>
</aside>
</div>
</section>
{profile.contentSections && profile.contentSections.length > 0 && (
<section className="comparison-context">
<div className="container comparison-context-grid">
{profile.contentSections.map((section) => (
<article key={section.title} className="comparison-context-card">
<p className="tag">{section.eyebrow}</p>
<h2>{section.title}</h2>
<p>{section.body}</p>
{section.bullets && (
<ul className="comparison-bullet-list comparison-bullet-list--dark">
{section.bullets.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
)}
</article>
))}
</div>
</section>
)}
{/* Feature table */}
<section className="comparison-table-section" id="feature-table">
<div className="container">
<div className="comparison-section-head">
<p className="tag">At a glance</p>
<p className="tag">{labels.tableTag}</p>
<h2>{profile.featureTable.title}</h2>
</div>
@@ -106,8 +207,8 @@ export default function SeoCategoryPage({ profile }: SeoCategoryPageProps) {
<section className="comparison-fit">
<div className="container comparison-fit-grid">
<article className="comparison-fit-card comparison-fit-card--greenlens">
<p className="tag">Best fit</p>
<h2>Choose GreenLens if:</h2>
<p className="tag">{labels.bestFit}</p>
<h2>{labels.chooseIf}</h2>
<ul className="comparison-bullet-list comparison-bullet-list--dark">
{profile.greenLensIf.map((item) => (
<li key={item}>{item}</li>
@@ -116,8 +217,8 @@ export default function SeoCategoryPage({ profile }: SeoCategoryPageProps) {
</article>
<article className="comparison-fit-card">
<p className="tag">Not the best fit</p>
<h2>GreenLens is not the right tool if:</h2>
<p className="tag">{labels.notBestFit}</p>
<h2>{labels.notRightIf}</h2>
<ul className="comparison-bullet-list comparison-bullet-list--dark">
{profile.notBestIf.map((item) => (
<li key={item}>{item}</li>
@@ -131,8 +232,8 @@ export default function SeoCategoryPage({ profile }: SeoCategoryPageProps) {
<section className="comparison-faq">
<div className="container">
<div className="comparison-section-head">
<p className="tag">FAQ</p>
<h2>Common questions answered directly.</h2>
<p className="tag">{labels.faqTag}</p>
<h2>{labels.faqTitle}</h2>
</div>
<div className="comparison-faq-grid">
@@ -152,18 +253,16 @@ export default function SeoCategoryPage({ profile }: SeoCategoryPageProps) {
<div className="container comparison-links-grid">
{profile.relatedLinks.map((link) => (
<Link key={link.href} href={link.href} className="comparison-link-card">
<p className="comparison-mini-label">Related</p>
<p className="comparison-mini-label">{labels.related}</p>
<h3>{link.label}</h3>
<p>{link.description}</p>
</Link>
))}
<Link href="/support" className="comparison-link-card comparison-link-card--support">
<p className="comparison-mini-label">Need help?</p>
<h3>Talk to GreenLens support</h3>
<p>
Questions about scans, care plans, billing, or features? Use the support page.
</p>
<p className="comparison-mini-label">{labels.supportTag}</p>
<h3>{labels.supportTitle}</h3>
<p>{labels.supportCopy}</p>
</Link>
</div>
</section>

View File

@@ -17,6 +17,9 @@ const LangContext = createContext<LangCtx>({
function getInitialLang(): Lang {
if (typeof document === 'undefined') return 'de'
if (window.location.pathname === '/') return 'de'
if (window.location.pathname === '/de' || window.location.pathname.startsWith('/de/')) return 'de'
if (window.location.pathname === '/es' || window.location.pathname.startsWith('/es/')) return 'es'
const match = document.cookie.match(/(?:^|;\s*)lang=([^;]+)/)
const val = match?.[1]
return val === 'en' || val === 'es' || val === 'de' ? val : 'de'
@@ -26,7 +29,9 @@ export function LangProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Lang>('de')
useEffect(() => {
setLangState(getInitialLang())
const initialLang = getInitialLang()
document.documentElement.lang = initialLang
setLangState(initialLang)
}, [])
const setLang = (l: Lang) => {

View File

@@ -1,4 +1,4 @@
export type CompetitorSlug = 'picturethis' | 'plantum' | 'inaturalist'
export type CompetitorSlug = 'picturethis' | 'plantum' | 'inaturalist' | 'google-lens'
export interface ComparisonThesis {
title: string
@@ -48,9 +48,9 @@ export const competitorProfiles: Record<CompetitorSlug, CompetitorProfile> = {
picturethis: {
slug: 'picturethis',
name: 'PictureThis',
metaTitle: 'GreenLens vs PictureThis',
metaTitle: 'GreenLens vs. PictureThis — Honest Plant App Comparison (2026)',
metaDescription:
'Compare GreenLens vs PictureThis for plant emergencies, next-step diagnosis, pricing friction, and care guidance. See when GreenLens is the better fit.',
'GreenLens or PictureThis? Compare plant emergency workflows, paywall behavior, care guidance, and diagnosis depth. See which app fits your situation.',
heroSummary:
'PictureThis is one of the best-known plant ID apps on the market, but GreenLens is built for a different moment: when your plant already looks wrong and you need the next correct action, not another generic care checklist.',
heroVerdict: [
@@ -208,9 +208,9 @@ export const competitorProfiles: Record<CompetitorSlug, CompetitorProfile> = {
plantum: {
slug: 'plantum',
name: 'Plantum',
metaTitle: 'GreenLens vs Plantum',
metaTitle: 'GreenLens vs. Plantum — Plant Triage vs. All-in-One Assistant (2026)',
metaDescription:
'Compare GreenLens vs Plantum for plant diagnosis, care workflows, pricing friction, and beginner clarity. See why GreenLens is the better plant ER choice.',
'GreenLens or Plantum? Compare diagnosis depth, beginner clarity, care workflows, and pricing friction. See which plant app fits your situation.',
heroSummary:
'Plantum markets itself as a high-accuracy, all-in-one plant care assistant. GreenLens is the sharper choice when the user does not want an all-in-one system right now, but a clear answer to what to do next for a struggling plant.',
heroVerdict: [
@@ -368,9 +368,9 @@ export const competitorProfiles: Record<CompetitorSlug, CompetitorProfile> = {
inaturalist: {
slug: 'inaturalist',
name: 'iNaturalist',
metaTitle: 'GreenLens vs iNaturalist',
metaTitle: 'GreenLens vs. iNaturalist — Plant Care vs. Citizen Science (2026)',
metaDescription:
'Compare GreenLens vs iNaturalist for plant identification, care guidance, and disease triage. See which app fits your situation — owned plant care or biodiversity discovery.',
'GreenLens or iNaturalist? Plant care, watering reminders, and health diagnosis (GreenLens) vs. biodiversity discovery and community ID (iNaturalist). Find your fit.',
heroSummary:
'iNaturalist is one of the most respected citizen science platforms in the world. GreenLens is built for a different job: helping you decide what to do next when a plant you own is struggling. These two tools solve different problems, and the right choice depends entirely on what you are trying to accomplish.',
heroVerdict: [
@@ -525,12 +525,180 @@ export const competitorProfiles: Record<CompetitorSlug, CompetitorProfile> = {
},
],
},
'google-lens': {
slug: 'google-lens',
name: 'Google Lens',
metaTitle: 'Google Pflanzen erkennen: GreenLens vs Google Lens',
metaDescription:
'Google Lens Pflanzen erkennen vs GreenLens: Name, Pflegeplan, Gießerinnerung und Diagnose vergleichen. Was hilft nach dem Foto wirklich?',
heroSummary:
'Mit Google Pflanzen erkennen ist schnell: Foto öffnen, Google Lens starten, Namen prüfen. GreenLens ist die spezialisierte Alternative für den Schritt danach: Pflanze fotografieren, sofort Name erhalten und direkt Pflegeplan, Diagnose und Gießerinnerungen nutzen — ohne Umweg über Google-Suchergebnisse.',
heroVerdict: [
'Wähle GreenLens, wenn du nach dem Pflanzennamen auch wissen willst, wie du sie pflegst.',
'Nutze Google Lens, wenn du nur schnell einen Namen nachschlagen möchtest und keine Pflegefunktionen brauchst.',
'Für alles nach dem Namen — Pflege, Diagnose, Erinnerungen — ist Google Lens nicht gebaut.',
],
disclaimer:
'Google Lens ist ein kostenloses, allgemeines Bildsuchwerkzeug von Google. Dieser Vergleich basiert auf öffentlich zugänglichen Funktionen (Stand April 2026).',
lastVerified: 'April 2026',
competitorSnapshot:
'Google Lens ist in die Google-Kamera und Google-Fotos integriert und kostenlos nutzbar. Es erkennt Pflanzen, Tiere, Objekte und Text anhand von Fotos. Für Pflanzenerkennung liefert es einen Namen und Links zu Google-Suchergebnissen — aber keinen Pflegeplan, keine Diagnose und keine Erinnerungen.',
greenLensPositioning:
'GreenLens ist kein allgemeines Suchwerkzeug. Die App ist ausschließlich für Pflanzen entwickelt: Erkennung, Pflegeplanung, Gesundheitscheck und Sammelverwaltung in einer Oberfläche — ohne Weiterleitung auf externe Webseiten.',
whyPeopleCompare: [
'Sie haben Google Lens für schnelle Erkennung genutzt, aber keine Pflegeinformationen erhalten.',
'Sie suchen nach „Pflanzen bestimmen Google" und wollen wissen, ob eine spezialisierte App danach mehr hilft.',
'Sie suchen nach „Google Pflanzen erkennen" oder „Google Lens Pflanzen erkennen" und vergleichen, ob eine Pflanzen-App genauer für Pflegefragen ist.',
'Sie suchen eine kostenlose Alternative zu Google, die nach der Erkennung weitergeht.',
'Sie wollen nach dem Scan direkt wissen, was sie tun sollen — nicht auf eine Suchergebnisseite weitergeleitet werden.',
],
theses: [
{
title: 'Allgemeine Suche vs. Pflanzen-App',
greenlens:
'GreenLens ist ausschließlich für Pflanzen entwickelt. Scan, Pflege und Diagnose sind auf den Pflanzenkontext ausgerichtet.',
competitor:
'Google Lens ist ein universelles Bildsuchwerkzeug. Pflanzenerkennung ist eine von vielen Funktionen — keine Kernkompetenz.',
},
{
title: 'Name vs. nächster Schritt',
greenlens:
'Nach dem Scan liefert GreenLens sofort Pflegeplan, Gießerinnerungen und Gesundheitscheck — ohne weiteren Klick.',
competitor:
'Google Lens gibt einen Namen zurück und leitet auf Suchergebnisse weiter. Was du als nächstes tun sollst, bleibt offen.',
},
{
title: 'Pflege und Diagnose',
greenlens:
'Jede erkannte Pflanze bekommt automatisch Pflegeinformationen, Gießplan und die Möglichkeit eines Gesundheitschecks bei Symptomen.',
competitor:
'Google Lens hat keine Pflegefunktionen, keine Erinnerungen und keine Möglichkeit, Symptome wie gelbe Blätter zu analysieren.',
},
],
categories: [
{
title: 'Pflanzenerkennung',
greenlens:
'KI-gestützter Scan über 450 Arten. Ergebnis erscheint direkt in der App mit Namen, Artportrait und Pflegeprofil.',
competitor:
'Erkennt eine große Bandbreite an Pflanzen schnell und kostenlos. Ergebnis sind Links zu Google-Suchergebnissen.',
whyItMatters:
'Für einfache Namenssuche ist Google Lens gut. Für alles, was danach kommt, ist GreenLens die richtigere Wahl.',
},
{
title: 'Pflegeplan und Gießerinnerungen',
greenlens:
'Automatischer Pflegeplan nach dem Scan. Gießerinnerungen, Düngepläne und Umtopf-Hinweise pro Pflanze.',
competitor:
'Keine Pflegefunktionen. Nach der Erkennung gibt es keinen weiteren Schritt in der App.',
whyItMatters:
'Den Namen einer Pflanze zu kennen löst das eigentliche Problem nicht. Zu wissen, wann sie Wasser braucht, schon.',
},
{
title: 'Gesundheitscheck und Diagnose',
greenlens:
'Eigener Scan für Symptome: gelbe Blätter, weiche Stiele, Flecken. GreenLens nennt die wahrscheinlichste Ursache und einen konkreten nächsten Schritt.',
competitor:
'Keine Diagnosefunktion. Google Lens erkennt die Pflanze, aber nicht ihren Zustand oder Symptome.',
whyItMatters:
'Wenn eine Pflanze krank aussieht, braucht man eine Diagnose — keinen Pflanzennamen.',
},
{
title: 'Pflanzensammlung',
greenlens:
'Erkannte Pflanzen in persönlicher Sammlung mit Fotos, Pflegeverlauf und individuellen Erinnerungen speichern.',
competitor:
'Keine Sammelfunktion. Google Lens hat keinen Pflanzenbereich, in dem erkannte Pflanzen dauerhaft verwaltet werden.',
whyItMatters:
'Wer mehrere Pflanzen hat, braucht mehr als eine Suchhistorie.',
},
{
title: 'Preis',
greenlens:
'Kostenlose Pflanzenerkennung mit optionalen Paid-Funktionen für unbegrenzte KI-Scans und Gesundheitschecks.',
competitor:
'Vollständig kostenlos — allerdings auch ohne Pflegefunktionen.',
whyItMatters:
'Google Lens ist der günstigste Weg, eine Pflanze zu benennen. GreenLens liefert den Wert, der danach kommt.',
},
{
title: 'Offline-Nutzung',
greenlens:
'Scans und Gesundheitschecks benötigen Internet. Gespeicherte Sammlung und Erinnerungen sind offline verfügbar.',
competitor:
'Benötigt ebenfalls eine Internetverbindung für die Bilderkennung.',
whyItMatters:
'Beide benötigen Internet für die Erkennung. GreenLens bietet mehr Offline-Datenzugang für gespeicherte Pflanzen.',
},
],
greenLensBestFor: [
'Pflanzenbesitzer, die nach dem Namen auch wissen wollen, wie sie ihre Pflanze pflegen.',
'Nutzer, die bei Problemen wie gelben Blättern oder weichen Stielen schnell eine Diagnose brauchen.',
'Alle, die ihre Pflanzensammlung verwalten und Pflegeerinnerungen nutzen möchten.',
],
competitorBestFor: [
'Schnelle kostenlose Namenssuche ohne weitere Ansprüche an die App.',
'Gelegenheitsnutzer, die nur selten und ohne Pflanzenpflege-Kontext identifizieren möchten.',
'Nutzer, die die Google-Kamera bereits verwenden und keinen separaten App-Download wollen.',
],
emergencyScenarios: [
{
symptom: 'Gelbe Blätter — Was tun?',
greenlens:
'GreenLens analysiert die Symptome, fragt nach Veränderungen in der Pflege und nennt den wahrscheinlichsten Grund mit einem konkreten nächsten Schritt.',
competitor:
'Google Lens kann das Pflanzenbild erkennen, aber keine Diagnose für Symptome stellen. Es gibt keine Pflegeanalyse oder Handlungsempfehlung.',
},
{
symptom: 'Unbekannte Pflanze im Garten — Was ist das?',
greenlens:
'GreenLens identifiziert die Pflanze und speichert sie direkt mit Pflegeplan in der Sammlung.',
competitor:
'Google Lens liefert schnell und kostenlos einen Namen — für einfache Identifikation eine gute Option.',
},
{
symptom: 'Pflanze hängt nach dem Umtopfen',
greenlens:
'GreenLens verbindet das Symptom mit der jüngsten Veränderung und empfiehlt den nächsten risikoärmsten Schritt.',
competitor:
'Nicht für diesen Anwendungsfall entwickelt. Google Lens bietet keine Pflegekontext-Analyse.',
},
],
faqs: [
{
question: 'Kann Google Lens Pflanzen genauso gut erkennen wie GreenLens?',
answer:
'Für einfache Identifikation häufiger Pflanzen ist Google Lens schnell und kostenlos. GreenLens ist spezialisiert auf Pflanzen und liefert nach der Erkennung direkt einen Pflegeplan — Google Lens leitet auf Suchergebnisse weiter.',
},
{
question: 'Warum GreenLens nutzen, wenn Google Lens kostenlos ist?',
answer:
'Google Lens nennt den Namen. GreenLens erklärt, was du als nächstes tun sollst: Wie oft gießen, welches Licht, wann umtopfen — und bei Problemen wie gelben Blättern, was die Ursache ist und was du konkret tun kannst.',
},
{
question: 'Hat Google Lens eine Pflanzenpflege-Funktion?',
answer:
'Nein. Google Lens ist ein allgemeines Bildsuchwerkzeug ohne Pflegeplan, Gießerinnerungen, Gesundheitscheck oder Sammlungsverwaltung.',
},
{
question: 'Wie kann ich Pflanzen mit Google bestimmen?',
answer:
'Mit Google Lens kannst du ein Pflanzenfoto analysieren und Suchergebnisse zum wahrscheinlichen Namen erhalten. GreenLens nutzt denselben Foto-Intent, geht aber weiter: Nach dem Scan erhältst du Artname, Pflegeplan, Gießerinnerung und bei Symptomen eine Diagnose.',
},
{
question: 'Ist GreenLens besser als Google Lens für Pflanzenerkennung?',
answer:
'Für die reine Erkennung sind beide gut. Der Unterschied liegt im Danach: GreenLens gibt Pflegeplan, Diagnose und Erinnerungen — Google Lens gibt Links zu Webseiten.',
},
],
},
}
export const competitorOrder: CompetitorSlug[] = ['picturethis', 'plantum', 'inaturalist']
export const competitorOrder: CompetitorSlug[] = ['picturethis', 'plantum', 'inaturalist', 'google-lens']
export function getCompetitorBySlug(slug: string): CompetitorProfile | undefined {
if (slug === 'picturethis' || slug === 'plantum' || slug === 'inaturalist') {
if (slug === 'picturethis' || slug === 'plantum' || slug === 'inaturalist' || slug === 'google-lens') {
return competitorProfiles[slug]
}

View File

@@ -14,7 +14,7 @@ export const translations = {
h1a: 'Dein Urban',
h1b: 'Jungle,',
h1em: 'besser gepflegt.',
desc: 'Scanne Pflanzen, verstehe ihre Beduerfnisse und organisiere Pflege, Erinnerungen und Sammlung in einer App.',
desc: 'Scanne Pflanzen, verstehe ihre Bedürfnisse und organisiere Pflege, Erinnerungen und Sammlung in einer App.',
primary: 'App entdecken',
secondary: 'Mehr erfahren',
badge: 'Plant care with AI',
@@ -26,7 +26,7 @@ export const translations = {
tag: 'Pflanzenhilfe',
headline: 'Weniger raten.',
sub: 'Schneller verstehen, was deiner Pflanze fehlt.',
desc: 'GreenLens hilft dir dabei, Symptome einzuordnen, Pflanzen zu identifizieren und naechste Pflegeschritte klarer zu machen.',
desc: 'GreenLens hilft dir dabei, Symptome einzuordnen, Pflanzen zu identifizieren und nächste Pflegeschritte klarer zu machen.',
before: 'Vorher',
after: 'Nachher',
sliderLabel: 'Vergleichs-Slider',
@@ -35,30 +35,30 @@ export const translations = {
proof2title: 'Pflegeplan strukturieren',
proof2desc: 'Weniger Chaos bei Licht, Wasser und Intervallen',
proof3title: 'Hinweise an einem Ort',
proof3desc: 'Scan, Sammlung und Care Notes zusammengefuehrt',
proof3desc: 'Scan, Sammlung und Care Notes zusammengeführt',
},
features: {
tag: 'Features',
h2a: 'Alles, was dein',
h2b: 'Urban Jungle braucht.',
desc: 'Von der ersten Identifikation bis zur laufenden Pflege hilft GreenLens dir, Pflanzen besser zu verstehen und besser zu organisieren. Das Lexikon umfasst ueber 450 Pflanzenarten.',
desc: 'Von der ersten Identifikation bis zur laufenden Pflege hilft GreenLens dir, Pflanzen besser zu verstehen und besser zu organisieren. Das Lexikon umfasst über 450 Pflanzenarten.',
},
cta: {
tag: 'Download',
h2a: 'Bereit fuer bessere',
h2a: 'Bereit für bessere',
h2em: 'Pflanzenpflege?',
desc: 'GreenLens hilft dir beim Erkennen, Verstehen und Pflegen deiner Pflanzen. Wenn der Store-Link noch nicht live ist, erreichst du uns ueber die Support-Seite.',
desc: 'GreenLens hilft dir beim Erkennen, Verstehen und Pflegen deiner Pflanzen. Wenn der Store-Link noch nicht live ist, erreichst du uns über die Support-Seite.',
apple: 'Laden im',
google: 'Jetzt bei',
support: 'Zur',
supportLabel: 'Support-Seite',
contact: 'Direkt',
email: 'E-Mail senden',
comingSoon: 'Die App ist bald verfuegbar.',
liveNote: 'Die App ist bereits in den Stores verfuegbar.',
comingSoon: 'Die App ist bald verfügbar.',
liveNote: 'Die App ist bereits in den Stores verfügbar.',
},
footer: {
brand: 'Die App fuer Pflanzenfans, die Sammlung, Identifikation und Pflege an einem Ort wollen.',
brand: 'Die App für Pflanzenfans, die Sammlung, Identifikation und Pflege an einem Ort wollen.',
copy: '© 2026 GreenLens. Alle Rechte vorbehalten.',
cols: [
{ title: 'Produkt', links: ['Features', 'Technologie', 'App laden', 'Support'] },

File diff suppressed because it is too large Load Diff

View File

@@ -3,8 +3,8 @@ const siteUrl = (process.env.NEXT_PUBLIC_SITE_URL || 'https://greenlenspro.com')
export const siteConfig = {
name: 'GreenLens',
domain: siteUrl,
supportEmail: 'knuth.timo@gmail.com',
legalEmail: 'knuth.timo@gmail.com',
supportEmail: 'timo@greenlenspro.com',
legalEmail: 'timo@greenlenspro.com',
iosAppStoreUrl: 'https://apps.apple.com/de/app/greenlens-pro/id6759843546?l=en-GB',
androidPlayStoreUrl: '',
company: {

View File

@@ -0,0 +1,388 @@
import type { SeoPageProfile } from '@/lib/seoPages'
export const spanishSeoPageProfiles: Record<string, SeoPageProfile> = {
'identificador-de-plantas': {
slug: 'identificador-de-plantas',
locale: 'es',
metaTitle: 'Identificador de plantas por foto | GreenLens',
metaDescription:
'Identifica plantas por foto con GreenLens y recibe nombre, cuidados, recordatorios y diagnostico de salud en una sola app.',
canonical: '/es/identificador-de-plantas',
h1: 'Identificador de plantas',
tagline: 'Escanea una planta y entiende que necesita en segundos.',
directAnswer:
'GreenLens es un identificador de plantas para iOS y Android. Toma una foto, recibe el nombre de la especie y pasa directamente a cuidados, riego y diagnostico si la planta muestra sintomas.',
definitionBlock:
'Un identificador de plantas usa IA para comparar una foto con una base de datos vegetal. GreenLens conecta esa identificacion con un plan de cuidado, recordatorios y revision de salud.',
lastUpdated: 'Mayo 2026',
includeAppSchema: true,
featureTable: {
title: 'GreenLens frente a un identificador basico',
alternativeLabel: 'Apps basicas',
rows: [
{
feature: 'Identificacion por foto',
greenlens: 'Nombre, especie y perfil de cuidado en el mismo flujo.',
alternative: 'Normalmente solo muestran el nombre o enlaces externos.',
},
{
feature: 'Plan de cuidado',
greenlens: 'Riego, luz y fertilizacion despues del escaneo.',
alternative: 'El usuario debe buscar y configurar todo manualmente.',
},
{
feature: 'Diagnostico de salud',
greenlens: 'Analiza sintomas como hojas amarillas, manchas o tallos blandos.',
alternative: 'Suele estar ausente o separado de la identificacion.',
},
],
},
greenLensIf: [
'Quieres identificar una planta y saber que hacer despues.',
'Tienes plantas de interior, jardin o suculentas y necesitas una guia sencilla.',
'Quieres guardar tus plantas con notas y recordatorios.',
],
notBestIf: [
'Necesitas identificacion botanica profesional para especies raras.',
'Solo quieres una busqueda ocasional sin seguimiento de cuidado.',
],
faqs: [
{
question: '¿GreenLens identifica plantas desde fotos antiguas?',
answer: 'Si. Puedes usar una foto de la galeria o tomar una nueva imagen desde la app.',
},
{
question: '¿GreenLens tambien ayuda con el cuidado?',
answer:
'Si. Cada identificacion se conecta con recomendaciones de riego, luz, fertilizacion y seguimiento.',
},
{
question: '¿Funciona para plantas de interior?',
answer:
'Si. GreenLens cubre plantas comunes de interior, plantas de jardin, flores y suculentas.',
},
],
relatedLinks: [
{
href: '/es/escaner-de-plantas',
label: 'Escaner de plantas',
description: 'Escanea hojas, flores o plantas completas desde el movil.',
},
{
href: '/es/app-para-cuidar-plantas',
label: 'App para cuidar plantas',
description: 'Organiza riego, luz y recordatorios para cada planta.',
},
{
href: '/es/diagnosticar-enfermedades-plantas',
label: 'Diagnosticar enfermedades de plantas',
description: 'Analiza sintomas y recibe el siguiente paso mas seguro.',
},
],
},
'app-para-cuidar-plantas': {
slug: 'app-para-cuidar-plantas',
locale: 'es',
metaTitle: 'App para cuidar plantas | GreenLens',
metaDescription:
'GreenLens te ayuda a cuidar plantas con identificacion, planes de riego, recordatorios y diagnostico de problemas visibles.',
canonical: '/es/app-para-cuidar-plantas',
h1: 'App para cuidar plantas',
tagline: 'Recordatorios y cuidados basados en la planta, no solo en el calendario.',
directAnswer:
'GreenLens combina identificacion de plantas, planes de cuidado, recordatorios y diagnostico de salud para que cada planta tenga una rutina clara.',
definitionBlock:
'Una app para cuidar plantas ayuda a organizar riego, fertilizacion, luz y seguimiento. GreenLens añade IA para identificar la planta y ajustar mejor las recomendaciones.',
lastUpdated: 'Mayo 2026',
includeAppSchema: true,
featureTable: {
title: 'Que aporta GreenLens al cuidado diario',
alternativeLabel: 'Recordatorios genericos',
rows: [
{
feature: 'Riego',
greenlens: 'Recomendaciones ligadas a la especie y al contexto.',
alternative: 'Intervalos fijos sin mirar el estado de la planta.',
},
{
feature: 'Coleccion',
greenlens: 'Perfil individual por planta con notas y fotos.',
alternative: 'Lista simple de tareas sin contexto vegetal.',
},
{
feature: 'Salud',
greenlens: 'El diagnostico conecta sintomas con decisiones de cuidado.',
alternative: 'El cuidado y la salud suelen estar separados.',
},
],
},
greenLensIf: [
'Quieres organizar varias plantas en una sola app.',
'Necesitas recordatorios de riego y cuidado por planta.',
'Quieres entender si una rutina esta causando problemas.',
],
notBestIf: [
'Solo necesitas un temporizador universal.',
'Gestionas una operacion agricola o vivero profesional.',
],
faqs: [
{
question: '¿GreenLens me recuerda cuando regar?',
answer: 'Si. Puedes usar recordatorios de cuidado para organizar cada planta.',
},
{
question: '¿Sirve para principiantes?',
answer:
'Si. GreenLens esta pensado para convertir dudas comunes en pasos de cuidado claros.',
},
{
question: '¿Puedo guardar varias plantas?',
answer: 'Si. La app esta diseñada para mantener una coleccion personal de plantas.',
},
],
relatedLinks: [
{
href: '/es/identificador-de-plantas',
label: 'Identificador de plantas',
description: 'Empieza identificando la planta antes de crear su rutina.',
},
{
href: '/es/diagnosticar-enfermedades-plantas',
label: 'Diagnostico de plantas',
description: 'Cuando una planta muestra sintomas, revisa la causa probable.',
},
],
},
'diagnosticar-enfermedades-plantas': {
slug: 'diagnosticar-enfermedades-plantas',
locale: 'es',
metaTitle: 'Diagnosticar enfermedades de plantas | GreenLens',
metaDescription:
'Analiza hojas amarillas, manchas, tallos blandos y otros sintomas con GreenLens para recibir una accion concreta.',
canonical: '/es/diagnosticar-enfermedades-plantas',
h1: 'Diagnosticar enfermedades de plantas',
tagline: 'Cuando algo se ve mal, encuentra el siguiente paso correcto.',
directAnswer:
'GreenLens analiza sintomas visibles de una planta y sugiere la causa mas probable con una accion concreta, como revisar humedad, ajustar luz o detener el riego.',
definitionBlock:
'Diagnosticar enfermedades de plantas significa interpretar sintomas como manchas, hojas amarillas, marchitez o tallos blandos. GreenLens prioriza la accion mas segura antes que una lista larga de posibilidades.',
lastUpdated: 'Mayo 2026',
includeAppSchema: true,
featureTable: {
title: 'Diagnostico practico para plantas con sintomas',
alternativeLabel: 'Guias genericas',
rows: [
{
feature: 'Sintomas visibles',
greenlens: 'Analiza patrones de hojas, tallos y cambios recientes.',
alternative: 'Listas amplias sin priorizar causas.',
},
{
feature: 'Siguiente paso',
greenlens: 'Recomienda una accion clara y de bajo riesgo.',
alternative: 'Consejos generales dificiles de ordenar.',
},
{
feature: 'Prevencion de exceso de cuidado',
greenlens: 'Ayuda a evitar regar, fertilizar o mover la planta demasiado pronto.',
alternative: 'A menudo propone mas tareas sin contexto.',
},
],
},
greenLensIf: [
'Tu planta tiene hojas amarillas, manchas o tallos blandos.',
'No sabes si el problema es agua, luz, raices o plagas.',
'Quieres evitar empeorar la situacion con una accion impulsiva.',
],
notBestIf: [
'Necesitas un analisis de laboratorio.',
'Gestionas cultivos comerciales a gran escala.',
],
faqs: [
{
question: '¿GreenLens puede detectar pudricion de raices?',
answer:
'Puede identificar señales visibles asociadas, como tallos blandos, hojas amarillas y suelo demasiado humedo, y recomendar que revisar primero.',
},
{
question: '¿Por que se ponen amarillas las hojas?',
answer:
'Las causas comunes son exceso de agua, falta de luz, daño de raices o desequilibrio de nutrientes.',
},
{
question: '¿Sustituye a un experto?',
answer:
'No para casos raros o comerciales. Para problemas comunes de plantas domesticas, ayuda a decidir el siguiente paso.',
},
],
relatedLinks: [
{
href: '/es/app-para-cuidar-plantas',
label: 'App para cuidar plantas',
description: 'Crea una rutina que reduzca problemas futuros.',
},
{
href: '/es/identificador-de-plantas',
label: 'Identificador de plantas',
description: 'Identifica primero la planta para orientar mejor el cuidado.',
},
],
},
'escaner-de-plantas': {
slug: 'escaner-de-plantas',
locale: 'es',
metaTitle: 'Escaner de plantas | GreenLens',
metaDescription:
'Escanea plantas con tu movil y recibe nombre, perfil, cuidado y diagnostico con GreenLens.',
canonical: '/es/escaner-de-plantas',
h1: 'Escaner de plantas',
tagline: 'De una foto a una decision de cuidado mas clara.',
directAnswer:
'El escaner de GreenLens identifica plantas desde una foto y conecta el resultado con recomendaciones de cuidado y salud.',
definitionBlock:
'Un escaner de plantas usa la camara del movil para reconocer hojas, flores o forma de crecimiento. GreenLens convierte ese escaneo en una ficha util para cuidar la planta.',
lastUpdated: 'Mayo 2026',
includeAppSchema: true,
featureTable: {
title: 'Que hace el escaner de GreenLens',
alternativeLabel: 'Busqueda por imagen',
rows: [
{
feature: 'Camara o galeria',
greenlens: 'Permite usar fotos nuevas o existentes.',
alternative: 'Puede limitarse a resultados de busqueda.',
},
{
feature: 'Resultado accionable',
greenlens: 'Incluye cuidado, recordatorios y revision de salud.',
alternative: 'Suele terminar en el nombre de la planta.',
},
{
feature: 'Coleccion',
greenlens: 'Guarda plantas escaneadas para seguimiento.',
alternative: 'No mantiene una coleccion de cuidado.',
},
],
},
greenLensIf: [
'Quieres escanear plantas rapidamente desde el movil.',
'Quieres guardar los resultados para cuidarlas despues.',
'Buscas algo mas practico que una busqueda web.',
],
notBestIf: [
'Solo quieres comparar imagenes en la web.',
'Necesitas identificacion cientifica comunitaria para especies raras.',
],
faqs: [
{
question: '¿Puedo escanear flores?',
answer: 'Si. GreenLens puede usar hojas, flores o fotos completas de la planta.',
},
{
question: '¿Necesito internet?',
answer: 'Si, los escaneos y diagnosticos requieren conexion.',
},
{
question: '¿El resultado se puede guardar?',
answer: 'Si, puedes guardar plantas en tu coleccion para seguimiento.',
},
],
relatedLinks: [
{
href: '/es/identificador-de-plantas',
label: 'Identificador de plantas',
description: 'Identifica la especie y recibe informacion de cuidado.',
},
{
href: '/es/app-para-cuidar-plantas',
label: 'App para cuidar plantas',
description: 'Convierte cada escaneo en una rutina de cuidado.',
},
],
},
'comparar-google-lens': {
slug: 'comparar-google-lens',
locale: 'es',
metaTitle: 'GreenLens vs Google Lens para plantas | Comparacion',
metaDescription:
'Google Lens identifica el nombre. GreenLens tambien ofrece cuidado, diagnostico y recordatorios. Compara cual conviene para plantas.',
canonical: '/es/comparar/google-lens',
h1: 'GreenLens vs Google Lens para plantas',
tagline: 'Google Lens encuentra el nombre. GreenLens ayuda con lo que viene despues.',
directAnswer:
'Google Lens es util para reconocer una planta rapidamente. GreenLens esta diseñado para propietarios de plantas: identifica, crea un plan de cuidado, guarda la planta y ayuda con sintomas.',
definitionBlock:
'La diferencia principal es el objetivo. Google Lens es busqueda visual general; GreenLens es una app especializada en identificacion, cuidado y diagnostico de plantas.',
lastUpdated: 'Mayo 2026',
includeAppSchema: true,
featureTable: {
title: 'GreenLens frente a Google Lens',
alternativeLabel: 'Google Lens',
rows: [
{
feature: 'Identificacion',
greenlens: 'Resultado dentro de una app especializada en plantas.',
alternative: 'Busqueda visual general con enlaces a resultados web.',
},
{
feature: 'Cuidado',
greenlens: 'Plan, riego, luz y recordatorios despues del escaneo.',
alternative: 'No incluye rutina de cuidado.',
},
{
feature: 'Diagnostico',
greenlens: 'Revisa sintomas y sugiere el siguiente paso.',
alternative: 'No esta diseñado para diagnosticar problemas de salud vegetal.',
},
],
},
greenLensIf: [
'Quieres saber como cuidar la planta despues de identificarla.',
'Tu planta muestra sintomas y necesitas una accion concreta.',
'Quieres una coleccion y recordatorios en la misma app.',
],
notBestIf: [
'Solo necesitas una busqueda visual rapida y gratuita.',
'No quieres instalar una app especializada.',
],
faqs: [
{
question: '¿Google Lens basta para identificar plantas?',
answer:
'Para obtener un nombre rapido puede bastar. Para cuidado, diagnostico y seguimiento, GreenLens cubre mas del flujo.',
},
{
question: '¿GreenLens reemplaza Google Lens?',
answer:
'No como herramienta general. GreenLens es mejor cuando el objetivo es cuidar plantas, no buscar cualquier objeto.',
},
{
question: '¿Cual elegir para hojas amarillas?',
answer:
'GreenLens, porque analiza sintomas y recomienda el siguiente paso en vez de solo buscar imagenes similares.',
},
],
relatedLinks: [
{
href: '/es/identificador-de-plantas',
label: 'Identificador de plantas',
description: 'Identifica plantas y continua con su cuidado.',
},
{
href: '/es/diagnosticar-enfermedades-plantas',
label: 'Diagnosticar enfermedades',
description: 'Entiende sintomas y acciones de rescate.',
},
],
},
}
export const spanishSeoPageSlugs = Object.keys(spanishSeoPageProfiles)
export function getSpanishSeoPageBySlug(slug: string): SeoPageProfile | undefined {
return spanishSeoPageProfiles[slug]
}

View File

@@ -6,6 +6,30 @@ const nextConfig: NextConfig = {
turbopack: {
root: path.join(__dirname),
},
async redirects() {
const germanSeoRedirects = [
'/pflanzen-erkennen-kostenlos',
'/pflanzen-erkennen-app',
'/pflanzen-bestimmen',
'/blumen-scanner',
'/zimmerpflanzen-bestimmen',
'/pflanzen-pflege-app',
'/pflanzen-krankheiten-erkennen',
].map((slug) => ({
source: `/de${slug}`,
destination: slug,
permanent: true,
}))
return [
{
source: '/de',
destination: '/',
permanent: true,
},
...germanSeoRedirects,
]
},
};
export default nextConfig;

View File

@@ -3,13 +3,34 @@ import { NextResponse } from 'next/server'
const APEX_HOST = 'greenlenspro.com'
const WWW_HOST = `www.${APEX_HOST}`
const ENGLISH_PATHS = new Set([
'/best-plant-identification-app',
'/plant-identifier-app',
'/plant-scanner',
'/flower-scanner',
'/houseplant-identifier',
'/succulent-identifier',
'/identify-plant-photo',
'/plant-disease-identifier',
'/plant-health-app',
'/plant-care-app',
])
function getLangForPath(pathname: string) {
if (pathname === '/es' || pathname.startsWith('/es/')) return 'es'
if (pathname === '/vs/google-lens') return 'de'
if (ENGLISH_PATHS.has(pathname) || pathname.startsWith('/vs/')) return 'en'
return 'de'
}
export function proxy(request: NextRequest) {
const host = request.headers.get('host')
const forwardedProto = request.headers.get('x-forwarded-proto')
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-greenlens-lang', getLangForPath(request.nextUrl.pathname))
if (!host || (host !== APEX_HOST && host !== WWW_HOST)) {
return NextResponse.next()
return NextResponse.next({ request: { headers: requestHeaders } })
}
const url = request.nextUrl.clone()
@@ -25,7 +46,9 @@ export function proxy(request: NextRequest) {
shouldRedirect = true
}
return shouldRedirect ? NextResponse.redirect(url, 308) : NextResponse.next()
return shouldRedirect
? NextResponse.redirect(url, 308)
: NextResponse.next({ request: { headers: requestHeaders } })
}
export const config = {

View File

@@ -0,0 +1,233 @@
# GreenLens — Weekly B2B Lead Report
Week of April 20, 2026 | Markets: US, UK, Australia, Canada
## Summary
| Metric | Value |
|--------|-------|
| Companies researched | ~30 |
| Top 10 leads selected | 10 |
| Apollo contacts created | 10 |
| Apollo label applied | GreenLns-B2B-Leads |
| Apollo phone numbers auto-enriched | 7 of 10 |
| Cold email sequences written | 5 (15 emails total) |
> Note: Apollo paid plan (Basic+) required to unlock people/company search and email enrichment. Contacts were created manually via Apollo API.
---
## Top 10 Leads
| # | Company | Country | Type | Est. Employees | Contact | Title | Phone (Apollo) |
|---|---------|---------|------|---------------|---------|-------|----------------|
| 1 | Flora Grubb Gardens | 🇺🇸 US | Garden center / plant shop | ~15 | Flora Grubb | Co-Founder & Owner | — |
| 2 | Pistils Nursery | 🇺🇸 US | Urban nursery | ~12 | Lisa Muddiman | Co-Owner | +1 503-288-4889 |
| 3 | Swansons Nursery | 🇺🇸 US | Independent nursery | ~45 | Nick Hage | Owner | +1 206-782-2543 |
| 4 | The Plant Society | 🇦🇺 AU | Boutique plant shop | ~12 | Jason Chongue | Co-Founder & Creative Director | +61 439 282 409 |
| 5 | Petersham Nurseries | 🇬🇧 UK | Destination nursery & restaurant | ~80 | Gael Boglione | Founder & Director | +44 20 8940 5230 |
| 6 | Leaf Supply | 🇦🇺 AU | Plant shop / media brand | ~8 | Lauren Camilleri | Co-Founder | — |
| 7 | The Plant Runner | 🇦🇺 AU | Plant shop & care products | ~10 | Ian Drummond | Founder | — |
| 8 | Clifton Nurseries | 🇬🇧 UK | Independent nursery (est. 1851) | ~30 | — (needs research) | — | +44 20 7289 6851 |
| 9 | GardenWorks | 🇨🇦 CA | Garden center chain (6 locations) | ~120 | — (needs research) | — | +1 604-299-0621 |
| 10 | Chelsea Physic Garden | 🇬🇧 UK | Botanical garden (est. 1673) | ~35 | Sue Minter* | Chief Executive | +44 20 7352 5646 |
*Verify Sue Minter's current role — may have moved to consultancy.
---
## Opportunity Scoring — Top 5 Selected for Sequences
| Lead | Key Fit Signal | Primary GreenLens Value Prop |
|------|---------------|------------------------------|
| Flora Grubb Gardens | Farm-to-store model, design-literate clientele, two locations | QR tags + white-label API |
| Pistils Nursery | Education-led community, active class program, rare plant selection | In-store ID experience + staff tool |
| Swansons Nursery | 45+ staff, 100-year heritage, high-volume spring season | Staff training + seasonal onboarding |
| The Plant Society | Strong brand identity, pop-up events, two published books | QR tags + white-label API |
| Petersham Nurseries | 80 staff, destination visitor model, premium brand | Staff training + visitor experience |
---
## Cold Email Sequences
### Lead 1 — Flora Grubb | Flora Grubb Gardens | San Francisco, CA 🇺🇸
**Email 1 — Initial Outreach**
Subject: `plants customers can't name`
The farm-to-store model with Grubb & Nadler Nursery is something you don't see in retail — the provenance story is right there, and it naturally drives that "I need to know everything about this plant" feeling from your customers.
GreenLens is an AI plant ID app that turns that curiosity into a self-serve experience. A QR code on each plant tag lets customers scan, instantly identify the species, and pull up care guides — without needing to flag down your floor team for every uncommon cultivar or unusual California native.
Worth a quick chat to see if that fits the Flora Grubb experience?
---
**Email 2 — Follow-up: multi-location knowledge consistency**
Subject: `sf and la knowledge gap`
With retail locations in both SF and LA now, keeping consistent plant knowledge across two floors is a real challenge — especially for staff at the LA location who haven't spent time at the Fallbrook farm and don't have that same hands-on grounding.
GreenLens gives both teams the same identification resource in their pocket: scan any plant on the floor and get species details, care notes, and common customer questions in real time. A few multi-location nurseries use it specifically as a knowledge-levelling tool across sites where expertise naturally concentrates at the original location.
Relevant to how you're managing that as you expand?
---
**Email 3 — Follow-up: white-label brand extension**
Subject: `flora grubb branded plant id`
One last angle before I leave this with you: GreenLens offers a white-label API that lets retailers embed plant identification directly into their own website or app — under your brand, not ours.
For a business that's built a genuine identity around plant expertise and farm provenance, owning that digital layer makes sense. Your customers are clearly the type to go deep on the plants they buy, and that tool becomes part of the Flora Grubb experience rather than a third-party add-on.
Is a 20-minute call worth it?
---
### Lead 2 — Lisa Muddiman | Pistils Nursery | Portland, OR 🇺🇸
**Email 1 — Initial Outreach**
Subject: `id for your class days`
The education program at Pistils is what sets you apart from every other Portland nursery — you've built a community around plant curiosity, and that's harder to replicate than inventory.
GreenLens slots into that naturally. QR codes on your plant tags let customers self-serve species info and care guidance mid-browse, extending the education experience to the floor between classes. A few urban nurseries with active programming have used it to carry the "class feeling" into everyday visits when the schedule is quieter.
Worth a quick conversation?
---
**Email 2 — Follow-up: staff tool for rare inventory**
Subject: `your rarest plants unidentified`
Pistils carries a genuinely unusual selection — which means your team probably fields a lot of "what even is this?" questions from customers handling plants they've never encountered before.
GreenLens handles those instantly with a phone scan. Nurseries with similarly diverse inventories use it to free up their most knowledgeable staff for higher-value conversations — growing advice, plant pairings, troubleshooting — rather than repeating identification work on the floor all day.
Relevant to how you run things with your current team size?
---
**Email 3 — Follow-up: purchase conversion for unfamiliar plants**
Subject: `the hesitation before buying`
One more thought before I go quiet: customers who can't confidently name a plant they're considering often don't buy it — they hesitate, put it back, and think about it later (meaning they don't come back for it). GreenLens closes that gap in the aisle. Scan, identify, get care info, feel confident, buy.
For a nursery that specifically stocks unusual cultivars, reducing that friction matters more than at a store selling the same hostas as every garden center in the city.
Happy to share what we're seeing at comparable stores if that's useful.
---
### Lead 3 — Nick Hage | Swansons Nursery | Seattle, WA 🇺🇸
**Email 1 — Initial Outreach**
Subject: `your floor team at peak`
Swansons has one of the best-known floor teams of any independent nursery in the Pacific Northwest — that reputation for expertise is decades in the making and clearly a competitive moat.
GreenLens sits alongside that human knowledge rather than replacing it: QR codes on your plant tags let customers self-serve species info and care guides during the peak weekend rush when your team is stretched and every conversation has a queue. The expertise is there; the tool just helps route it more efficiently.
Worth a quick conversation?
---
**Email 2 — Follow-up: seasonal staff onboarding**
Subject: `spring hires on day one`
Spring staffing is a pressure point for most nurseries — you bring in seasonal team members who need to get up to speed fast on a large, rotating inventory, while your permanent staff are already at full capacity handling customers.
GreenLens works as an on-the-job training resource: new hires scan plants on the floor and instantly get species details, common names, and care requirements in context. A few nurseries with 40+ person teams have used it specifically to compress their onboarding timeline without sacrificing the knowledge quality Swansons is known for.
Relevant to how you manage the spring ramp-up?
---
**Email 3 — Follow-up: events program extension**
Subject: `your events beyond the room`
One more angle worth floating before I leave this with you: Swansons' classes and workshops are clearly a loyalty engine — well-attended and a meaningful part of how you hold the community year-round.
GreenLens could add an interactive identification layer to plant walks and hands-on workshops: participants scan plants in real time and build botanical context on the spot. A few nurseries also add branded QR codes to event handouts so attendees can identify plants they encounter at home afterward — extending the relationship past the event itself.
Happy to show you a quick demo if any of this is the kind of thing you'd find useful.
---
### Lead 4 — Jason Chongue | The Plant Society | Melbourne, VIC 🇦🇺
**Email 1 — Initial Outreach**
Subject: `id at point of curiosity`
The way The Plant Society merges retail with plant literacy — the books, styling content, the curation — there's a clear appetite from your customers to go deeper on what they're buying, not just take it home and hope for the best.
GreenLens puts an instant identification layer on that curiosity. QR codes on your plant tags let customers scan a Monstera obliqua or an unusual Hoya and immediately get species context, care requirements, and interesting botanical notes — turning the browsing moment into the kind of discovery experience your store already signals it's for.
Worth a chat to see if it fits what you're building?
---
**Email 2 — Follow-up: pop-up and event activations**
Subject: `identification at your popups`
A separate angle worth floating: your pop-ups reach a lot of people who may be encountering some of your rarer varieties for the first time, with no floor team ratio to handle the curiosity that creates at a market table.
GreenLens works offline and can be configured with branded QR codes for specific events — giving customers a take-home identification experience even after they've left the table. A few specialty plant retailers have used it as a standalone pop-up activation that drives follow-on online traffic after the event.
Relevant to what you're doing with events this season?
---
**Email 3 — Follow-up: white-label API as brand extension**
Subject: `plant id under your brand`
Last thought: GreenLens offers a white-label API that lets retailers embed plant identification directly into their own website or app — under your name, not ours.
Given the authority The Plant Society has built — two published books, a design reputation, a loyal following — owning a plant ID tool under your brand is a logical extension of the expertise you've already made public. It also makes your digital presence genuinely useful to existing customers, not just a catalogue for new ones.
Worth 20 minutes to walk through what that looks like in practice?
---
### Lead 5 — Gael Boglione | Petersham Nurseries | Richmond, London 🇬🇧
**Email 1 — Initial Outreach**
Subject: `your plant knowledge at scale`
Petersham has built something genuinely rare — a garden destination where horticultural expertise and an editorial sensibility come together in the same physical space, and visitors arrive expecting both.
GreenLens extends that knowledgeable experience beyond the staff. QR codes on plant labels in the nursery let visitors scan, identify, and get detailed care information independently — the kind of self-directed discovery that fits a destination audience who come to spend time in the space, not just pick up a pot and leave.
Worth exploring whether that makes sense for the nursery side?
---
**Email 2 — Follow-up: staff knowledge consistency at scale**
Subject: `consistent expertise across 80 staff`
With a team the size of Petersham's — and the seasonal variation that comes with it — keeping plant knowledge consistent across the floor is a real operational challenge. Not every team member carries the same horticultural depth, and customers who come specifically for the expertise notice when that's uneven.
GreenLens can function as a staff training resource: newer team members scan plants in the nursery and learn species, common names, and care notes in context, on the floor, rather than from a handbook in a back room. A few garden destinations with 50+ staff have used it specifically to reduce the knowledge gap between experienced and seasonal employees without a formal training programme.
Relevant to how you manage plant knowledge operationally?
---
**Email 3 — Follow-up: self-guided visitor experience / dwell time**
Subject: `the self-guided visitor experience`
One final thought: Petersham attracts visitors who want to linger — people engaging with the plants and the walled garden setting as an aesthetic experience, not just a shopping exercise.
A GreenLens-powered identification layer across the nursery beds and seasonal plantings gives those visitors a tool to explore independently at their own pace. Some botanical gardens and destination nurseries use it specifically to deepen the visitor's connection to the collection and increase dwell time — which in a dual nursery-restaurant setting has obvious downstream value.
Happy to share a couple of examples from comparable venues — worth a quick call?
---
## Open Items
- [ ] Upgrade Apollo to Basic+ to unlock email enrichment for all 10 contacts
- [ ] Find named contacts for Clifton Nurseries and GardenWorks via LinkedIn
- [ ] Verify Sue Minter's current role at Chelsea Physic Garden
- [ ] Personalise `[Name, GreenLens]` signatures before sending
- [ ] Load sequences into Apollo Sequences once email addresses are confirmed

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 635 KiB

View File

@@ -0,0 +1,312 @@
# GreenLens Ad Creatives
Datum: 2026-05-08
Sprache: Deutsch
Basis: GreenLens Pro als Premium-App fuer Pflanzen-Scan, Diagnose, Pflegeplan, Health Check, Erinnerungen, Sammlung und Standort-/Licht-Tipps.
## Annahmen
- Hauptziel: App-Install oder Landing-Page-Klick mit anschliessendem Pro-Upgrade.
- Zielgruppe: Zimmerpflanzen-Besitzer, Pflanzen-Anfaenger, Urban-Jungle-Fans, Nutzer mit kranken Pflanzen.
- Positionierung: Nicht nur Pflanzen bestimmen, sondern Ursache erkennen, naechsten Schritt bekommen und Pflege langfristig tracken.
- Wichtige Funnel-Regel: Nicht als dauerhaft kostenlose App positionieren. Demo-Scans nur als Einstieg, Pro ist das eigentliche Produkt.
## Creative Angles
1. Diagnose statt Raten: Symptome brauchen Kontext.
2. Rettungsplan: Foto machen, Ursache verstehen, 7-Tage-Plan bekommen.
3. Pflege-System: Scannen, tracken, erinnern lassen.
4. Standort/Licht: Der falsche Platz ist oft das Problem.
5. Premium Calm: Pflanzenpflege fuehlt sich ruhiger an, wenn der naechste Schritt klar ist.
## Meta / Instagram Feed Ads
Empfohlene Limits: Primary Text vorne stark halten, Headline ca. 40 Zeichen, Description ca. 30 Zeichen.
### Meta Ad 1 - Diagnose statt Raten
- Visual: Close-up einer gelben Monstera- oder Pothos-Blattspitze auf warmem cremefarbenem Hintergrund. Links kurze Overlays: "Gelb ist kein Befund." Darunter App-Screen mit Health Check Ergebnis.
- Primary Text: Gelbe Blaetter? GreenLens scannt deine Pflanze und zeigt dir moegliche Ursachen plus naechste Schritte.
- Headline: Pflanze scannen. Klarheit bekommen.
- Description: Diagnose mit KI
- CTA: App installieren
### Meta Ad 2 - Rettungsplan
- Visual: Split Frame. Links gestresste Pflanze, rechts GreenLens Ergebnis mit "Moegliche Ursachen", "Sofortmassnahmen", "7-Tage-Plan".
- Primary Text: Deine Pflanze sieht schlecht aus? Mach ein Foto und bekomme einen klaren Rettungsplan statt Bauchgefuehl.
- Headline: Dein Pflanzen-Rettungsplan
- Description: In Sekunden starten
- CTA: Mehr erfahren
### Meta Ad 3 - Pflege-System
- Visual: Drei App-Karten als Bento: Scan, Giesstermin, Pflanzen-Sammlung. Ruhiger Botanical-Archive Look.
- Primary Text: GreenLens ist dein ruhiges System fuer Pflanzenpflege: scannen, giessen, Health Checks und Wachstum tracken.
- Headline: Scan it. Track it. Grow it.
- Description: Pflege in einer App
- CTA: App installieren
### Meta Ad 4 - Standort-Check
- Visual: Pflanze am Fenster, Lichtstrahlen, dezenter Light-Meter/Standort-Check Screen.
- Primary Text: Manchmal ist nicht Wasser das Problem. GreenLens hilft dir, Licht, Standort und Pflege besser einzuordnen.
- Headline: Steht deine Pflanze falsch?
- Description: Standort besser pruefen
- CTA: Mehr erfahren
### Meta Ad 5 - Ueberwaesserung
- Visual: Droopende Pflanze neben Giesskanne. Text: "Mehr Wasser ist nicht immer Hilfe."
- Primary Text: Ueberwaesserung sieht oft aus wie Durst. GreenLens hilft dir, erst zu pruefen und dann zu handeln.
- Headline: Nicht sofort giessen
- Description: Erst Ursache finden
- CTA: App installieren
### Meta Ad 6 - Premium App Store
- Visual: App-Store-artiger Hero mit echtem Produktvideo im Phone Frame, Botanical-Archive Typografie, GreenLens Logo.
- Primary Text: Was waere, wenn jede Pflanze eine Anleitung haette? Oeffne GreenLens, scanne ein Blatt und bekomme den naechsten Schritt.
- Headline: Jede Pflanze mit Anleitung
- Description: GreenLens Pro
- CTA: App installieren
## TikTok / Reels Video Ads
Empfohlene Laenge: 12-20 Sekunden. Format: 9:16.
### TikTok Ad 1 - "Gelb ist kein Befund"
- Hook Text: Gelbe Blaetter sind nicht die Diagnose.
- Szene 1: Gelbes Blatt in Nahaufnahme, schnelle Textblende: "Nicht sofort giessen."
- Szene 2: App oeffnet Scanner, Blatt wird fotografiert.
- Szene 3: Ergebnis: moegliche Ursachen, Sofortmassnahmen, 7-Tage-Plan.
- VO: "Gelbe Blaetter koennen Wasser, Licht, Wurzeln oder Stress bedeuten. GreenLens hilft dir, erst die Ursache zu finden."
- CTA Text: Scanne deine Pflanze mit GreenLens.
- Ad Text: Gelbe Blaetter? Erst Ursache finden, dann handeln.
### TikTok Ad 2 - "Drooping Trap"
- Hook Text: Drooping heisst nicht automatisch Durst.
- Szene 1: Traurige Pflanze, Hand greift zur Giesskanne.
- Szene 2: Freeze Frame: "Stopp."
- Szene 3: GreenLens Scan und Checkliste: Licht, Wasser, Erde, Schädlinge.
- VO: "Viele Pflanzen sehen durstig aus, obwohl die Wurzeln gestresst sind. Scan sie, bevor du mehr Wasser gibst."
- CTA Text: Nicht raten. GreenLens nutzen.
- Ad Text: Drooping? GreenLens prueft mehr als nur Wasser.
### TikTok Ad 3 - "Plant ER"
- Hook Text: Deine Pflanze braucht Triage, keine Panik.
- Szene 1: Drei schnelle Symptome: gelb, braun, haengend.
- Szene 2: App erkennt Pflanze.
- Szene 3: Health Score, Ursachen, Aktionen jetzt.
- VO: "Wenn eine Pflanze kippt, brauchst du Reihenfolge. GreenLens macht aus Panik einen Plan."
- CTA Text: Starte deinen Health Check.
- Ad Text: Pflanzen-Health-Check direkt aus einem Foto.
### TikTok Ad 4 - "Every Plant Instructions"
- Hook Text: Was, wenn jede Pflanze eine Anleitung haette?
- Szene 1: Ruhiger Pflanzen-Hero.
- Szene 2: Scan-Moment mit App.
- Szene 3: Pflegeplan, Giesserinnerung, Sammlung.
- VO: "Oeffne GreenLens. Scanne ein Blatt. Bekomme Name, Pflegeplan und den naechsten Schritt."
- CTA Text: Scan it. Track it. Grow it.
- Ad Text: GreenLens macht Pflanzenpflege klarer.
### TikTok Ad 5 - "Falscher Standort"
- Hook Text: Vielleicht ist nicht Wasser das Problem.
- Szene 1: Pflanze in dunkler Ecke.
- Szene 2: Licht am Fenster, Standort-Check Visual.
- Szene 3: GreenLens zeigt Pflege- und Licht-Hinweise.
- VO: "Viele Pflanzen werden am falschen Platz gepflegt. GreenLens hilft dir, Standort und Pflege zusammen zu betrachten."
- CTA Text: Pruefe den Standort mit GreenLens.
- Ad Text: Wasser ist nicht immer die Antwort.
### TikTok Ad 6 - "Beginner Mistake"
- Hook Text: Der groesste Anfaengerfehler: zu viel tun.
- Szene 1: Wasser, Duenger, Umtopfen, alles schnell hintereinander.
- Szene 2: Text: "Mehr Pflege kann Stress machen."
- Szene 3: GreenLens Health Check und ruhiger Pflegeplan.
- VO: "Wenn du nicht weisst, was los ist, wird jede Massnahme zum Risiko. GreenLens zeigt dir den naechsten sinnvollen Schritt."
- CTA Text: Erst scannen. Dann handeln.
- Ad Text: Gute Pflanzenpflege beginnt mit Klarheit.
## Google Ads Responsive Search Ads
### Headlines
1. GreenLens Pflanzen App
2. Pflanze per Foto erkennen
3. Pflanzenkrankheit erkennen
4. Gelbe Blaetter? Scannen
5. Pflanzenpflege mit KI
6. Health Check fuer Pflanzen
7. Pflegeplan in Sekunden
8. Nie wieder Giessen raten
9. Scan it. Track it. Grow it.
10. Pflanze krank? Foto machen
11. Standort & Licht pruefen
12. Dein Pflanzen-Rettungsplan
13. Zimmerpflanzen bestimmen
14. KI Scan fuer Pflanzen
15. GreenLens Pro starten
### Descriptions
1. Scanne deine Pflanze und erhalte Name, Pflegeplan und naechste Schritte.
2. GreenLens hilft bei gelben Blaettern, Schädlingen, Licht und Pflegefehlern.
3. Tracke Giessen, Wachstum, Notizen und Health Checks in einer ruhigen App.
4. Starte GreenLens Pro und mach aus Pflanzenpflege einen klaren Plan.
### Display Paths
- pflanzen / scanner
- health / check
- pflege / plan
## Google Ad Groups
### Ad Group: Pflanzen Bestimmen
- Keywords: pflanzen bestimmen app, pflanze per foto erkennen, pflanzen scanner, zimmerpflanzen bestimmen, plant identifier app
- Best headlines: "Pflanze per Foto erkennen", "GreenLens Pflanzen App", "Zimmerpflanzen bestimmen"
- Best descriptions: 1, 3
### Ad Group: Pflanzen Krankheit
- Keywords: pflanze krank was tun, gelbe blaetter pflanze, pflanzenkrankheiten erkennen, plant disease identifier, pflanze haengt
- Best headlines: "Pflanzenkrankheit erkennen", "Gelbe Blaetter? Scannen", "Health Check fuer Pflanzen"
- Best descriptions: 2, 4
### Ad Group: Pflege App
- Keywords: pflanzenpflege app, giess erinnerung pflanzen, pflanzen pflegeplan, indoor plant care app
- Best headlines: "Pflanzenpflege mit KI", "Pflegeplan in Sekunden", "Nie wieder Giessen raten"
- Best descriptions: 1, 3
## Static Image Concepts
### Concept A - Symptom Is Not Diagnosis
- Format: 1080x1350 und 1080x1920
- Main Text: "Gelbe Blaetter sind nur ein Signal."
- Subtext: "GreenLens findet moegliche Ursachen und naechste Schritte."
- Visual: Premium Botanical Archive. Ein gelbes Blatt als Herbar-Beleg, daneben ein klarer App-Health-Check Screen. Creme-Papier, tiefe Gruentoene, wenig UI, hohe Lesbarkeit.
- CTA: "Jetzt scannen"
### Concept B - 7-Tage-Rettungsplan
- Format: 1080x1350 und 1080x1920
- Main Text: "Aus Pflanzen-Panik wird ein Plan."
- Subtext: "Foto machen. Ursache verstehen. 7 Tage handeln."
- Visual: Vorher/nachher Komposition mit App-Karten: Ursachen, Sofortmassnahmen, 7-Tage-Plan.
- CTA: "Health Check starten"
### Concept C - Light & Location
- Format: 1080x1350 und 1080x1920
- Main Text: "Vielleicht steht sie nur falsch."
- Subtext: "Pruefe Licht, Standort und Pflege zusammen."
- Visual: Pflanze am Fenster, Lichtverlauf, dezente Standort-Analyse im Phone Frame.
- CTA: "Standort pruefen"
### Concept D - Calm Plant OS
- Format: 1080x1350 und 1080x1920
- Main Text: "Eine App fuer deine Pflanzen."
- Subtext: "Scan. Pflegeplan. Erinnerungen. Health Check."
- Visual: App-Bento mit Scan, Timeline, Collection und Health Score.
- CTA: "GreenLens Pro starten"
## UGC Creator Briefs
### UGC 1 - Beginner Rescue
- Creator: Pflanzen-Anfaenger mit echter gestresster Pflanze.
- Opening line: "Ich dachte, meine Pflanze braucht einfach mehr Wasser."
- Beats: Symptom zeigen, falsche Annahme nennen, GreenLens Scan zeigen, Ergebnis/Plan zeigen, erster Schritt umsetzen.
- Must say: "Ich pruefe jetzt erst die Ursache, bevor ich irgendwas mache."
### UGC 2 - Plant Parent Routine
- Creator: Urban-Jungle / Home Decor.
- Opening line: "Das ist meine 2-Minuten-Routine fuer alle Pflanzen, die komisch aussehen."
- Beats: Foto, Scan, Health Check, Giesstermin, Notiz/Growth Photo.
- Must say: "GreenLens ist fuer mich nicht nur Scanner, sondern mein Pflege-System."
### UGC 3 - Light Mistake
- Creator: Pflanzenpflege Account.
- Opening line: "Diese Pflanze war nicht durstig. Sie stand einfach falsch."
- Beats: dunkler Standort, App-Check, Umstellen, Pflegeplan.
- Must say: "Wasser ist nicht immer die Antwort."
## Testing Matrix
Prioritaet 1:
- Angle: Diagnose statt Raten
- Plattform: Meta + TikTok
- KPI: Install CVR und Trial/Pro-Start
- Creatives: Meta Ad 1, TikTok Ad 1, Static Concept A
Prioritaet 2:
- Angle: Rettungsplan
- Plattform: Meta + Google Search
- KPI: Landing-Page CVR und Paywall-View-to-Purchase
- Creatives: Meta Ad 2, TikTok Ad 3, Static Concept B
Prioritaet 3:
- Angle: Pflege-System
- Plattform: Meta Retargeting
- KPI: Install-to-Onboarding-Complete
- Creatives: Meta Ad 3, TikTok Ad 4, Static Concept D
## Compliance / Copy Guardrails
- Keine garantierte Heilung versprechen.
- Nicht behaupten, Krankheiten "sicher" zu erkennen; besser: "moegliche Ursachen", "Health Check", "naechste Schritte".
- Nicht dauerhaft kostenlos positionieren.
- Keine medizinisch anmutende Sicherheitssprache wie "100% Diagnose" oder "rettet jede Pflanze".
- "In Sekunden" nur fuer Nutzererlebnis verwenden, nicht als technische Garantie fuer jedes Netzwerk.
## Zeichenlimit-Check
Maschinell geprueft am 2026-05-08. Ergebnis: 31 / 31 Texte innerhalb der Limits.
| Asset | Zeichen / Limit |
| --- | ---: |
| Meta H1 | 35 / 40 |
| Meta H2 | 26 / 40 |
| Meta H3 | 27 / 40 |
| Meta H4 | 27 / 40 |
| Meta H5 | 20 / 40 |
| Meta H6 | 26 / 40 |
| TikTok 1 | 50 / 80 |
| TikTok 2 | 47 / 80 |
| TikTok 3 | 44 / 80 |
| TikTok 4 | 38 / 80 |
| TikTok 5 | 35 / 80 |
| TikTok 6 | 41 / 80 |
| RSA H1 | 22 / 30 |
| RSA H2 | 25 / 30 |
| RSA H3 | 26 / 30 |
| RSA H4 | 23 / 30 |
| RSA H5 | 21 / 30 |
| RSA H6 | 26 / 30 |
| RSA H7 | 22 / 30 |
| RSA H8 | 24 / 30 |
| RSA H9 | 27 / 30 |
| RSA H10 | 26 / 30 |
| RSA H11 | 24 / 30 |
| RSA H12 | 26 / 30 |
| RSA H13 | 24 / 30 |
| RSA H14 | 21 / 30 |
| RSA H15 | 21 / 30 |
| RSA D1 | 72 / 90 |
| RSA D2 | 76 / 90 |
| RSA D3 | 73 / 90 |
| RSA D4 | 67 / 90 |

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Some files were not shown because too many files have changed in this diff Show More