Files
QR-master/PLAN_CRO_UND_RETENTION_2026-07-27.md

24 KiB
Raw Blame History

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

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:

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:

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

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.

-- 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:

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:

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:

  limitReachedNudgeSentAt DateTime?
  firstScanNudgeSentAt    DateTime?

und beim Model QRCode den Index:

  @@index([userId, type, status])

Abschließend npx prisma generate - kein migrate.

Prüf-Queries vor dem Deploy

-- 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:

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.