diff --git a/5blogpostseo.md b/5blogpostseo.md
new file mode 100644
index 0000000..12b6ddf
--- /dev/null
+++ b/5blogpostseo.md
@@ -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** ��� 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?
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
index bbd74bf..1cb7d55 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/TODOS.md b/TODOS.md
new file mode 100644
index 0000000..78db740
--- /dev/null
+++ b/TODOS.md
@@ -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
diff --git a/__tests__/app/lexiconCategoryFilter.test.tsx b/__tests__/app/lexiconCategoryFilter.test.tsx
new file mode 100644
index 0000000..fa5a8bf
--- /dev/null
+++ b/__tests__/app/lexiconCategoryFilter.test.tsx
@@ -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 }) => {children},
+ 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();
+
+ await waitFor(() => {
+ expect(mockSearchPlants).toHaveBeenCalledWith('', 'de', {
+ category: 'easy',
+ limit: 500,
+ });
+ });
+
+ expect(getByPlaceholderText('Lexikon durchsuchen...').props.value).toBe('');
+ });
+});
diff --git a/__tests__/app/searchCategoryNavigation.test.tsx b/__tests__/app/searchCategoryNavigation.test.tsx
new file mode 100644
index 0000000..309b5e4
--- /dev/null
+++ b/__tests__/app/searchCategoryNavigation.test.tsx
@@ -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 }) => {children},
+ };
+});
+
+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();
+
+ fireEvent.press(getByText('Pflegeleicht'));
+
+ expect(mockPush).toHaveBeenCalledWith({
+ pathname: '/lexicon',
+ params: {
+ categoryId: 'easy',
+ },
+ });
+ });
+});
diff --git a/__tests__/services/mockBackendService.test.ts b/__tests__/services/mockBackendService.test.ts
index 2b798be..60e4225 100644
--- a/__tests__/services/mockBackendService.test.ts
+++ b/__tests__/services/mockBackendService.test.ts
@@ -68,77 +68,94 @@ 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: 'topup-order-1',
+ 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',
productId: 'topup_small',
});
let lastScan = await runScan(userId, 'scan-order-0');
- expect(lastScan.billing.credits.usedThisCycle).toBe(1);
- expect(lastScan.billing.credits.topupBalance).toBe(25);
-
- for (let i = 1; i <= 15; i += 1) {
- lastScan = await runScan(userId, `scan-order-${i}`);
- }
-
- expect(lastScan.billing.credits.usedThisCycle).toBe(15);
- expect(lastScan.billing.credits.topupBalance).toBe(24);
- });
-
- it('can deplete all available credits via webhook simulation', async () => {
- const userId = 'test-user-deplete-credits';
- await mockBackendService.simulatePurchase({
- userId,
- idempotencyKey: 'topup-deplete-1',
- productId: 'topup_small',
- });
-
- const response = await mockBackendService.simulateWebhook({
- userId,
- idempotencyKey: 'webhook-deplete-1',
- event: 'credits_depleted',
- });
-
- expect(response.billing.credits.available).toBe(0);
- expect(response.billing.credits.topupBalance).toBe(0);
- expect(response.billing.credits.usedThisCycle).toBe(response.billing.credits.monthlyAllowance);
- });
-
+ expect(lastScan.billing.credits.usedThisCycle).toBe(1);
+ expect(lastScan.billing.credits.topupBalance).toBe(30);
+
+ 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;
+ }
+
+ 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 () => {
+ const userId = 'test-user-deplete-credits';
+ await mockBackendService.simulatePurchase({
+ userId,
+ idempotencyKey: 'topup-deplete-1',
+ productId: 'topup_small',
+ });
+
+ const response = await mockBackendService.simulateWebhook({
+ userId,
+ idempotencyKey: 'webhook-deplete-1',
+ event: 'credits_depleted',
+ });
+
+ expect(response.billing.credits.available).toBe(0);
+ expect(response.billing.credits.topupBalance).toBe(0);
+ expect(response.billing.credits.usedThisCycle).toBe(response.billing.credits.monthlyAllowance);
+ });
+
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(second.billing.credits.available).toBe(first.billing.credits.available);
- });
-
- it('enforces free monthly credit limit', 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;
- }
- }
-
+
+ expect(first.creditsCharged).toBeGreaterThan(0);
+ expect(second.creditsCharged).toBe(first.creditsCharged);
+ expect(second.billing.credits.available).toBe(first.billing.credits.available);
+ });
+
+ it('blocks free users from real scans', async () => {
+ const userId = 'test-user-credit-limit';
+ let successfulScans = 0;
+ let errorCode: string | null = null;
+
+ 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 +179,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 +275,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 +304,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 +351,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);
});
});
diff --git a/app.json b/app.json
index 430e94d..62948d6 100644
--- a/app.json
+++ b/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "GreenLens",
"slug": "greenlens",
- "version": "2.2.1",
+ "version": "2.2.3",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -15,10 +15,11 @@
"assetBundlePatterns": [
"**/*"
],
- "ios": {
- "supportsTablet": true,
- "bundleIdentifier": "com.greenlens.app",
- "buildNumber": "35",
+ "ios": {
+ "supportsTablet": true,
+ "usesAppleSignIn": true,
+ "bundleIdentifier": "com.greenlens.app",
+ "buildNumber": "37",
"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": 2,
+ "versionCode": 4,
"permissions": [
"android.permission.CAMERA",
"android.permission.RECORD_AUDIO"
@@ -46,8 +47,9 @@
"plugins": [
"expo-dev-client",
"expo-router",
- "expo-camera",
- "expo-image-picker",
+ "expo-camera",
+ "expo-apple-authentication",
+ "expo-image-picker",
"expo-secure-store",
"expo-asset",
"expo-font",
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index b2332e2..07072cd 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -10,55 +10,167 @@ import {
View,
Dimensions,
} from 'react-native';
-import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
-import { useRouter } from 'expo-router';
-import { Ionicons } from '@expo/vector-icons';
-import AsyncStorage from '@react-native-async-storage/async-storage';
-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 { 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 { usePostHog } from 'posthog-react-native';
+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');
type FilterKey = 'all' | 'today' | 'week' | 'healthy' | 'dormant';
-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 }) {
- 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' },
- ];
-
- return (
-
- {t.nextStepsTitle}
-
- {checklist.map((item) => (
- {
- 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);
- }
- }}
- disabled={item.completed}
- >
-
- string[];
+ registerLayout: (key: string, layout: { x: number; y: number; width: number; height: number }) => void;
+ posthog: any;
+}) {
+ const cardRef = useRef(null);
+ const previousProgressRef = useRef(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' 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 (
+ {
+ cardRef.current?.measureInWindow((x, y, width, height) => {
+ registerLayout('onboarding_checklist', { x, y, width, height });
+ });
+ }}
+ >
+
+
+
+ {t.onboardingChecklistIntro}
+
+ {t.onboardingChecklistTitle}
+
+ {(nextItem ? t.onboardingChecklistNextLabel : t.onboardingChecklistDone).replace('{0}', nextItem?.label ?? '')}
+
+
+
+
+ {t.onboardingChecklistProgress
+ .replace('{0}', completedCount.toString())
+ .replace('{1}', checklist.length.toString())}
+
+
+
+
+
+
+
+
+
+ {checklist.map((item) => (
+ navigateToStep(item.id)}
+ disabled={item.completed}
+ >
+
+ {
return Math.ceil(remainingMs / DAY_MS);
};
-export default function HomeScreen() {
- const {
- plants,
- isLoadingPlants,
- profileImageUri,
- profileName,
- billingSummary,
- isLoadingBilling,
- language,
- t,
- isDarkMode,
- colorPalette,
- } = useApp();
- const colors = useColors(isDarkMode, colorPalette);
- const router = useRouter();
- const insets = useSafeAreaInsets();
- const [activeFilter, setActiveFilter] = useState('all');
- const { registerLayout, startTour } = useCoachMarks();
- const fabRef = useRef(null);
-
- // Tour nach Registrierung starten
- useEffect(() => {
- const checkTour = async () => {
+export default function HomeScreen() {
+ const {
+ session,
+ plants,
+ isLoadingPlants,
+ profileImageUri,
+ profileName,
+ billingSummary,
+ isLoadingBilling,
+ t,
+ isDarkMode,
+ appearanceMode,
+ colorPalette,
+ getLexiconSearchHistory,
+ } = useApp();
+ const colors = useColors(isDarkMode, colorPalette);
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+ const [activeFilter, setActiveFilter] = useState('all');
+ const [onboardingSignals, setOnboardingSignals] = useState({
+ lexiconExplored: false,
+ customizationDone: false,
+ });
+ const { registerLayout, startTour } = useCoachMarks();
+ const fabRef = useRef(null);
+ const posthog = usePostHog();
+
+ useFocusEffect(
+ React.useCallback(() => {
+ if (!session?.userId) {
+ setOnboardingSignals({
+ lexiconExplored: false,
+ customizationDone: false,
+ });
+ return;
+ }
+
+ setOnboardingSignals(OnboardingProgressService.getSignals(session.userId));
+ }, [session?.userId]),
+ );
+
+ // Tour nach Registrierung starten
+ useEffect(() => {
+ const checkTour = async () => {
const flag = await AsyncStorage.getItem('greenlens_show_tour');
if (flag !== 'true') return;
await AsyncStorage.removeItem('greenlens_show_tour');
@@ -143,17 +276,23 @@ export default function HomeScreen() {
description: t.tourSearchDesc,
tooltipSide: 'above',
},
- {
- elementKey: 'tab_profile',
- title: t.tourProfileTitle,
- description: t.tourProfileDesc,
- tooltipSide: 'above',
- },
- ]);
- }, 1000);
- };
- checkTour();
- }, []);
+ {
+ elementKey: 'tab_profile',
+ title: t.tourProfileTitle,
+ description: t.tourProfileDesc,
+ tooltipSide: 'above',
+ },
+ {
+ elementKey: 'onboarding_checklist',
+ title: t.tourChecklistTitle,
+ description: t.tourChecklistDesc,
+ tooltipSide: 'below',
+ },
+ ]);
+ }, 1000);
+ };
+ checkTour();
+ }, [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 +465,19 @@ export default function HomeScreen() {
/>
- {plants.length === 0 && (
-
- )}
+ {
- router.push({
- pathname: '/lexicon',
- params: {
- categoryId,
- categoryLabel: encodeURIComponent(categoryName),
- },
- });
- };
+ const openCategoryLexicon = (categoryId: string) => {
+ router.push({
+ pathname: '/lexicon',
+ params: {
+ categoryId,
+ },
+ });
+ };
const clearAll = () => {
setSearchQuery('');
@@ -384,9 +383,9 @@ export default function SearchScreen() {
borderColor: colors.chipBorder,
},
]}
- onPress={() => openCategoryLexicon(item.id, item.name)}
- activeOpacity={0.8}
- >
+ onPress={() => openCategoryLexicon(item.id)}
+ activeOpacity={0.8}
+ >
{item.name}
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 6407075..82a84aa 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
-import { Animated, Easing, Image, StyleSheet, Text, View } from 'react-native';
+import { Animated, AppState, Easing, Image, StyleSheet, 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';
@@ -54,13 +54,23 @@ const ensureInstallConsistency = async (): Promise => {
import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen';
function RootLayoutInner() {
- const { isDarkMode, colorPalette, signOut, session, isInitializing, isLoadingPlants, syncRevenueCatState } = 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();
+ const {
+ isDarkMode,
+ colorPalette,
+ signOut,
+ session,
+ billingSummary,
+ isInitializing,
+ isLoadingPlants,
+ isLoadingBilling,
+ syncRevenueCatState,
+ } = 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
@@ -72,42 +82,42 @@ function RootLayoutInner() {
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]);
+ 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) {
@@ -120,6 +130,20 @@ function RootLayoutInner() {
}
}, [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 () => {
const didResetSessionForFreshInstall = await ensureInstallConsistency();
@@ -130,17 +154,25 @@ function RootLayoutInner() {
})();
}, [signOut]);
- const isAppReady = installCheckDone && !isInitializing && !isLoadingPlants;
+ const isAppReady = installCheckDone && !isInitializing && !isLoadingPlants;
+ const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro'
+ && billingSummary?.entitlement?.status === 'active';
+ const isAllowedWithoutSession = pathname.includes('onboarding')
+ || pathname.includes('auth/')
+ || pathname.includes('scanner')
+ || pathname.includes('profile/billing');
+ const isAllowedWithoutEntitlement = pathname.includes('auth/')
+ || pathname.includes('scanner')
+ || 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) {
- content = ;
- } else {
+ if (!session) {
+ // Only redirect if we are not already on an auth-related page or the scanner
+ if (!isAllowedWithoutSession) {
+ content = ;
+ } else {
content = (
-
-
-
+
+
+
+
+
+
+
- );
- }
- } else {
+ );
+ }
+ } else if (!hasActiveEntitlement && !isLoadingBilling && !isAllowedWithoutEntitlement) {
+ content = ;
+ } else {
content = (
<>
-
-
-
+
+
+
+
+
+
+
+
diff --git a/app/auth/login.tsx b/app/auth/login.tsx
index 9661169..9f5c9f5 100644
--- a/app/auth/login.tsx
+++ b/app/auth/login.tsx
@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useEffect, useState } from 'react';
import {
View,
Text,
@@ -16,17 +16,35 @@ 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 { AuthService } from '../../services/authService';
+import * as AppleAuthentication from 'expo-apple-authentication';
+import { usePostHog } from 'posthog-react-native';
export default function LoginScreen() {
- const { isDarkMode, colorPalette, hydrateSession, t } = useApp();
- const colors = useColors(isDarkMode, colorPalette);
-
- const [email, setEmail] = useState('');
- const [password, setPassword] = useState('');
- const [showPassword, setShowPassword] = useState(false);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
+ const { isDarkMode, colorPalette, hydrateSession, t } = useApp();
+ const colors = useColors(isDarkMode, colorPalette);
+ const posthog = usePostHog();
+
+ 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(null);
+
+ useEffect(() => {
+ let mounted = true;
+ AppleAuthentication.isAvailableAsync()
+ .then((available) => {
+ if (mounted) setAppleAvailable(available);
+ })
+ .catch(() => {
+ if (mounted) setAppleAvailable(false);
+ });
+ return () => {
+ mounted = false;
+ };
+ }, []);
const handleLogin = async () => {
if (!email.trim() || !password) {
@@ -36,9 +54,9 @@ export default function LoginScreen() {
setLoading(true);
setError(null);
try {
- const session = await AuthService.login(email, password);
- await hydrateSession(session);
- router.replace('/(tabs)');
+ const session = await AuthService.login(email, password);
+ await hydrateSession(session);
+ router.replace('/profile/billing');
} catch (e: any) {
if (e.message === 'USER_NOT_FOUND') {
setError(t.errUserNotFound);
@@ -53,8 +71,53 @@ export default function LoginScreen() {
}
} finally {
setLoading(false);
- }
- };
+ }
+ };
+
+ 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);
+ posthog.capture('apple_login_succeeded', { surface: 'login' });
+ router.replace('/profile/billing');
+ } 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 (
- {/* Card */}
-
- {/* Email */}
-
+ {/* Card */}
+
+ {appleAvailable ? (
+
+ ) : null}
+
+ {appleAvailable ? (
+
+
+ {t.orDivider}
+
+
+ ) : null}
+
+ {/* Email */}
+ E-Mail
@@ -150,8 +233,8 @@ export default function LoginScreen() {
- {/* Divider */}
-
+ {/* Divider */}
+ {t.orDivider}
@@ -201,7 +284,7 @@ const styles = StyleSheet.create({
fontSize: 15,
fontWeight: '400',
},
- card: {
+ card: {
borderRadius: 20,
borderWidth: 1,
padding: 24,
@@ -210,7 +293,18 @@ const styles = StyleSheet.create({
shadowOpacity: 1,
shadowRadius: 12,
elevation: 4,
- },
+ },
+ appleButton: {
+ width: '100%',
+ height: 50,
+ marginBottom: 2,
+ },
+ dividerRowCompact: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ marginVertical: 2,
+ },
fieldGroup: {
gap: 6,
},
diff --git a/app/auth/signup.tsx b/app/auth/signup.tsx
index e3ce556..7468889 100644
--- a/app/auth/signup.tsx
+++ b/app/auth/signup.tsx
@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useEffect, useState } from 'react';
import {
View,
Text,
@@ -16,22 +16,40 @@ 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 { AuthService } from '../../services/authService';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import * as AppleAuthentication from 'expo-apple-authentication';
+import { usePostHog } from 'posthog-react-native';
export default function SignupScreen() {
- const { isDarkMode, colorPalette, hydrateSession, getPendingPlant, t } = useApp();
- const colors = useColors(isDarkMode, colorPalette);
- const pendingPlant = getPendingPlant();
+ const { isDarkMode, colorPalette, hydrateSession, getPendingPlant, t } = useApp();
+ const colors = useColors(isDarkMode, colorPalette);
+ const posthog = usePostHog();
+ const pendingPlant = getPendingPlant();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
- const [passwordConfirm, setPasswordConfirm] = useState('');
- const [showPassword, setShowPassword] = useState(false);
- const [showPasswordConfirm, setShowPasswordConfirm] = useState(false);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
+ 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(null);
+
+ useEffect(() => {
+ let mounted = true;
+ AppleAuthentication.isAvailableAsync()
+ .then((available) => {
+ if (mounted) setAppleAvailable(available);
+ })
+ .catch(() => {
+ if (mounted) setAppleAvailable(false);
+ });
+ return () => {
+ mounted = false;
+ };
+ }, []);
const validate = (): string | null => {
if (!name.trim()) return t.errNameRequired;
@@ -50,11 +68,11 @@ export default function SignupScreen() {
setLoading(true);
setError(null);
try {
- const session = await AuthService.signUp(email, name, password);
- await hydrateSession(session);
- // Flag setzen: Tour beim nächsten App-Öffnen anzeigen
- await AsyncStorage.setItem('greenlens_show_tour', 'true');
- router.replace('/(tabs)');
+ const session = await AuthService.signUp(email, name, password);
+ await hydrateSession(session);
+ // Flag setzen: Tour beim nächsten App-Öffnen anzeigen
+ await AsyncStorage.setItem('greenlens_show_tour', 'true');
+ router.replace('/profile/billing');
} catch (e: any) {
if (e.message === 'EMAIL_TAKEN') {
setError(t.errEmailTaken);
@@ -71,8 +89,54 @@ export default function SignupScreen() {
}
} finally {
setLoading(false);
- }
- };
+ }
+ };
+
+ 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('/profile/billing');
+ } 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 (
)}
- {/* Card */}
-
- {/* Name */}
-
+ {/* Card */}
+
+ {appleAvailable ? (
+
+ ) : null}
+
+ {appleAvailable ? (
+
+
+ {t.orDivider}
+
+
+ ) : null}
+
+ {/* Name */}
+ Name
@@ -317,17 +401,36 @@ const styles = StyleSheet.create({
fontSize: 15,
fontWeight: '400',
},
- card: {
- borderRadius: 20,
- borderWidth: 1,
+ card: {
+ borderRadius: 20,
+ borderWidth: 1,
padding: 24,
gap: 14,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 1,
- shadowRadius: 12,
- elevation: 4,
- },
- fieldGroup: {
+ 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,
},
label: {
diff --git a/app/lexicon.tsx b/app/lexicon.tsx
index 7aa1c36..1b8dada 100644
--- a/app/lexicon.tsx
+++ b/app/lexicon.tsx
@@ -11,35 +11,25 @@ import { PlantDatabaseService } from '../services/plantDatabaseService';
import { IdentificationResult } from '../types';
import { DatabaseEntry } from '../services/plantDatabaseService';
import { ResultCard } from '../components/ResultCard';
-import { ThemeBackdrop } from '../components/ThemeBackdrop';
-import { SafeImage } from '../components/SafeImage';
-import { resolveImageUri } from '../utils/imageUri';
-
-export default function LexiconScreen() {
- const { 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 [activeCategoryId, setActiveCategoryId] = useState(initialCategoryId);
+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 { 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 initialCategoryId = typeof categoryIdParam === 'string' ? categoryIdParam : null;
+ const topInsetFallback = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : 20;
+ const topInset = insets.top > 0 ? insets.top : topInsetFallback;
+
+ const [searchQuery, setSearchQuery] = useState('');
+ const [activeCategoryId, setActiveCategoryId] = useState(initialCategoryId);
const [selectedItem, setSelectedItem] = useState<(IdentificationResult & { imageUri: string }) | null>(null);
const [isAiSearching, setIsAiSearching] = useState(false);
const [aiResults, setAiResults] = useState(null);
@@ -66,20 +56,25 @@ export default function LexiconScreen() {
}
}
}
- }, [detailParam]);
+ }, [detailParam]);
+
+ React.useEffect(() => {
+ setActiveCategoryId(initialCategoryId);
+ setSearchQuery('');
+ }, [initialCategoryId]);
- React.useEffect(() => {
- setActiveCategoryId(initialCategoryId);
- setSearchQuery(initialCategoryLabel);
- }, [initialCategoryId, initialCategoryLabel]);
-
- React.useEffect(() => {
- const loadHistory = async () => {
- const history = getLexiconSearchHistory();
- setSearchHistory(history);
- };
- loadHistory();
- }, []);
+ React.useEffect(() => {
+ const loadHistory = async () => {
+ const history = getLexiconSearchHistory();
+ setSearchHistory(history);
+ };
+ loadHistory();
+ }, []);
+
+ React.useEffect(() => {
+ if (!session?.userId) return;
+ OnboardingProgressService.completeStep(session.userId, 'lexicon');
+ }, [session?.userId]);
const handleResultClose = () => {
if (openedWithDetail) {
diff --git a/app/onboarding/customize.tsx b/app/onboarding/customize.tsx
new file mode 100644
index 0000000..402c2a1
--- /dev/null
+++ b/app/onboarding/customize.tsx
@@ -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 { usePostHog } from 'posthog-react-native';
+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 = {
+ forest: ['#5fa779', '#3d7f57'],
+ ocean: ['#5a90be', '#3d6f99'],
+ sunset: ['#c98965', '#a36442'],
+ mono: ['#7b8796', '#5b6574'],
+};
+
+export default function CustomizeOnboardingScreen() {
+ const router = useRouter();
+ const posthog = usePostHog();
+ 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 (
+
+
+
+
+
+
+
+
+ {t.onboardingChecklistIntro}
+ {t.customizeOnboardingTitle}
+ {t.customizeOnboardingSubtitle}
+
+
+
+
+
+ {t.customizeOnboardingPreview}
+ {t.onboardingTagline}
+
+
+ {appearanceMode}
+
+
+ {colorPalette}
+
+
+ {language.toUpperCase()}
+
+
+
+
+
+ {t.appearanceMode}
+
+ {(['system', 'light', 'dark'] as AppearanceMode[]).map((mode) => {
+ const isActive = appearanceMode === mode;
+ const label = mode === 'system' ? t.themeSystem : mode === 'light' ? t.themeLight : t.themeDark;
+
+ return (
+ setAppearanceMode(mode)}
+ >
+
+ {label}
+
+
+ );
+ })}
+
+
+
+
+ {t.colorPalette}
+
+ {(['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 (
+ setColorPalette(palette)}
+ >
+
+ {label}
+
+ );
+ })}
+
+
+
+
+ {t.language}
+
+ {(['en', 'de', 'es'] as Language[]).map((lang) => {
+ const isActive = language === lang;
+ const label = lang === 'en' ? 'English' : lang === 'de' ? 'Deutsch' : 'Español';
+
+ return (
+ changeLanguage(lang)}
+ >
+
+ {label}
+
+
+ );
+ })}
+
+
+
+
+
+
+ {t.customizeOnboardingSkip}
+
+
+ {t.customizeOnboardingContinue}
+
+
+
+
+ );
+}
+
+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',
+ },
+});
diff --git a/app/onboarding/experience.tsx b/app/onboarding/experience.tsx
new file mode 100644
index 0000000..1c7ac0d
--- /dev/null
+++ b/app/onboarding/experience.tsx
@@ -0,0 +1,129 @@
+import React, { useMemo, useState } from 'react';
+import { 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 { usePostHog } from 'posthog-react-native';
+import { ThemeBackdrop } from '../../components/ThemeBackdrop';
+import { useColors } from '../../constants/Colors';
+import { useApp } from '../../context/AppContext';
+import { OnboardingProgressService } from '../../services/onboardingProgressService';
+
+const EXPERIENCE_OPTIONS = [
+ { id: 'beginner', icon: 'leaf-outline' as const },
+ { id: 'intermediate', icon: 'sunny-outline' as const },
+ { id: 'advanced', icon: 'flask-outline' as const },
+];
+
+export default function OnboardingExperienceScreen() {
+ const router = useRouter();
+ const posthog = usePostHog();
+ const { session, isDarkMode, colorPalette, t } = useApp();
+ const colors = useColors(isDarkMode, colorPalette);
+ const [selectedLevel, setSelectedLevel] = useState(null);
+
+ 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('/(tabs)');
+ };
+
+ return (
+
+
+
+
+
+
+
+ {t.experienceOnboardingTitle}
+ {t.experienceOnboardingSubtitle}
+
+
+
+ {EXPERIENCE_OPTIONS.map((option) => {
+ const isActive = selectedLevel === option.id;
+ return (
+ setSelectedLevel(option.id)}
+ activeOpacity={0.85}
+ >
+
+
+
+ {levelLabels[option.id as keyof typeof levelLabels]}
+ {isActive && }
+
+ );
+ })}
+
+
+
+ finish(null)}
+ >
+ {t.experienceOnboardingSkip}
+
+ finish(selectedLevel)}
+ disabled={!selectedLevel}
+ >
+
+ {t.experienceOnboardingContinue}
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1 },
+ safeArea: { flex: 1, paddingHorizontal: 20, paddingTop: 24, paddingBottom: 20 },
+ header: { alignItems: 'center', gap: 10, marginBottom: 28 },
+ headerIcon: { width: 64, height: 64, borderRadius: 32, alignItems: 'center', justifyContent: 'center' },
+ title: { fontSize: 28, fontWeight: '800', textAlign: 'center', lineHeight: 32 },
+ subtitle: { fontSize: 14, textAlign: 'center', lineHeight: 20, maxWidth: 320 },
+ options: { gap: 12, flex: 1 },
+ optionCard: {
+ minHeight: 64,
+ borderRadius: 18,
+ borderWidth: 1.5,
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 16,
+ gap: 12,
+ },
+ optionIcon: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' },
+ optionLabel: { flex: 1, fontSize: 15, fontWeight: '600' },
+ footer: { flexDirection: 'row', gap: 12, marginTop: 16 },
+ secondaryBtn: { flex: 1, height: 52, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' },
+ secondaryBtnText: { fontSize: 15, fontWeight: '600' },
+ primaryBtn: { flex: 1.2, height: 52, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
+ primaryBtnText: { fontSize: 15, fontWeight: '700' },
+});
diff --git a/app/onboarding/goal.tsx b/app/onboarding/goal.tsx
new file mode 100644
index 0000000..50ee04f
--- /dev/null
+++ b/app/onboarding/goal.tsx
@@ -0,0 +1,131 @@
+import React, { useMemo, useState } from 'react';
+import { 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 { usePostHog } from 'posthog-react-native';
+import { ThemeBackdrop } from '../../components/ThemeBackdrop';
+import { useColors } from '../../constants/Colors';
+import { useApp } from '../../context/AppContext';
+import { OnboardingProgressService } from '../../services/onboardingProgressService';
+
+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 },
+];
+
+export default function OnboardingGoalScreen() {
+ const router = useRouter();
+ const posthog = usePostHog();
+ const { session, isDarkMode, colorPalette, t } = useApp();
+ const colors = useColors(isDarkMode, colorPalette);
+ const [selectedGoal, setSelectedGoal] = useState(null);
+
+ 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 (
+
+
+
+
+
+
+
+ {t.goalOnboardingTitle}
+ {t.goalOnboardingSubtitle}
+
+
+
+ {GOAL_OPTIONS.map((option) => {
+ const isActive = selectedGoal === option.id;
+ return (
+ setSelectedGoal(option.id)}
+ activeOpacity={0.85}
+ >
+
+
+
+ {goalLabels[option.id as keyof typeof goalLabels]}
+ {isActive && }
+
+ );
+ })}
+
+
+
+ finish(null)}
+ >
+ {t.goalOnboardingSkip}
+
+ finish(selectedGoal)}
+ disabled={!selectedGoal}
+ >
+
+ {t.goalOnboardingContinue}
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1 },
+ safeArea: { flex: 1, paddingHorizontal: 20, paddingTop: 24, paddingBottom: 20 },
+ header: { alignItems: 'center', gap: 10, marginBottom: 28 },
+ headerIcon: { width: 64, height: 64, borderRadius: 32, alignItems: 'center', justifyContent: 'center' },
+ title: { fontSize: 28, fontWeight: '800', textAlign: 'center', lineHeight: 32 },
+ subtitle: { fontSize: 14, textAlign: 'center', lineHeight: 20, maxWidth: 320 },
+ options: { gap: 12, flex: 1 },
+ optionCard: {
+ minHeight: 64,
+ borderRadius: 18,
+ borderWidth: 1.5,
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 16,
+ gap: 12,
+ },
+ optionIcon: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' },
+ optionLabel: { flex: 1, fontSize: 15, fontWeight: '600' },
+ footer: { flexDirection: 'row', gap: 12, marginTop: 16 },
+ secondaryBtn: { flex: 1, height: 52, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' },
+ secondaryBtnText: { fontSize: 15, fontWeight: '600' },
+ primaryBtn: { flex: 1.2, height: 52, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
+ primaryBtnText: { fontSize: 15, fontWeight: '700' },
+});
diff --git a/app/onboarding/source.tsx b/app/onboarding/source.tsx
new file mode 100644
index 0000000..062004f
--- /dev/null
+++ b/app/onboarding/source.tsx
@@ -0,0 +1,213 @@
+import React, { useMemo, useState } from 'react';
+import { 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 { usePostHog } from 'posthog-react-native';
+import { ThemeBackdrop } from '../../components/ThemeBackdrop';
+import { useColors } from '../../constants/Colors';
+import { useApp } from '../../context/AppContext';
+import { OnboardingProgressService } from '../../services/onboardingProgressService';
+
+const SOURCE_OPTIONS = [
+ { id: 'app_store', icon: 'phone-portrait-outline' as const },
+ { id: 'instagram', icon: 'logo-instagram' as const },
+ { id: 'tiktok', icon: 'musical-notes-outline' as const },
+ { id: 'friend', icon: 'people-outline' as const },
+ { id: 'search', icon: 'search-outline' as const },
+ { id: 'other', icon: 'ellipsis-horizontal-circle-outline' as const },
+];
+
+export default function OnboardingSourceScreen() {
+ const router = useRouter();
+ const posthog = usePostHog();
+ const { session, isDarkMode, colorPalette, t } = useApp();
+ const colors = useColors(isDarkMode, colorPalette);
+ const [selectedSource, setSelectedSource] = useState(null);
+
+ 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',
+ });
+ router.replace('/onboarding/goal');
+ };
+
+ return (
+
+
+
+
+
+
+
+ {t.sourceOnboardingTitle}
+ {t.sourceOnboardingSubtitle}
+
+
+
+ {SOURCE_OPTIONS.map((option) => {
+ const isActive = selectedSource === option.id;
+ return (
+ setSelectedSource(option.id)}
+ activeOpacity={0.85}
+ >
+
+
+
+ {sourceLabels[option.id as keyof typeof sourceLabels]}
+ {isActive && }
+
+ );
+ })}
+
+
+
+ finish(null)}
+ >
+ {t.sourceOnboardingSkip}
+
+ finish(selectedSource)}
+ disabled={!selectedSource}
+ >
+
+ {t.sourceOnboardingContinue}
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ safeArea: {
+ flex: 1,
+ paddingHorizontal: 20,
+ paddingTop: 24,
+ paddingBottom: 20,
+ },
+ header: {
+ alignItems: 'center',
+ gap: 10,
+ marginBottom: 28,
+ },
+ headerIcon: {
+ width: 64,
+ height: 64,
+ borderRadius: 32,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ title: {
+ fontSize: 28,
+ fontWeight: '800',
+ textAlign: 'center',
+ lineHeight: 32,
+ },
+ subtitle: {
+ fontSize: 14,
+ textAlign: 'center',
+ lineHeight: 20,
+ maxWidth: 320,
+ },
+ options: {
+ gap: 12,
+ flex: 1,
+ },
+ optionCard: {
+ minHeight: 64,
+ borderRadius: 18,
+ borderWidth: 1.5,
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 16,
+ gap: 12,
+ },
+ optionIcon: {
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ optionLabel: {
+ flex: 1,
+ fontSize: 15,
+ fontWeight: '600',
+ },
+ footer: {
+ flexDirection: 'row',
+ gap: 12,
+ marginTop: 16,
+ },
+ secondaryBtn: {
+ flex: 1,
+ height: 52,
+ borderRadius: 16,
+ borderWidth: 1.5,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ secondaryBtnText: {
+ fontSize: 15,
+ fontWeight: '600',
+ },
+ primaryBtn: {
+ flex: 1.2,
+ height: 52,
+ borderRadius: 16,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ primaryBtnText: {
+ fontSize: 15,
+ fontWeight: '700',
+ },
+});
diff --git a/app/profile/billing.tsx b/app/profile/billing.tsx
index 63d0f22..f72da56 100644
--- a/app/profile/billing.tsx
+++ b/app/profile/billing.tsx
@@ -12,15 +12,26 @@ import Purchases, {
PurchasesStoreProduct,
} from 'react-native-purchases';
import { useApp } from '../../context/AppContext';
+import { usePostHog } from 'posthog-react-native';
import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { Language } from '../../types';
import { PurchaseProductId } from '../../services/backend/contracts';
type SubscriptionProductId = 'monthly_pro' | 'yearly_pro';
-type TopupProductId = Extract;
-type SubscriptionPackages = Partial>;
-type TopupProducts = Partial>;
+type TopupProductId = Extract;
+type SubscriptionPackages = Partial>;
+type TopupProducts = Partial>;
+
+const TOPUP_CREDITS_BY_PRODUCT: Record = {
+ topup_small: 30,
+ topup_medium: 100,
+ topup_large: 250,
+};
+
+const isTopupProductId = (productId: PurchaseProductId): productId is TopupProductId => (
+ productId === 'topup_small' || productId === 'topup_medium' || productId === 'topup_large'
+);
const isMatchingPackage = (
pkg: PurchasesPackage,
@@ -78,7 +89,22 @@ const getBillingCopy = (language: Language) => {
manageSubscription: 'Abo verwalten',
subscriptionTitle: 'Abos',
subscriptionHint: 'Wähle ein Abo und schalte stärkere KI-Scans sowie mehr Credits frei.',
- freePlanName: 'Free',
+ paywallTitle: 'Vollstaendige Diagnose freischalten',
+ paywallHint: 'Starte Pro fuer echte GPT-5.4 Scans, deinen 7-Tage-Rettungsplan und 100 Credits fuer AI-Scans und Follow-ups.',
+ startTrial: '7 Tage kostenlos testen',
+ monthlyCta: 'Monatlich starten',
+ yearlyCta: 'Jaehrlich starten',
+ yearlyTrialBadge: '7 TAGE GRATIS',
+ monthlyBadge: 'FLEXIBEL',
+ yearlySubline: 'Danach 39,99 EUR/Jahr. Jederzeit kuendbar.',
+ monthlySubline: '4,99 EUR/Monat. Ohne Jahresbindung.',
+ saveLabel: 'Bester Wert',
+ expoGoPurchaseTitle: 'Kauf nur im Dev Build',
+ expoGoPurchaseMessage: 'Expo Go kann keine Apple- oder RevenueCat-Kaufmaske anzeigen. Im Development Build oder TestFlight erscheint hier der echte 7-Tage-Trial. Fuer lokale Tests kannst du Pro simulieren.',
+ expoGoSimulate: 'Pro simulieren',
+ perYear: '/ Jahr',
+ perMonth: '/ Monat',
+ freePlanName: 'Free',
freePlanPrice: '0 EUR / Monat',
proPlanName: 'Pro',
proPlanPrice: '4,99 € / Monat',
@@ -87,17 +113,20 @@ const getBillingCopy = (language: Language) => {
proYearlyPlanPrice: '39,99 € / Jahr',
proYearlyBadgeText: 'SPAREN',
proBenefits: [
- '250 Credits jeden Monat',
+ '100 Credits für AI-Scans und Follow-ups jeden Monat',
'Pro-Scans mit GPT-5.4',
'Unbegrenzte Historie & Galerie',
'KI-Pflanzendoktor inklusive',
'Priorisierter Support'
],
topupTitle: 'Credits Aufladen',
- topupSmall: '25 Credits – 1,99 €',
- topupMedium: '120 Credits – 6,99 €',
- topupLarge: '300 Credits – 12,99 €',
- topupBestValue: 'BESTES ANGEBOT',
+ topupHint: 'Für aktive Pro-Nutzer, wenn die Monatscredits nicht reichen.',
+ topupSmall: '30 Credits – 2,99 €',
+ topupMedium: '100 Credits – 6,99 €',
+ topupLarge: '250 Credits – 12,99 €',
+ topupBestValue: 'BESTES ANGEBOT',
+ topupRequiresProTitle: 'Pro erforderlich',
+ topupRequiresProMessage: 'Top-ups sind für aktive Pro-Nutzer gedacht. Starte Pro, um zusätzliche Credits zu kaufen.',
cancelTitle: 'Schade, dass du gehst',
cancelQuestion: 'Dürfen wir fragen, warum du kündigst?',
reasonTooExpensive: 'Es ist mir zu teuer',
@@ -123,7 +152,22 @@ const getBillingCopy = (language: Language) => {
manageSubscription: 'Administrar Suscripción',
subscriptionTitle: 'Suscripciones',
subscriptionHint: 'Elige un plan y desbloquea escaneos con IA más potentes y más créditos.',
- freePlanName: 'Gratis',
+ paywallTitle: 'Desbloquear diagnostico completo',
+ paywallHint: 'Inicia Pro para escaneos reales con GPT-5.4, tu plan de rescate de 7 dias y 100 creditos para escaneos IA y seguimientos.',
+ startTrial: 'Probar 7 dias gratis',
+ monthlyCta: 'Empezar mensual',
+ yearlyCta: 'Empezar anual',
+ yearlyTrialBadge: '7 DIAS GRATIS',
+ monthlyBadge: 'FLEXIBLE',
+ yearlySubline: 'Despues 39.99 EUR/ano. Cancela cuando quieras.',
+ monthlySubline: '4.99 EUR/mes. Sin compromiso anual.',
+ saveLabel: 'Mejor valor',
+ expoGoPurchaseTitle: 'Compra solo en Dev Build',
+ expoGoPurchaseMessage: 'Expo Go no puede mostrar la compra nativa de Apple o RevenueCat. En Development Build o TestFlight aparecera el trial real de 7 dias. Para pruebas locales puedes simular Pro.',
+ expoGoSimulate: 'Simular Pro',
+ perYear: '/ ano',
+ perMonth: '/ mes',
+ freePlanName: 'Gratis',
freePlanPrice: '0 EUR / Mes',
proPlanName: 'Pro',
proPlanPrice: '4.99 EUR / Mes',
@@ -132,17 +176,20 @@ const getBillingCopy = (language: Language) => {
proYearlyPlanPrice: '39.99 EUR / Año',
proYearlyBadgeText: 'AHORRAR',
proBenefits: [
- '250 créditos cada mes',
+ '100 créditos para escaneos IA y seguimientos cada mes',
'Escaneos Pro con GPT-5.4',
'Historial y galería ilimitados',
'Doctor de plantas de IA incluido',
'Soporte prioritario'
],
topupTitle: 'Recargar Créditos',
- topupSmall: '25 Créditos – 1,99 €',
- topupMedium: '120 Créditos – 6,99 €',
- topupLarge: '300 Créditos – 12,99 €',
- topupBestValue: 'MEJOR OFERTA',
+ topupHint: 'Para usuarios Pro activos cuando los créditos mensuales no alcanzan.',
+ topupSmall: '30 Créditos – 2,99 €',
+ topupMedium: '100 Créditos – 6,99 €',
+ topupLarge: '250 Créditos – 12,99 €',
+ topupBestValue: 'MEJOR OFERTA',
+ topupRequiresProTitle: 'Pro requerido',
+ topupRequiresProMessage: 'Los top-ups son para usuarios Pro activos. Inicia Pro para comprar créditos adicionales.',
cancelTitle: 'Lamentamos verte ir',
cancelQuestion: '¿Podemos saber por qué cancelas?',
reasonTooExpensive: 'Es muy caro',
@@ -168,7 +215,22 @@ const getBillingCopy = (language: Language) => {
manageSubscription: 'Manage Subscription',
subscriptionTitle: 'Subscriptions',
subscriptionHint: 'Choose a plan to unlock stronger AI scans and more credits.',
- freePlanName: 'Free',
+ paywallTitle: 'Unlock the full diagnosis',
+ paywallHint: 'Start Pro for real GPT-5.4 scans, your 7-day rescue plan, and 100 credits for AI scans and follow-ups.',
+ startTrial: 'Start 7-day free trial',
+ monthlyCta: 'Start monthly',
+ yearlyCta: 'Start yearly',
+ yearlyTrialBadge: '7 DAYS FREE',
+ monthlyBadge: 'FLEXIBLE',
+ yearlySubline: 'Then EUR 39.99/year. Cancel anytime.',
+ monthlySubline: 'EUR 4.99/month. No annual commitment.',
+ saveLabel: 'Best value',
+ expoGoPurchaseTitle: 'Purchase requires a dev build',
+ expoGoPurchaseMessage: 'Expo Go cannot show the native Apple or RevenueCat purchase sheet. In a Development Build or TestFlight this opens the real 7-day trial. For local testing you can simulate Pro.',
+ expoGoSimulate: 'Simulate Pro',
+ perYear: '/ year',
+ perMonth: '/ month',
+ freePlanName: 'Free',
freePlanPrice: '0 EUR / Month',
proPlanName: 'Pro',
proPlanPrice: '4.99 EUR / Month',
@@ -177,17 +239,20 @@ const getBillingCopy = (language: Language) => {
proYearlyPlanPrice: '39.99 EUR / Year',
proYearlyBadgeText: 'SAVE',
proBenefits: [
- '250 credits every month',
+ '100 credits for AI scans and follow-ups every month',
'Pro scans with GPT-5.4',
'Unlimited history & gallery',
'AI Plant Doctor included',
'Priority support'
],
topupTitle: 'Topup Credits',
- topupSmall: '25 Credits – €1.99',
- topupMedium: '120 Credits – €6.99',
- topupLarge: '300 Credits – €12.99',
- topupBestValue: 'BEST VALUE',
+ topupHint: 'For active Pro users when monthly credits are not enough.',
+ topupSmall: '30 Credits – €2.99',
+ topupMedium: '100 Credits – €6.99',
+ topupLarge: '250 Credits – €12.99',
+ topupBestValue: 'BEST VALUE',
+ topupRequiresProTitle: 'Pro required',
+ topupRequiresProMessage: 'Top-ups are for active Pro users. Start Pro to buy extra credits.',
cancelTitle: 'Sorry to see you go',
cancelQuestion: 'May we ask why you are cancelling?',
reasonTooExpensive: 'It is too expensive',
@@ -211,6 +276,7 @@ export default function BillingScreen() {
const router = useRouter();
const { isDarkMode, language, billingSummary, isLoadingBilling, simulatePurchase, simulateWebhookEvent, syncRevenueCatState, colorPalette, session } = useApp();
const colors = useColors(isDarkMode, colorPalette);
+ const posthog = usePostHog();
const copy = getBillingCopy(language);
const isExpoGo = Constants.appOwnership === 'expo';
@@ -223,8 +289,9 @@ export default function BillingScreen() {
// Cancel Flow State
const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none');
- const planId = billingSummary?.entitlement?.plan || 'free';
- const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? '--');
+ const planId = billingSummary?.entitlement?.plan || 'free';
+ const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? '--');
+ const showPaywallPlans = !session || planId !== 'pro';
useEffect(() => {
let cancelled = false;
@@ -272,6 +339,16 @@ export default function BillingScreen() {
};
}, [isExpoGo]);
+ useEffect(() => {
+ posthog.capture('paywall_viewed', { plan_id: planId });
+ if (showPaywallPlans) {
+ posthog.capture('hard_paywall_viewed', {
+ plan_id: planId,
+ authenticated: Boolean(session),
+ });
+ }
+ }, [posthog, planId, session?.serverUserId, showPaywallPlans]);
+
const monthlyPackage = subscriptionPackages.monthly_pro;
const yearlyPackage = subscriptionPackages.yearly_pro;
@@ -279,21 +356,60 @@ export default function BillingScreen() {
const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPrice;
const topupLabels = useMemo(() => ({
- topup_small: topupProducts.topup_small ? `25 Credits - ${topupProducts.topup_small.priceString}` : copy.topupSmall,
- topup_medium: topupProducts.topup_medium ? `120 Credits - ${topupProducts.topup_medium.priceString}` : copy.topupMedium,
- topup_large: topupProducts.topup_large ? `300 Credits - ${topupProducts.topup_large.priceString}` : copy.topupLarge,
+ topup_small: topupProducts.topup_small ? `${TOPUP_CREDITS_BY_PRODUCT.topup_small} Credits - ${topupProducts.topup_small.priceString}` : copy.topupSmall,
+ topup_medium: topupProducts.topup_medium ? `${TOPUP_CREDITS_BY_PRODUCT.topup_medium} Credits - ${topupProducts.topup_medium.priceString}` : copy.topupMedium,
+ topup_large: topupProducts.topup_large ? `${TOPUP_CREDITS_BY_PRODUCT.topup_large} Credits - ${topupProducts.topup_large.priceString}` : copy.topupLarge,
}), [copy.topupLarge, copy.topupMedium, copy.topupSmall, topupProducts.topup_large, topupProducts.topup_medium, topupProducts.topup_small]);
- const openAppleSubscriptions = async () => {
- await Linking.openURL('itms-apps://apps.apple.com/account/subscriptions');
- };
-
- const handlePurchase = async (productId: PurchaseProductId) => {
- setIsUpdating(true);
+ const openAppleSubscriptions = async () => {
+ await Linking.openURL('itms-apps://apps.apple.com/account/subscriptions');
+ };
+
+ const handleBack = () => {
+ if (showPaywallPlans) {
+ router.replace(session ? '/scanner' : '/onboarding');
+ return;
+ }
+ router.back();
+ };
+
+ const completeExpoGoSimulation = async (productId: PurchaseProductId) => {
+ setIsUpdating(true);
+ try {
+ await simulatePurchase(productId);
+ if (productId === 'monthly_pro' || productId === 'yearly_pro') {
+ posthog.capture('subscription_started', { product_id: productId, simulated: true });
+ posthog.capture('trial_started', { product_id: productId, simulated: true });
+ } else {
+ posthog.capture('topup_purchased', { product_id: productId, simulated: true });
+ }
+ } finally {
+ setIsUpdating(false);
+ }
+ };
+
+ const handlePurchase = async (productId: PurchaseProductId) => {
+ if (isTopupProductId(productId) && planId !== 'pro') {
+ Alert.alert(copy.topupRequiresProTitle, copy.topupRequiresProMessage, [
+ { text: copy.manageSubscription, onPress: () => setSubModalVisible(true) },
+ ]);
+ return;
+ }
+
+ setIsUpdating(true);
+ posthog.capture('purchase_initiated', { product_id: productId });
try {
if (isExpoGo) {
// ExpoGo has no native RevenueCat — use simulation for development only
- await simulatePurchase(productId);
+ setIsUpdating(false);
+ if (productId === 'monthly_pro' || productId === 'yearly_pro') {
+ Alert.alert(copy.expoGoPurchaseTitle, copy.expoGoPurchaseMessage, [
+ { text: 'OK', style: 'cancel' },
+ ]);
+ return;
+ }
+ await completeExpoGoSimulation(productId);
+ return;
} else {
if (productId === 'monthly_pro' || productId === 'yearly_pro') {
if (planId === 'pro') {
@@ -316,7 +432,7 @@ export default function BillingScreen() {
// Derive plan locally from RevenueCat — backend sync via webhook comes later (Step 3)
const customerInfo = await Purchases.getCustomerInfo();
await syncRevenueCatState(customerInfo as any, 'subscription_purchase');
- } else {
+ } else {
const selectedProduct = topupProducts[productId];
if (!selectedProduct) {
throw new Error('Top-up Produkt konnte nicht geladen werden. Bitte Store-Produkt IDs prüfen.');
@@ -324,16 +440,24 @@ export default function BillingScreen() {
await Purchases.purchaseStoreProduct(selectedProduct);
const customerInfo = await Purchases.getCustomerInfo();
await syncRevenueCatState(customerInfo as any, 'topup_purchase');
- }
- }
- setSubModalVisible(false);
+ }
+ }
+ if (productId === 'monthly_pro' || productId === 'yearly_pro') {
+ posthog.capture('subscription_started', { product_id: productId });
+ posthog.capture('trial_started', { product_id: productId });
+ } else {
+ posthog.capture('topup_purchased', { product_id: productId });
+ }
+ setSubModalVisible(false);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const userCancelled = typeof e === 'object' && e !== null && 'userCancelled' in e && Boolean((e as { userCancelled?: boolean }).userCancelled);
- if (userCancelled) {
- return;
- }
+ if (userCancelled) {
+ posthog.capture('purchase_cancelled', { product_id: productId });
+ posthog.capture('paywall_purchase_cancelled', { product_id: productId });
+ return;
+ }
// RevenueCat error code 7 = PRODUCT_ALREADY_PURCHASED — the Apple ID already
// owns this subscription on a different GreenLens account. Silently dismiss;
@@ -345,6 +469,7 @@ export default function BillingScreen() {
}
console.error('Payment failed', e);
+ posthog.capture('purchase_failed', { product_id: productId, error: msg });
Alert.alert('Unerwarteter Fehler', msg);
} finally {
setIsUpdating(false);
@@ -394,7 +519,7 @@ export default function BillingScreen() {
- router.back()} style={styles.backButton}>
+ {copy.title}
@@ -406,7 +531,7 @@ export default function BillingScreen() {
) : (
<>
- {session && (
+ {session && planId === 'pro' && (
{copy.planLabel}
@@ -425,66 +550,73 @@ export default function BillingScreen() {
{credits}
)}
- {!session && (
-
- Subscription Plans
-
- Choose a plan to unlock AI plant scans and care features.
-
+ {showPaywallPlans && (
+
+ {copy.paywallTitle}
+
+ {copy.paywallHint}
+
- {/* Monthly */}
-
-
- GreenLens Pro
-
- MONTHLY
-
-
- {monthlyPrice}
- {copy.autoRenewMonthly}
-
- {copy.proBenefits.map((b, i) => (
-
-
- {b}
-
- ))}
-
- handlePurchase('monthly_pro')}
- disabled={isUpdating || !storeReady}
- >
- Subscribe Monthly
-
-
-
- {/* Yearly */}
-
-
- GreenLens Pro
-
- YEARLY
-
-
- {yearlyPrice}
- {copy.autoRenewYearly}
-
- {copy.proBenefits.map((b, i) => (
-
-
- {b}
-
- ))}
-
- handlePurchase('yearly_pro')}
- disabled={isUpdating || !storeReady}
- >
- Subscribe Yearly
-
-
+
+ {copy.proBenefits.slice(0, 3).map((benefit, index) => (
+
+
+ {benefit}
+
+ ))}
+
+
+ handlePurchase('yearly_pro')}
+ disabled={isUpdating || !storeReady}
+ activeOpacity={0.9}
+ >
+
+
+ GreenLens Pro
+ {copy.saveLabel}
+
+
+ {copy.yearlyTrialBadge}
+
+
+
+ {yearlyPrice}
+ {copy.perYear}
+
+ {copy.yearlySubline}
+
+
+ {copy.startTrial}
+
+
+
+ handlePurchase('monthly_pro')}
+ disabled={isUpdating || !storeReady}
+ activeOpacity={0.9}
+ >
+
+
+ Monatlich
+ {copy.monthlySubline}
+
+
+ {copy.monthlyBadge}
+
+
+
+ {monthlyPrice}
+ {copy.perMonth}
+
+ {copy.monthlyCta}
+ Linking.openURL('https://greenlenspro.com/privacy')}>
@@ -501,9 +633,11 @@ export default function BillingScreen() {
)}
-
- {copy.topupTitle}
-
+ {session && planId === 'pro' && !isExpoGo ? (
+
+ {copy.topupTitle}
+ {copy.topupHint}
+
{([
{ id: 'topup_small' as PurchaseProductId, label: topupLabels.topup_small },
{ id: 'topup_medium' as PurchaseProductId, label: topupLabels.topup_medium, badge: copy.topupBestValue },
@@ -549,10 +683,11 @@ export default function BillingScreen() {
Terms of Use
-
- {copy.restorePurchases}
-
-
+
+ {copy.restorePurchases}
+
+
+ ) : null}
>
)}
@@ -768,11 +903,36 @@ const styles = StyleSheet.create({
fontSize: 14,
fontWeight: '600',
},
- creditsValue: {
- fontSize: 32,
- fontWeight: '700',
- },
- topupBtn: {
+ creditsValue: {
+ fontSize: 32,
+ fontWeight: '700',
+ },
+ paywallTitle: {
+ fontSize: 24,
+ fontWeight: '800',
+ lineHeight: 30,
+ marginBottom: 8,
+ },
+ paywallValueRows: {
+ gap: 8,
+ marginBottom: 14,
+ },
+ paywallValueRow: {
+ minHeight: 42,
+ borderRadius: 12,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 9,
+ },
+ paywallValueText: {
+ flex: 1,
+ fontSize: 13,
+ fontWeight: '700',
+ lineHeight: 18,
+ },
+ topupBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
@@ -929,29 +1089,93 @@ const styles = StyleSheet.create({
fontSize: 15,
fontWeight: '500',
},
- guestPlanCard: {
- borderWidth: 2,
- borderRadius: 12,
- padding: 16,
- },
- guestPlanHeader: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 8,
- marginBottom: 4,
- },
- guestPlanName: {
- fontSize: 18,
- fontWeight: '700',
- },
- guestPlanPrice: {
- fontSize: 22,
- fontWeight: '700',
- marginBottom: 2,
- },
- guestPlanRenew: {
- fontSize: 12,
- },
+ guestPlanCard: {
+ borderWidth: 2,
+ borderRadius: 12,
+ padding: 16,
+ },
+ paywallPlanCardPrimary: {
+ borderWidth: 2,
+ borderRadius: 14,
+ padding: 16,
+ marginTop: 2,
+ },
+ paywallPlanCardSecondary: {
+ borderWidth: 1,
+ borderRadius: 14,
+ padding: 14,
+ marginTop: 10,
+ },
+ guestPlanHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ marginBottom: 4,
+ },
+ planTopRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'flex-start',
+ gap: 10,
+ marginBottom: 10,
+ },
+ guestPlanName: {
+ fontSize: 18,
+ fontWeight: '700',
+ },
+ planSubline: {
+ fontSize: 12,
+ fontWeight: '600',
+ marginTop: 2,
+ },
+ priceRow: {
+ flexDirection: 'row',
+ alignItems: 'baseline',
+ gap: 6,
+ },
+ guestPlanPrice: {
+ fontSize: 22,
+ fontWeight: '700',
+ marginBottom: 2,
+ },
+ planTerm: {
+ fontSize: 13,
+ fontWeight: '600',
+ },
+ guestPlanRenew: {
+ fontSize: 12,
+ lineHeight: 17,
+ },
+ trialCallout: {
+ minHeight: 46,
+ borderRadius: 12,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 8,
+ marginTop: 14,
+ },
+ trialCalloutText: {
+ fontSize: 15,
+ fontWeight: '800',
+ },
+ secondaryBadge: {
+ borderWidth: 1,
+ borderRadius: 999,
+ paddingHorizontal: 8,
+ paddingVertical: 3,
+ },
+ secondaryBadgeText: {
+ fontSize: 10,
+ fontWeight: '800',
+ },
+ monthlyCtaText: {
+ fontSize: 13,
+ fontWeight: '800',
+ marginTop: 8,
+ },
guestSubscribeBtn: {
marginTop: 14,
paddingVertical: 12,
diff --git a/app/scanner.tsx b/app/scanner.tsx
index 8b4af51..31819e3 100644
--- a/app/scanner.tsx
+++ b/app/scanner.tsx
@@ -6,18 +6,20 @@ import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { CameraView, useCameraPermissions } from 'expo-camera';
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 ImagePicker from 'expo-image-picker';
+import * as ImageManipulator from 'expo-image-manipulator';
+import * as Haptics from 'expo-haptics';
+import * as AppleAuthentication from 'expo-apple-authentication';
+import { usePostHog } from 'posthog-react-native';
import { useApp } from '../context/AppContext';
import { useColors } from '../constants/Colors';
import { PlantRecognitionService } from '../services/plantRecognitionService';
import { IdentificationResult } from '../types';
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 { backendApiClient, isInsufficientCreditsError, isNetworkError, isTimeoutError } from '../services/backend/backendApiClient';
+import { isBackendApiError } from '../services/backend/contracts';
+import { createIdempotencyKey } from '../utils/idempotency';
+import { AuthService } from '../services/authService';
const HEALTH_CHECK_CREDIT_COST = 2;
@@ -37,13 +39,20 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
timeoutTitle: 'Scan zu langsam',
timeoutMessage: 'Die Analyse hat zu lange gedauert. Bitte erneut versuchen.',
retryLabel: 'Erneut versuchen',
+ notAPlantTitle: 'Keine Pflanze erkannt',
+ notAPlantMessage: 'Das Bild zeigt keine erkennbare Pflanze. Bitte fotografiere eine Pflanze und versuche es erneut.',
providerErrorMessage: 'KI-Scan gerade nicht verfügbar. Bitte versuche es erneut.',
healthProviderErrorMessage: 'KI-Health-Check gerade nicht verfügbar. Bitte versuche es erneut.',
- healthTitle: 'Health Check',
- healthDoneTitle: 'Health Check abgeschlossen',
- healthDoneMessage: 'Neues Foto wurde geprueft und zur Galerie hinzugefuegt.',
- signupLabel: 'Registrieren',
- };
+ healthTitle: 'Health Check',
+ 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.',
+ appleCta: 'Mit Apple fortfahren',
+ emailCta: 'Mit E-Mail fortfahren',
+ unlockCta: 'Vollständige Diagnose freischalten',
+ };
}
if (language === 'es') {
@@ -61,13 +70,20 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
timeoutTitle: 'Escaneo lento',
timeoutMessage: 'El análisis tardó demasiado. Inténtalo de nuevo.',
retryLabel: 'Reintentar',
+ notAPlantTitle: 'No es una planta',
+ notAPlantMessage: 'La imagen no muestra una planta reconocible. Por favor fotografía una planta e inténtalo de nuevo.',
providerErrorMessage: 'Escaneo IA no disponible ahora. Inténtalo de nuevo.',
healthProviderErrorMessage: 'Health-check IA no disponible ahora. Inténtalo de nuevo.',
- healthTitle: 'Health Check',
- healthDoneTitle: 'Health-check completado',
- healthDoneMessage: 'La foto nueva fue analizada y guardada en la galeria.',
- signupLabel: 'Registrarse',
- };
+ healthTitle: 'Health Check',
+ 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.',
+ appleCta: 'Continuar con Apple',
+ emailCta: 'Continuar con email',
+ unlockCta: 'Desbloquear diagnóstico completo',
+ };
}
return {
@@ -84,14 +100,21 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
timeoutTitle: 'Scan Too Slow',
timeoutMessage: 'Analysis took too long. Please try again.',
retryLabel: 'Try again',
+ notAPlantTitle: 'No plant detected',
+ notAPlantMessage: 'The image does not show a recognizable plant. Please photograph a plant and try again.',
providerErrorMessage: 'AI scan is currently unavailable. Please try again.',
healthProviderErrorMessage: 'AI health check is currently unavailable. Please try again.',
- healthTitle: 'Health Check',
- healthDoneTitle: 'Health Check Complete',
- healthDoneMessage: 'The new photo was analyzed and added to gallery.',
- signupLabel: 'Sign Up',
- };
-};
+ healthTitle: 'Health Check',
+ 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.',
+ appleCta: 'Continue with Apple',
+ emailCta: 'Continue with email',
+ unlockCta: 'Unlock full diagnosis',
+ };
+};
export default function ScannerScreen() {
const params = useLocalSearchParams<{ mode?: string; plantId?: string }>();
@@ -106,36 +129,53 @@ export default function ScannerScreen() {
updatePlant,
billingSummary,
refreshBillingSummary,
- isLoadingBilling,
- session,
- setPendingPlant,
- guestScanCount,
- incrementGuestScanCount,
- } = useApp();
+ isLoadingBilling,
+ session,
+ hydrateSession,
+ setPendingPlant,
+ } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const router = useRouter();
const insets = useSafeAreaInsets();
const billingCopy = getBillingCopy(language);
const isHealthMode = params.mode === 'health';
const healthPlantId = Array.isArray(params.plantId) ? params.plantId[0] : params.plantId;
- const healthPlant = isHealthMode && healthPlantId
- ? plants.find((item) => item.id === healthPlantId)
- : null;
- const availableCredits = session
- ? (billingSummary?.credits.available ?? 0)
- : Math.max(0, 5 - guestScanCount);
-
- const [permission, requestPermission] = useCameraPermissions();
- const [selectedImage, setSelectedImage] = useState(null);
- const [isAnalyzing, setIsAnalyzing] = useState(false);
- const [analysisProgress, setAnalysisProgress] = useState(0);
- const [analysisResult, setAnalysisResult] = useState(null);
- const cameraRef = useRef(null);
+ const healthPlant = isHealthMode && healthPlantId
+ ? plants.find((item) => item.id === healthPlantId)
+ : null;
+ const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro'
+ && billingSummary?.entitlement?.status === 'active';
+ const isDemoMode = !hasActiveEntitlement;
+ const availableCredits = hasActiveEntitlement ? (billingSummary?.credits.available ?? 0) : 0;
+
+ const [permission, requestPermission] = useCameraPermissions();
+ const [selectedImage, setSelectedImage] = useState(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(null);
+ const [demoResultVisible, setDemoResultVisible] = useState(false);
+ const cameraRef = useRef(null);
const scanLineProgress = useRef(new Animated.Value(0)).current;
const scanPulse = useRef(new Animated.Value(0)).current;
- useEffect(() => {
- if (!isAnalyzing) {
+ useEffect(() => {
+ let mounted = true;
+ AppleAuthentication.isAvailableAsync()
+ .then((available) => {
+ if (mounted) setAppleAvailable(available);
+ })
+ .catch(() => {
+ if (mounted) setAppleAvailable(false);
+ });
+ return () => {
+ mounted = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!isAnalyzing) {
scanLineProgress.stopAnimation();
scanLineProgress.setValue(0);
scanPulse.stopAnimation();
@@ -181,8 +221,8 @@ export default function ScannerScreen() {
try {
const result = await ImageManipulator.manipulateAsync(
uri,
- [{ resize: { width: 1024 } }],
- { compress: 0.6, format: ImageManipulator.SaveFormat.JPEG, base64: true },
+ [{ resize: { width: 768 } }],
+ { compress: 0.7, format: ImageManipulator.SaveFormat.JPEG, base64: true },
);
return result.base64 ? `data:image/jpeg;base64,${result.base64}` : result.uri;
} catch {
@@ -193,29 +233,25 @@ 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 && availableCredits <= 0) {
Alert.alert(
billingCopy.noCreditsTitle,
isHealthMode ? billingCopy.healthNoCreditsMessage : billingCopy.noCreditsMessage,
[
- { text: billingCopy.dismiss, style: 'cancel' },
- {
- text: billingCopy.managePlan,
- onPress: () => router.replace('/(tabs)/profile'),
- },
- ],
- );
+ { text: billingCopy.dismiss, style: 'cancel' },
+ {
+ text: billingCopy.managePlan,
+ onPress: () => router.replace('/profile/billing'),
+ },
+ ],
+ );
return;
}
- setIsAnalyzing(true);
- setAnalysisProgress(0);
- setAnalysisResult(null);
+ setIsAnalyzing(true);
+ setAnalysisProgress(0);
+ setAnalysisResult(null);
+ setDemoResultVisible(false);
const startTime = Date.now();
@@ -228,10 +264,32 @@ export default function ScannerScreen() {
});
}, 150);
- try {
- if (isHealthMode) {
- if (!healthPlant) {
- Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage);
+ try {
+ if (isDemoMode) {
+ posthog.capture('demo_scan_started', {
+ authenticated: Boolean(session),
+ scan_type: isHealthMode ? 'health_check' : 'identification',
+ });
+ await new Promise(resolve => setTimeout(resolve, 2100));
+ setAnalysisProgress(100);
+ await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ await new Promise(resolve => setTimeout(resolve, 350));
+ setDemoResultVisible(true);
+ posthog.capture('demo_scan_completed', {
+ authenticated: Boolean(session),
+ latency_ms: Date.now() - startTime,
+ });
+ 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);
setSelectedImage(null);
setIsAnalyzing(false);
return;
@@ -255,11 +313,7 @@ export default function ScannerScreen() {
latency_ms: Date.now() - startTime,
});
- if (!session) {
- incrementGuestScanCount();
- }
-
- const currentGallery = healthPlant.gallery || [];
+ const currentGallery = healthPlant.gallery || [];
const existingChecks = healthPlant.healthChecks || [];
const updatedChecks = [response.healthCheck, ...existingChecks].slice(0, 6);
const updatedPlant = {
@@ -279,16 +333,16 @@ export default function ScannerScreen() {
latency_ms: Date.now() - startTime,
});
- if (!session) {
- incrementGuestScanCount();
- }
-
- setAnalysisResult(result);
- }
- setAnalysisProgress(100);
- await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
- await new Promise(resolve => setTimeout(resolve, 500));
- setIsAnalyzing(false);
+ 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) {
Alert.alert(billingCopy.healthDoneTitle, billingCopy.healthDoneMessage, [
{ text: billingCopy.dismiss, onPress: () => router.replace(`/plant/${healthPlant.id}`) },
@@ -309,13 +363,13 @@ export default function ScannerScreen() {
billingCopy.noCreditsTitle,
isHealthMode ? billingCopy.healthNoCreditsMessage : billingCopy.noCreditsMessage,
[
- { text: billingCopy.dismiss, style: 'cancel' },
- {
- text: billingCopy.managePlan,
- onPress: () => router.replace('/(tabs)/profile'),
- },
- ],
- );
+ { text: billingCopy.dismiss, style: 'cancel' },
+ {
+ text: billingCopy.managePlan,
+ onPress: () => router.replace('/profile/billing'),
+ },
+ ],
+ );
} else if (isTimeoutError(error)) {
Alert.alert(
billingCopy.timeoutTitle,
@@ -334,6 +388,12 @@ export default function ScannerScreen() {
{ text: billingCopy.retryLabel, onPress: () => analyzeImage(imageUri, galleryImageUri) },
],
);
+ } else if (isBackendApiError(error) && error.code === 'NOT_A_PLANT') {
+ Alert.alert(
+ billingCopy.notAPlantTitle,
+ billingCopy.notAPlantMessage,
+ [{ text: billingCopy.dismiss, style: 'cancel' }],
+ );
} else if (isBackendApiError(error) && error.code === 'PROVIDER_ERROR') {
Alert.alert(
billingCopy.genericErrorTitle,
@@ -348,23 +408,24 @@ export default function ScannerScreen() {
}
setSelectedImage(null);
setIsAnalyzing(false);
- } finally {
- clearInterval(progressInterval);
- await refreshBillingSummary();
- }
- };
+ } finally {
+ clearInterval(progressInterval);
+ setIsAnalyzing(false);
+ if (!isDemoMode) {
+ await refreshBillingSummary();
+ }
+ }
+ };
const takePicture = async () => {
if (!cameraRef.current || isAnalyzing) return;
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
- const photo = await cameraRef.current.takePictureAsync({ base64: true, quality: 0.5 });
- if (photo) {
- const analysisUri = photo.base64
- ? `data:image/jpeg;base64,${photo.base64}`
- : photo.uri;
- const galleryUri = photo.uri || analysisUri;
- setSelectedImage(analysisUri);
- analyzeImage(analysisUri, galleryUri);
+ 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);
}
};
@@ -376,15 +437,16 @@ export default function ScannerScreen() {
quality: 1,
base64: false,
});
- if (!result.canceled && result.assets[0]) {
- const asset = result.assets[0];
- const analysisUri = await resizeForAnalysis(asset.uri);
- setSelectedImage(asset.uri);
+ 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);
}
};
- const handleSave = async () => {
+ const handleSave = async () => {
if (analysisResult && selectedImage) {
if (!session) {
// Guest mode: store result and go to signup
@@ -400,10 +462,72 @@ export default function ScannerScreen() {
console.error('Saving identified plant failed', error);
Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage);
}
- }
- };
-
- const handleClose = () => {
+ }
+ };
+
+ 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('/profile/billing');
+ } 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();
};
@@ -461,14 +585,14 @@ export default function ScannerScreen() {
{isHealthMode ? billingCopy.healthTitle : t.scanner}
-
-
-
-
- {billingCopy.creditsLabel}: {availableCredits}
-
-
-
+
+
+
+
+ {isDemoMode ? 'Demo' : `${billingCopy.creditsLabel}: ${availableCredits}`}
+
+
+
{/* Camera */}
@@ -551,10 +675,65 @@ export default function ScannerScreen() {
- )}
-
- {/* Bottom Controls */}
-
+
+
+
+ {billingCopy.demoTitle}
+ {billingCopy.demoMessage}
+
+ {!session && appleAvailable ? (
+
+ ) : (
+
+
+ {isAuthLoading ? '...' : session ? billingCopy.unlockCta : billingCopy.appleCta}
+
+
+ )}
+
+ {!session ? (
+ {
+ posthog.capture('auth_prompt_shown', { surface: 'demo_scan_result', method: 'email' });
+ router.replace('/auth/signup');
+ }}
+ activeOpacity={0.85}
+ >
+ {billingCopy.emailCta}
+
+ ) : null}
+
+ ) : null}
+
+ {/* Bottom Controls */}
+ # 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 `