Copy overhaul + qr designs

This commit is contained in:
2026-07-27 17:54:59 +02:00
parent 033bc7e29d
commit 70d97aa970
144 changed files with 23107 additions and 1699 deletions

View File

@@ -0,0 +1,421 @@
# Umsetzungsplan - CRO-Momente, Upgrade-Wege und Retention-Mails
Stand 27. Juli 2026. Alle Datei- und Zeilenangaben gegen den aktuellen Stand verifiziert.
**Getroffene Entscheidungen:**
- Das Dynamic-Limit zählt künftig nur noch Codes mit `status = ACTIVE`. Pausieren gibt einen Slot frei.
- Der Upgrade-Weg im Limit-Moment läuft per Direkt-Checkout aus dem Modal. Kein Redirect auf `/pricing`.
---
## Phase 1 - Der Limit-Moment in `/create`
### 1.1 Limit-Query auf ACTIVE umstellen
**Datei:** `src/app/(main)/api/qrs/route.ts`, Zeile 117-121
```ts
const dynamicQRCount = await db.qRCode.count({
where: { userId, type: 'DYNAMIC', status: 'ACTIVE' },
});
```
Dieselbe Änderung in `src/app/(main)/api/user/stats/route.ts`, sonst zeigt das Dashboard eine andere Zahl als die API durchlässt.
**Nebenwirkung, die vorher klar sein muss:** Free-Nutzer, die heute pausierte Codes haben, bekommen dadurch rückwirkend Slots frei. Das ist eine Lockerung, keine Verschärfung - es nimmt niemandem etwas weg. Vor dem Deploy einmal zählen, wie viele Nutzer betroffen sind (SQL unten in Abschnitt „Prüf-Queries").
### 1.2 Das Limit-Modal
**Neue Datei:** `src/components/app/DynamicLimitModal.tsx`
Der 403 aus `/api/qrs` liefert bereits `currentCount`, `limit` und `plan` mit. Es braucht keinen zusätzlichen Request.
**Datei:** `src/app/(main)/(app)/create/page.tsx`, Zeile 456-459 - der Redirect entfällt ersatzlos:
```ts
if (response.status === 403 && responseData.error === 'Limit reached') {
setLimitInfo({ current: responseData.currentCount, limit: responseData.limit, plan: responseData.plan });
setLimitModalOpen(true);
return;
}
```
Der Formular-State bleibt dadurch erhalten. Das ist der eigentliche Fix - alles andere ist Ausgestaltung.
**Copy des Modals:**
> ### Dein vierter Code ist fertig. Er braucht nur noch einen Platz.
>
> Du nutzt alle 3 dynamischen Codes deines kostenlosen Plans. Dieser hier ist gebaut und wartet - du kannst ihn behalten oder einen bestehenden freigeben.
>
> **[ Diesen Code mit Pro speichern - 9 € / Monat ]**
> [ Einen bestehenden Code pausieren ]
> [ Stattdessen als statischen Code herunterladen ]
>
> *Deine 3 aktiven Codes laufen weiter, egal wie du dich entscheidest.*
Die Zahlen (`vierter`, `3`) kommen aus `limitInfo`, damit das Modal auch für Pro bei 51 stimmt.
**Warum diese drei Optionen:**
Der Hauptbutton verkauft keinen Plan, sondern rettet einen konkreten Code, den der Nutzer gerade in der Hand hat. Option 2 ist die ehrliche Alternative innerhalb des Free-Plans - sie kostet ein paar Conversions und kauft dafür Belief 5 aus dem Necessary-Beliefs-Doc. Option 3 ist der Ausweg ohne Verlust: ein statischer Code löst das Problem zu einem guten Teil, kostet nichts, und die Zusage „läuft nie ab" ist verifiziert. Der Schlusssatz ist Risk Reversal genau an der Stelle, an der die Kategorie ihren schlechtesten Ruf hat.
### 1.3 Direkt-Checkout aus dem Modal
Der Pro-Button ruft `/api/stripe/checkout` direkt auf, mit `priceId`, `plan: 'PRO'` und `userEmail`.
**Datei:** `src/app/(main)/api/stripe/checkout/route.ts`, Zeile 63. Die `success_url` ist aktuell fest auf `/dashboard?success=true`. Sie muss eine optionale `returnPath` aus dem Request-Body akzeptieren, damit der Nutzer nach dem Kauf dorthin zurückkommt, wo er war.
Vor dem Öffnen von Stripe wird der Formularzustand nach `localStorage` geschrieben (`qrm_pending_qr`, mit Zeitstempel). Beim Zurückkommen auf `/create?restored=1` liest die Seite ihn aus, füllt das Formular und zeigt: *„Willkommen zurück. Dein Code steht noch genau so da - jetzt mit Platz."* Danach den Key löschen. Einträge älter als 24 Stunden werden verworfen.
Kein Draft in der Datenbank. Der Zustand ist ohnehin nur im Browser relevant, und eine Draft-Tabelle wäre Infrastruktur für ein Problem, das `localStorage` löst.
### 1.4 Option „Code pausieren" im Modal
Zeigt die aktiven dynamischen Codes des Nutzers mit Titel und Scan-Zahl der letzten 30 Tage. Ein Klick setzt `status = PAUSED` über das bestehende `PATCH /api/qrs/[id]`, danach wird der ursprüngliche POST automatisch wiederholt.
Die Scan-Zahl daneben ist wichtig: sie macht die Entscheidung informiert statt willkürlich. Wer sieht, dass ein Code seit vier Wochen null Scans hat, pausiert ihn ohne schlechtes Gefühl - und wer sieht, dass alle drei laufen, versteht ohne Verkaufstext, warum Pro sinnvoll ist. Das ist Pointing statt Talking.
---
## Phase 2 - `/bulk-creation`
**Datei:** `src/app/(main)/(app)/bulk-creation/page.tsx`, Zeile 232-247
Die Schleife hat kein `else` zu `if (res.ok)`. Fehlgeschlagene Zeilen verschwinden still, und danach meldet ein grüner Toast `${results.length} dynamische QR-Codes erstellt!` - eine Zahl, die kleiner sein kann als das Hochgeladene, ohne jeden Hinweis.
**Fix:**
```ts
const failed: { row: number; title: string; reason: string }[] = [];
// im else-Zweig: failed.push({ row: i + 1, title, reason: (await res.json()).error })
```
Danach, wenn `failed.length > 0`, statt des Erfolgs-Toasts ein Ergebnis-Panel:
> **180 von 200 Codes erstellt.**
> 20 Zeilen konnten nicht angelegt werden, weil dein Kontingent an dynamischen Codes erschöpft ist. Hier sind sie - du kannst sie als statische Codes erzeugen oder dein Kontingent erhöhen.
>
> [ Fehlende Zeilen als CSV ] [ Kontingent erhöhen ]
Zusätzlich in derselben Datei:
- `remainingDynamic` nach dem Lauf vom Server neu laden statt nur lokal herunterzuzählen (Zeile 251). Das ist die Ursache der Race Condition.
- Die Toasts sind auf Deutsch (`'Du hast keine dynamischen QR-Codes mehr übrig...'`, Zeile 215 und 221), während die restliche App-Oberfläche Englisch ist. Auf Englisch umstellen.
---
## Phase 3 - Upgrade-Wege aus der App
`/pricing` liegt in der `(marketing)`-Route-Group. Wer im Dashboard auf Upgrade klickt, verliert die Sidebar und landet in der Marketing-Site. Zusätzlich liest `PricingClient.tsx` `searchParams` überhaupt nicht - das `?reason=limit_reached`, das `/create` heute anhängt, wird vollständig ignoriert.
Nach der getroffenen Entscheidung läuft der Limit-Fall künftig über das Modal, damit ist der wichtigste Fall gelöst. Es bleiben drei In-App-Links auf `/pricing`:
| Datei | Zeile | Kontext |
|---|---|---|
| `dashboard/page.tsx` | 340 | Upgrade-Badge im Header |
| `create/page.tsx` | 977 | Hinweis „Upgrade to PRO to customize colors" |
| `create/page.tsx` | 1106 | Hinweis „Upgrade to PRO to add logos" |
Die beiden Hinweise in `/create` sollten dasselbe Modal öffnen wie der Limit-Fall, nur mit anderem Aufhänger („Dein Logo gehört in diesen Code"). Der Nutzer ist mitten im Gestalten - ihn dafür aus der Seite zu werfen ist derselbe Fehler wie beim Limit, nur weniger sichtbar.
Der Dashboard-Badge kann auf `/pricing` zeigen bleiben. Dort ist der Nutzer nicht mitten in einer Aufgabe, es gibt nichts zu verlieren. Nur ein `?from=dashboard` anhängen und in `PricingClient` einen Zurück-Link rendern, damit der Weg zurück nicht über den Browser-Button läuft.
---
## Phase 4 - Post-Download-Popup auf den Tool-Seiten
**Datei:** `src/components/marketing/PostDownloadPopup.tsx`
Timing und die Ablehn-Option `No thanks, keep it static` bleiben unverändert - beides ist richtig gebaut.
Ersetzt werden Headline und Bullet-Liste:
> ### Dieser Code zeigt jetzt für immer auf diese URL.
>
> Bei einem dauerhaften Link ist das genau richtig. Falls sich das Ziel je ändert, brauchst du einen neuen Code und neues Druckmaterial.
>
> Ein kostenloses Konto gibt dir 3 dynamische Codes: gleiches Bild, Ziel jederzeit änderbar, jeder Scan gezählt.
>
> **[ Kostenloses Konto anlegen - keine Karte ]**
> *Nein danke, statisch reicht*
Die vier Bullets entfallen. Drei gleichrangige Vorteile sind schwächer als ein Satz, der den einen benennt - in einer Liste aus vier gleich formatierten Punkten ist keiner davon wichtig.
Neues Prop `variant`, das nur die erste Zeile austauscht:
| Tool | Erste Zeile |
|---|---|
| Google Review | Dieser Code zeigt jetzt für immer auf dieses Google-Profil. |
| WiFi | Dieser Code enthält jetzt dauerhaft dieses WLAN-Passwort. |
| vCard | Dieser Code enthält jetzt dauerhaft diese Kontaktdaten. |
| Crypto | Dieser Code enthält jetzt dauerhaft diese Wallet-Adresse. |
| Standard | Dieser Code zeigt jetzt für immer auf diese URL. |
Die WiFi-Variante ist die stärkste, weil sie einen Umstand benennt, den fast niemand vorher bedenkt: Wer das Passwort ändert, hat wertloses Druckmaterial.
Betroffen sind die 10+ Generator-Komponenten, die `PostDownloadPopup` einbinden - dort jeweils nur das `variant`-Prop ergänzen.
**Zusätzlich:** `shouldShowDownloadPopup()` prüft einen einzigen `localStorage`-Key. Wer das Popup einmal gesehen hat, sieht es auf keiner anderen Tool-Seite je wieder - auch nicht Monate später in einem anderen Kontext. Vorschlag: Key mit Zeitstempel, Wiedervorlage nach 30 Tagen.
---
## Phase 5 - Retention-Mails
**Datei:** `src/lib/email.ts` und `src/app/(main)/api/cron/retention-emails/route.ts`
### 5.1 Tag-7-Mail: Trigger vom Kalender aufs Verhalten
Aktuell feuert sie bei `createdAt < 7 Tage` und `qrCount > 0`. Ein Nutzer mit einem einzigen Code bekommt „You're 2 away from the free limit" - eine Verkaufsmail über ein Limit, das ihn nicht drückt, abgeschickt unter deinem Namen.
Neue Logik:
| Zustand | Auslöser | Mail |
|---|---|---|
| aktive dynamische Codes = Limit | sobald erreicht | Limit-Mail, Marker `limitReachedNudgeSentAt` |
| 1-2 von 3 belegt, Tag 7 | Tag 7 | keine Upgrade-Mail |
| erster Scan liegt vor | 1 Tag danach | Erster-Scan-Mail, Marker `firstScanNudgeSentAt` |
In der Vergleichstabelle der Limit-Mail entfällt die Zeile `CSV export: Free ✓ / Pro ✓`. Eine Zeile, in der beide Spalten identisch sind, gehört nicht in eine Upgrade-Tabelle - sie verwässert die drei, die einen Unterschied zeigen.
### 5.2 Neue Mail: erster Scan
`User.firstScanAt` existiert bereits und wird in `src/app/(main)/r/[slug]/route.ts` (Zeile 150-153 und 263-266) gesetzt. Es fehlt nur ein Versand-Marker.
Betreff: **Dein Code wurde gerade zum ersten Mal gescannt**
> Um {Uhrzeit}, auf einem {Gerät}, aus {Land}. Dein Code „{Titel}" ist im Einsatz.
>
> Ab jetzt zählt jeder weitere Scan mit. In ein paar Tagen siehst du, wann die meisten kommen - und ob sich der Ort lohnt, an dem du den Code platziert hast.
>
> [ Scans ansehen ]
Kein Verkaufsargument. Diese Mail hat einen Anlass, der nicht konstruiert ist, und ist der Moment, in dem die Positionierung zum ersten Mal einlöst. Sie ist die einzige in der Sequenz, deren Anlass nicht vom Kalender kommt.
### 5.3 Tag-30-Mail auf Scan-Daten umbauen
Zwei Probleme mit der jetzigen Fassung:
Der Satz *„The one thing I hear most from Pro users who switched after a few weeks: they wish they'd added their brand sooner"* behauptet ein Muster aus Kundengesprächen. Wenn es die gab: echtes Zitat rein. Wenn nicht: **streichen** - das ist ein erfundenes Testimonial in indirekter Rede und verstößt gegen die Beweisregel im Product Context. Bei einer Zielgruppe, die Bewertungsportale liest, ist das die teuerste Art von Satz.
Und Branding ist der schwächere von zwei verfügbaren Aufhängern. Nach 30 Tagen hat der Nutzer Scan-Daten. Neuer Aufbau:
> Deine Codes wurden diesen Monat {n}-mal gescannt, {Vorname}.
>
> Die meisten davon {Wochentag}s. Was du noch nicht sehen kannst: von welchen Geräten sie kamen und aus welchen Orten - und damit, welche deiner Platzierungen die Scans wirklich gebracht hat.
>
> [ Vollständige Auswertung freischalten ]
Damit ist der Kaufgrund aus dem hergeleitet, was der Nutzer selbst erlebt hat. Deckt sich mit Offer Brief §10, Option 1 - auf Analytics-Tiefe metern statt auf Code-Anzahl -, und ist die erste Stelle, an der man das testen kann, ohne das Pricing anzufassen.
### 5.4 Tag-3-Mail: kleine Korrektur
Betreff `You haven't made one yet` kann als Vorwurf gelesen werden - im Body löst die Headline es auf, im Posteingang steht der Betreff allein. Alternative gleicher Länge: **`Your 3 free codes are still sitting there`**.
---
## Phase 6 - QR-Design nach Plan gestaffelt
Neue Staffelung: Farben ab Free, Formen ab Pro, alles ab Business.
### 6.0 Der technische Blocker, der vorher geklärt sein muss
`/create` rendert über `QRCodeSVG` aus `qrcode.react` (Zeile 6 und 1233). **Diese Bibliothek kann ausschließlich quadratische Module.** Es gibt heute überhaupt keine Formauswahl im Produkt - nicht weil sie gesperrt wäre, sondern weil der Renderer sie nicht kann.
Für Formen muss der Renderer also getauscht werden. Es gibt zwei Kandidaten, und sie decken unterschiedlich viel ab:
| Renderer | Kann | Kann nicht |
|---|---|---|
| `qr-code-styling` (liegt bereits als Dependency in `package.json`, Zeile 64, ungenutzt) | square, dots, rounded, extra-rounded, classy, classy-rounded, Eye-Styles getrennt, Verläufe, Logo | star, plus, hexagon, diamond, mosaic, liquid |
| Canvas-Renderer aus `qr-design-showcase.html` | alle 11 Formen inklusive star, plus, hexagon, diamond, mosaic, liquid, Rahmen mit Label, Logo-Formen | ist eigener Code, muss portiert und gepflegt werden |
Das trifft sich gut mit der gewünschten Staffelung: **Pro läuft komplett über `qr-code-styling`** - vier Formen, geringer Aufwand, Bibliothek ist schon da. **Business braucht den portierten Showcase-Renderer** für die exotischen Formen. Damit ist der Mehraufwand genau dort, wo auch der höhere Preis ist.
### 6.1 Free - Farben
**Datei:** `src/app/(main)/(app)/create/page.tsx`, Zeile 167
```ts
const canCustomizeColors = true; // war: PRO || BUSINESS
const canUseShapes = userPlan === 'PRO' || userPlan === 'BUSINESS';
const canUseFullDesign = userPlan === 'BUSINESS';
const canUseLogo = userPlan === 'PRO' || userPlan === 'BUSINESS';
```
Damit fallen die beiden Zwangsüberschreibungen in Zeile 408-409 weg, die heute für Free-Nutzer stumpf `#000000` und `#FFFFFF` einsetzen. Der Upgrade-Hinweis-Block ab Zeile 972 („Upgrade to PRO to customize colors, add logos, and brand your QR codes") entfällt komplett - er stimmt danach nicht mehr.
Der Logo-Block ab Zeile 1104 bleibt, wird aber auf `canUseLogo` umgestellt statt auf `canCustomizeColors`, und öffnet künftig das Modal aus Phase 3 statt auf `/pricing` zu verlinken.
**Der Kontrast-Check bleibt aktiv.** `calculateContrast` gibt es bereits (Zeile 221). Sobald Farben für alle offen sind, wird er wichtiger, nicht unwichtiger - Free-Nutzer sind die Gruppe mit der geringsten Erfahrung darin, was noch scannbar ist. Bei zu geringem Kontrast eine Warnung, kein Verbot: *„Dieser Kontrast ist grenzwertig. Auf gedrucktem Material scannen helle Codes auf hellem Grund oft nicht."*
### 6.2 Pro - vier Modulformen
Umsetzung über `qr-code-styling`. Bewusst nur vier, nicht sechs:
| Auswahl | `dotsOptions.type` | Wofür |
|---|---|---|
| Eckig (klassisch) | `square` | Standard, höchste Scan-Sicherheit |
| Abgerundet | `rounded` | weicher, ohne Lesbarkeit zu kosten |
| Punkte | `dots` | modern, deutlich sichtbarer Unterschied |
| Fließend | `classy-rounded` | markantester der vier |
Dazu die Eye-Styles (`cornersSquareOptions`, `cornersDotOptions`) - eckig, abgerundet, Kreis. Die Augen machen optisch mehr aus als die Module selbst und kosten nichts extra, weil dieselbe Bibliothek sie mitbringt.
Vier Optionen statt aller sechs, weil die Auswahl selbst ein Verkaufsargument ist: Pro fühlt sich vollständig an, Business hat sichtbar mehr. Sechs fast identische Varianten wirken dagegen wie eine lange Liste ohne Unterschied.
### 6.3 Business - vollständiger Designer
Portierung des Renderers aus `qr-design-showcase.html` in eine Komponente `src/components/generator/AdvancedQRRenderer.tsx`. Was damit dazukommt:
- **Modulformen:** diamond, star, hexagon, plus, mosaic, liquid zusätzlich zu den vier aus Pro
- **Eye-Frames getrennt:** eckig, abgerundet, Kreis, Blatt, Flower/Squircle, Hexagon
- **Eye-Balls getrennt:** eckig, abgerundet, Kreis, Diamant, Stern, Hexagon
- **Farbverläufe:** linear und radial statt einfarbig
- **Rahmen mit Label:** weiße Karte mit Schatten, Label oben oder unten (z. B. „Speisekarte", „Jetzt bewerten")
- **Logo-Formen:** Punkt, Quadrat, Herz, Blitz, WLAN-Symbol
- **Fehlerkorrektur-Stufe** frei wählbar (L/M/Q/H)
**Erweiterungsvorschläge über die Showcase hinaus**, weil beim Business-Kunden die Wiederholbarkeit zählt und nicht die einzelne Spielerei:
1. **Design-Vorlagen speichern.** Ein einmal gebautes Design als benanntes Preset sichern und auf neue Codes anwenden. Das ist für eine Agentur mit mehreren Kunden der eigentliche Wert - nicht die Sternform, sondern dass Kunde A immer gleich aussieht.
2. **Preset auf den Bulk-Flow anwenden.** Ein Preset auswählen und alle 500 Codes eines Uploads tragen es. Das verbindet Phase 2 mit diesem Feature und ist das erste Argument für Business, das nicht nur eine Zahl ist.
3. **Print-Vorschau in Originalgröße.** Der Code in 2×2 cm, 4×4 cm und 10×10 cm dargestellt, mit Hinweis ab wann es kritisch wird. Passt zu den Druckangaben, die im Offer Brief bereits als verifizierter Fakt geführt werden.
### 6.4 Scanbarkeit - der Teil, der nicht optional ist
Exotische Formen füllen weniger Fläche pro Modul. Star, plus und mosaic reduzieren die Kontrastfläche spürbar, und in Kombination mit einem Logo in der Mitte und einer kleinen Druckgröße kippt die Lesbarkeit. Die Showcase weist bei „H (30%)" schon auf „empfohlen bei Logo" hin - das gehört ins Produkt übernommen, und zwar strenger:
- Bei star, plus, mosaic oder liquid automatisch auf Fehlerkorrektur **H** hochsetzen und das sichtbar begründen
- Bei Logo plus exotischer Form eine Warnung mit Mindestdruckgröße
- Ein **Test-Scan-Hinweis** vor dem Download: *„Scanne den Code einmal mit deinem eigenen Handy, bevor du ihn in Druck gibst."*
Das ist kein Beiwerk, sondern der Proof-Layer aus der Positionierung an einer Stelle, an der es konkret wird. Ein Anbieter, der vor seinem eigenen Feature warnt, wenn es kritisch wird, belegt Belief 5 besser als jede Aussage über Transparenz.
### 6.5 Was die Umstellung beim Pricing kostet
Ehrlich gesagt: Farben waren laut Offer Brief §10 einer von genau **drei** Gründen, heute auf Pro zu wechseln (die anderen zwei: mehr als 3 aktive Codes, Device- und Location-Breakdown). Diesen Grund gibt man mit dieser Änderung auf.
Das ist meiner Einschätzung nach trotzdem richtig, aber aus einem anderen Grund als „großzügiger sein":
Farben sind kein guter Upgrade-Anlass, weil man sie schon im kostenlosen Zustand *sehen* will, um zu verstehen, ob das Produkt etwas taugt. Ein Free-Nutzer, der nur schwarze Codes bauen kann, hält das Produkt für ein Basiswerkzeug - und bewertet es entsprechend, auch in Vergleichen. Formen sind der bessere Verkäufer, weil der Unterschied größer aussieht und man ihn in der Vorschau zeigen kann, bevor man ihn freischaltet: der Nutzer wählt „Punkte", sieht das Ergebnis live, und erst der Download fragt nach Pro.
**Folgeänderungen an der Preis-Copy** in `src/i18n/en.json` und `src/app/(main)/(marketing)/pricing/PricingClient.tsx`:
| Plan | vorher | nachher |
|---|---|---|
| Free | „Standard QR design templates" | „Deine Farben - Vorder- und Hintergrund frei wählbar" |
| Pro | „Custom branding (colors & logos)" | „4 Modulformen, eigene Eye-Styles und dein Logo im Code" |
| Business | (kein Design-Punkt) | „Voller Designer: 10 Formen, Verläufe, Rahmen mit Label, speicherbare Design-Vorlagen" |
Dieselbe Anpassung auf `/custom-qr-code-generator`, dessen Meta-Description aktuell „Put your logo and brand colors into the code itself" sagt - das bleibt korrekt, weil Logo weiterhin Pro ist, aber die Seite sollte künftig zeigen, was auf welcher Stufe geht.
### 6.6 Keine Datenbankänderung nötig
`QRCode.style` ist bereits `Json` (siehe `prisma/schema.prisma`). Alle neuen Felder - `dotType`, `eyeFrameType`, `eyeBallType`, `gradient`, `frameLabel`, `logoShape`, `ecLevel` - passen ohne Schemaänderung hinein. Bestehende Codes haben die Felder schlicht nicht und fallen auf die Defaults zurück.
**Ausnahme:** Wenn die speicherbaren Design-Vorlagen aus 6.3 kommen sollen, braucht es eine eigene Tabelle. SQL dafür steht unten, ist aber optional und kann später nachgezogen werden.
---
## SQL - alle nötigen Datenbankänderungen
Nach der Policy in `CLAUDE.md`: keine Prisma-Migrationen, nur direkte Statements.
```sql
-- Marker für die neuen verhaltensbasierten Retention-Mails
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "limitReachedNudgeSentAt" TIMESTAMP(3);
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "firstScanNudgeSentAt" TIMESTAMP(3);
-- Index für den Cron: sucht Nutzer mit erstem Scan, die die Mail noch nicht haben
CREATE INDEX IF NOT EXISTS "User_firstScanAt_firstScanNudgeSentAt_idx"
ON "User" ("firstScanAt", "firstScanNudgeSentAt");
-- Index für die neue Limit-Query (zählt nur noch ACTIVE)
CREATE INDEX IF NOT EXISTS "QRCode_userId_type_status_idx"
ON "QRCode" ("userId", "type", "status");
```
Für Phase 6 ist **nichts** davon nötig - `QRCode.style` ist bereits `Json`. Optional, nur falls die speicherbaren Design-Vorlagen aus 6.3 gebaut werden:
```sql
CREATE TABLE IF NOT EXISTS "QRDesignPreset" (
"id" TEXT PRIMARY KEY,
"userId" TEXT NOT NULL REFERENCES "User"("id") ON DELETE CASCADE,
"name" TEXT NOT NULL,
"style" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "QRDesignPreset_userId_idx" ON "QRDesignPreset" ("userId");
CREATE UNIQUE INDEX IF NOT EXISTS "QRDesignPreset_userId_name_key" ON "QRDesignPreset" ("userId", "name");
```
Ausführen über `npm run docker:db` oder:
```bash
docker-compose exec db psql -U postgres -d qrmaster -c 'ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "limitReachedNudgeSentAt" TIMESTAMP(3);'
```
Danach `prisma/schema.prisma` im Block `// Retention email tracking` ergänzen:
```prisma
limitReachedNudgeSentAt DateTime?
firstScanNudgeSentAt DateTime?
```
und beim Model `QRCode` den Index:
```prisma
@@index([userId, type, status])
```
Abschließend `npx prisma generate` - kein `migrate`.
### Prüf-Queries vor dem Deploy
```sql
-- Wie viele Free-Nutzer bekommen durch die ACTIVE-Umstellung Slots frei?
SELECT COUNT(DISTINCT u.id)
FROM "User" u
JOIN "QRCode" q ON q."userId" = u.id
WHERE u.plan = 'FREE' AND q.type = 'DYNAMIC' AND q.status = 'PAUSED';
-- Wie viele Nutzer bekämen die Erster-Scan-Mail beim ersten Cron-Lauf?
SELECT COUNT(*) FROM "User"
WHERE "firstScanAt" IS NOT NULL AND "firstScanNudgeSentAt" IS NULL;
```
Die zweite Zahl ist wichtig: Beim ersten Lauf würde die neue Mail an **alle** Bestandsnutzer mit Scan-Historie gehen - auch an solche, deren erster Scan Monate zurückliegt. Das wäre kein Anlass mehr, sondern Spam. Der Cron braucht deshalb ein Zeitfenster, etwa `firstScanAt > now() - interval '7 days'`, oder man setzt die Spalte bei Bestandsnutzern einmalig vor:
```sql
UPDATE "User" SET "firstScanNudgeSentAt" = now()
WHERE "firstScanAt" IS NOT NULL AND "firstScanAt" < now() - interval '7 days';
```
---
## Reihenfolge
| # | Was | Aufwand | DB |
|---|---|---|---|
| 1 | Limit-Query auf ACTIVE, beide Endpoints | klein | Index |
| 2 | Limit-Modal ohne Redirect | mittel | nein |
| 3 | Direkt-Checkout plus `returnPath` und localStorage-Wiederherstellung | mittel | nein |
| 4 | `bulk-creation`: Fehlerbehandlung und Ergebnis-Panel | klein | nein |
| 5 | Popup-Copy plus `variant` pro Tool-Seite | klein | nein |
| 6 | Retention: Trigger umstellen, Tabellen-Zeile raus, Betreff Tag 3 | klein | 1 Spalte |
| 7 | Erster-Scan-Mail | mittel | 1 Spalte |
| 8 | Tag-30 auf Scan-Daten, unbelegten Satz klären | klein | nein |
| 9 | Farben ab Free freischalten, Pricing-Copy nachziehen | klein | nein |
| 10 | Pro-Formen über `qr-code-styling`, Renderer-Tausch in `/create` | mittel | nein |
| 11 | Business-Designer, Showcase-Renderer portieren | groß | nein |
| 12 | Design-Vorlagen speichern und auf Bulk anwenden | mittel | Tabelle |
1 bis 3 gehören zusammen und sollten gemeinsam live gehen. 4 und 5 sind unabhängig und können jederzeit dazwischen. 6 bis 8 brauchen die SQL-Statements und den einmaligen `UPDATE` für Bestandsnutzer.
**9 ist der schnellste Gewinn im ganzen Dokument** - eine Zeile (`canCustomizeColors = true`) plus Copy-Anpassung, und das Produkt wirkt für jeden neuen Nutzer sofort weniger nach Basiswerkzeug. Sie sollte nicht auf 10 warten.
10 ist der Renderer-Tausch und damit der Punkt, an dem `/create` einmal gründlich getestet werden muss - Vorschau, Download PNG, Download SVG, Logo-Overlay und die Frame-Optionen hängen alle daran.
11 und 12 sind der eigentliche Business-Ausbau. 12 ist dabei wertvoller als 11: eine Agentur kauft nicht die Sternform, sondern dass Kunde A auf 500 Codes gleich aussieht.