Files
QR-master/TODO.md
2026-04-02 11:37:58 +02:00

462 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# QR Master — Growth TODO
Based on: 3-month audit (PostHog + GSC + AI citations + full codebase review, April 2026)
Domain age at audit: ~3 months | Visitors (90d): 169 | AI citations (33d): 1,849
---
## How to use this file
Sections are ordered by impact-per-hour-of-work. Don't skip to section 5 before section 1.
Each task has the exact file to touch and what to change. No hand-waving.
---
## Section 1 — Retention (do this week, nothing else matters until it's done)
Cohort data shows ~10% week-1 retention, near-zero by week 3. Root cause: **no email is sent after signup**. Zero. The `sendWelcomeEmail` function does not exist. Users sign up and hear nothing.
### 1.1 — Welcome email sequence (3 emails)
**File to edit:** `src/lib/email.ts`
Add three new exported functions after the existing `sendPasswordResetEmail`:
```ts
// Email 1: Sent immediately on signup
export async function sendWelcomeEmail(email: string, name: string) {}
// Email 2: Sent on Day 3 if user has 0 QR codes (check via cron or on login)
export async function sendActivationNudgeEmail(email: string, name: string) {}
// Email 3: Sent on Day 7 if user has ≥1 QR code but is still on FREE plan
export async function sendUpgradeNudgeEmail(email: string, name: string, qrCount: number) {}
```
**Email 1 — Welcome (Day 0)**
- Subject: `Your QR Master account is ready`
- Body: One action only → create first QR code. Link to `/create`.
- Mention the 3 free dynamic codes.
- 45 sentences max, plain layout.
- Sender: `Timo from QR Master <timo@qrmaster.net>`
**Email 2 — Activation nudge (Day 3, no QR codes created)**
- Subject: `Still haven't made your first QR code?`
- Trigger condition: `user.createdAt < now - 3 days AND qrCodes.count === 0`
- Body: One screenshot or step-by-step (3 steps). One CTA → `/create`.
- Don't send if they've already created one.
**Email 3 — Upgrade nudge (Day 7, has codes, still FREE)**
- Subject: `You've created {n} QR codes — here's what you're missing`
- Trigger condition: `qrCodes.count >= 1 AND plan === 'FREE' AND createdAt < now - 7 days`
- Body: 3 specific things they can't do on free: analytics breakdown, custom branding, more than 3 codes.
- CTA → `/pricing`
**File to edit:** `src/app/(main)/api/auth/signup/route.ts`
Add `sendWelcomeEmail` call after the user is created (line ~88, after `db.user.create`):
```ts
// After db.user.create(...)
try {
await sendWelcomeEmail(user.email, user.name ?? 'there');
} catch (emailError) {
// Don't fail signup if email fails
console.error('Welcome email failed:', emailError);
}
```
**For emails 2 and 3:** Either add a cron job (`/api/cron/retention-emails`) or check + send on each login in `/api/auth/simple-login` route. Cron is cleaner. Vercel cron syntax:
```json
// vercel.json
{
"crons": [{ "path": "/api/cron/retention-emails", "schedule": "0 10 * * *" }]
}
```
---
### 1.2 — Cancel flow (before Stripe portal)
**File to edit:** `src/app/(main)/(app)/settings/page.tsx`
Right now: button → Stripe portal. Users cancel without any friction or reason capture.
Replace the `Manage Subscription` button with a handler that opens a modal first:
```tsx
// Add state
const [showCancelModal, setShowCancelModal] = useState(false);
const [cancelReason, setCancelReason] = useState('');
// Replace direct portal redirect with:
const handleManageSubscription = () => {
if (plan !== 'FREE') {
setShowCancelModal(true); // intercept — show exit survey first
} else {
// FREE users can just go to portal normally
openStripePortal();
}
};
```
**Cancel modal — 4 steps:**
1. **Survey:** "Before you go — what's the main reason?" (radio: too expensive / not using it / missing feature / other)
2. **Save offer** based on reason:
- `too_expensive`*"What if we gave you 25% off for 2 months?"* → button calls `/api/stripe/apply-discount`
- `not_using`*"Want to pause instead of cancel?"* → pause link or info
- `missing_feature`*"Tell us what's missing"* → textarea → submit to `/api/feedback`, then let them proceed
- `other` → short text → submit to `/api/feedback`, then proceed
3. **Confirm:** "Still want to cancel? Your plan stays active until [end of billing period]." → button opens Stripe portal
4. **Post-cancel:** Stripe webhook already exists at `/api/stripe/webhook` — add a case for `customer.subscription.deleted` that logs the cancellation reason and (optionally) triggers a win-back email in 30 days.
**Save the reasons to DB.** Add a `cancellationReason` field to the User model in `prisma/schema.prisma` so you actually learn why people leave.
---
## Section 2 — Signup flow (23 hours of work, immediate conversion lift)
Current form: Name → Email → Password → Confirm Password → [Create Account] → divider → Google button
Problem: Google should be first. Confirm Password should not exist.
### 2.1 — Move Google OAuth above the form
**File:** `src/app/(main)/(auth)/signup/SignupClient.tsx`
Move the Google button block (currently after the `<form>`) to above the `<form>`. The divider "Or continue with" becomes "Or sign up with email" below the Google button.
```tsx
// New order in the Card:
<CardContent className="p-6">
{/* Google FIRST */}
<Button type="button" variant="outline" className="w-full" onClick={handleGoogleSignIn}>
{/* Google SVG */} Sign up with Google
</Button>
<div className="relative my-6">
{/* divider */}
<span className="px-2 bg-white text-gray-500">Or sign up with email</span>
</div>
{/* Email form SECOND */}
<form onSubmit={handleSubmit}>
...
</form>
</CardContent>
```
### 2.2 — Remove the Confirm Password field
**File:** `src/app/(main)/(auth)/signup/SignupClient.tsx`
Delete the `confirmPassword` state, the `confirmPassword` Input field, and the `if (password !== confirmPassword)` check. Replace with a password visibility toggle on the Password field instead:
```tsx
const [showPassword, setShowPassword] = useState(false);
// In the Input:
<div className="relative">
<Input
label="Password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-9 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
```
### 2.3 — Add value reinforcement to the signup page
**File:** `src/app/(main)/(auth)/signup/SignupClient.tsx`
Change the subtitle under "Create Account" from:
> *"Start creating QR codes in seconds"*
To:
> *"No credit card required — 3 dynamic QR codes free forever"*
Also remove the prominent "← Back to Home" bordered button. It's visually competing with the form. Replace with a small text link at the bottom of the card:
```tsx
<p className="text-center text-xs text-gray-400 mt-4">
Already have an account? <Link href="/login">Sign in</Link> · <Link href="/">Back to home</Link>
</p>
```
---
## Section 3 — Homepage copy (half a day)
### 3.1 — Headline rewrite
**File:** `src/i18n/en.json`
Current: `"title": "Create QR Codes That Work Everywhere"`
Replace with one of (pick based on your gut for the audience):
```json
"title": "The Free QR Code Generator That Doesn't Expire"
```
or
```json
"title": "Track Every Scan. Update Every Link. Free Forever."
```
Subtitle — current: *"Generate static and dynamic QR codes with tracking, custom branding, and bulk generation. Free forever."*
Replace:
```json
"subtitle": "Dynamic QR codes you can edit anytime without reprinting — see exactly who scanned, where, and on what device. Starts free, no credit card."
```
### 3.2 — Feature bullets: features → outcomes
**File:** `src/i18n/en.json`, `hero.features` array
```json
"features": [
"Change your link anytime — no reprinting needed",
"See scan location, device, and time of day",
"Your QR codes work forever, even on the free plan",
"Match your brand: custom colors in under a minute"
]
```
### 3.3 — Hero right column: replace animation with product screenshot
**File:** `src/components/marketing/Hero.tsx`
The flipping cards grid (lines ~130175) should be replaced with either:
- A static screenshot of the analytics dashboard (real scan data, anonymized)
- A live mini-generator: paste a URL → QR code renders inline (highest conversion)
A live mini-generator in the hero is the highest-impact change on the whole homepage. Even a simplified version that just renders a basic QR code and links to `/create` for customization would work.
### 3.4 — Add a social proof number to the hero
**File:** `src/components/marketing/Hero.tsx` or `src/components/marketing/StatsStrip.tsx`
Add one concrete number directly in or immediately below the hero — before the fold. Options:
- `"1M+ QR codes generated"` (if true or close)
- `"Trusted by 500+ businesses"` (if defensible)
- `"Cited by ChatGPT, Perplexity & Google AI"` (actually true based on your AI data — this is a unique claim almost no competitor can make)
The last one is genuinely differentiated and verified.
---
## Section 4 — Content: the 5 posts that will move GSC rankings
These are ordered by current GSC impression data — they already have Google's attention, they just need the content to justify moving up.
### 4.1 — Barcode generator companion post
**Current situation:** `/tools/barcode-generator` has 1,160 GSC impressions at position 71. No blog post supports it.
**Create:** `src/app/(main)/(marketing)/blog/barcode-vs-qr-code/` (or add to `src/lib/blog-data.ts`)
**Title:** *"Barcode vs QR Code: What's the Difference and When to Use Each"*
**Target query:** `barcode vs qr code` / `difference between barcode and qr code`
**Must include:** a comparison table (scanners required, data capacity, use cases, editability), a "when to use each" section, internal links to `/tools/barcode-generator` and `/dynamic-qr-code-generator`.
### 4.2 — Teams QR code blog post
**Current situation:** `/tools/teams-qr-code` gets 20 PostHog visitors with 0% bounce and ranks ~position 22 in GSC — the closest page to page 1 you have.
**Create:** blog post targeting `microsoft teams qr code` / `teams meeting qr code`
**Title:** *"How to Create a Microsoft Teams QR Code for Instant Meeting Joins"*
**Internal link:** back to `/tools/teams-qr-code` from the post and vice versa (the tool page should link to this post for content depth).
### 4.3 — WiFi QR code post update
**Current situation:** `/tools/wifi-qr-code` ranks ~position 44 (close to page 4). 14 PostHog visitors, 0% bounce.
**File:** The wifi tool page has German keywords mixed into English metadata (`wlan qr code erstellen`, `wifi passwort qr code`). Decide: is this page targeting English or German? Mixed intent hurts both. For the English version, clean up to English-only keywords. For German, create a dedicated `/de/` route (there's already a German path at `(marketing-de)`).
**Action:** Add a content section below the WiFi generator tool with at minimum: a "common WiFi QR code uses" section (restaurants, hotels, offices, Airbnb), a FAQ block (35 questions), and an internal link to the main QR generator.
### 4.4 — Fix the 2025-dated content
**File:** `src/lib/blog-data.ts`
The post with slug `qr-code-tracking-guide-2025` has `2025` in its URL. Update:
- `slug`: change to `qr-code-tracking-guide` or `qr-code-tracking-guide-2026`
- `title`: update year references in the content
- `dateModified`: set to current date
- Add a redirect in `next.config.mjs` from the old slug to the new one:
```js
async redirects() {
return [
{
source: '/blog/qr-code-tracking-guide-2025',
destination: '/blog/qr-code-tracking-guide-2026',
permanent: true,
},
];
},
```
### 4.5 — "Best QR code generator 2026" — make it actually compete
**File:** `src/lib/blog-data.ts` — post slug `best-qr-code-generator-2026` exists but has ~9 GSC position and 0 clicks from 38 impressions.
This post needs to be the most comprehensive comparison page on the site. It currently isn't doing the job. It needs:
- A feature comparison table: QR Master vs Bitly vs QR Code Generator vs Beaconstac (columns: free plan limits, dynamic QR, analytics, branding, bulk, pricing)
- Honest pros/cons for each (being fake-neutral is immediately obvious and AI systems penalise it)
- A `FAQPage` schema block
- Statistics with cited sources (e.g., "QR code scans grew X% in 2025 — [source]")
- `dateModified` updated to current month
This post alone, done properly, is the highest-value AI-citation target on the whole site.
---
## Section 5 — AI SEO quick fixes (12 hours)
These are code-level changes, not content work.
### 5.1 — Add cited sources to every statistic in blog posts
**File:** `src/lib/blog-data.ts`
Every blog post `content` field that contains a statistic without a source link needs one. Example fix in the restaurant post:
```html
<!-- Before -->
<p>over 60% of restaurants that adopted QR menus during 20202021 kept them afterward</p>
<!-- After -->
<p>over 60% of restaurants that adopted QR menus during 20202021 kept them afterward
<a href="https://nationalrestaurantassociation.org/..." target="_blank" rel="noopener">[NRA, 2022]</a></p>
```
Per Princeton GEO research: adding cited sources increases AI citation rate by +40%. This is the single highest-ROI AI SEO action available.
### 5.2 — Add definition blocks to tool pages
**Which pages:** barcode generator, WiFi QR generator, Teams QR generator, URL QR generator
**What to add:** A one-paragraph definition in the first `<p>` tag that directly answers "What is a [X] generator?" in 4060 words. AI systems extract from the opening paragraph first.
Example for barcode generator:
```html
<p>A barcode generator creates machine-readable linear barcodes (EAN-13, UPC-A, Code 128)
from numeric or alphanumeric data. Unlike QR codes, barcodes store data in parallel lines
and are scanned by dedicated readers. Use this free tool to generate printable barcodes
for retail products, inventory labels, or shipping.</p>
```
### 5.3 — Add `dateModified` display to blog post pages
**File:** whichever component renders the blog post header — search for `publishDate` or `datePublished` usage in `src/app/(main)/(marketing)/blog/`
AI tools penalise undated content. Every post already has `dateModified` in `blog-data.ts` — it just needs to render visibly on the page. Add "Last updated: [date]" next to the author line. It's one line of JSX.
---
## Section 6 — Pricing page (1 hour)
**File:** `src/i18n/en.json`, pricing section + the Pricing component
### 6.1 — Add upgrade trigger context to the Pro plan
Under the Pro plan title, add a single explanatory sentence that explains *when* someone should upgrade:
```json
"pro": {
"trigger": "When you hit 3 dynamic QR codes or need scan analytics"
}
```
Render this in the Pricing component under the plan title, above the price.
### 6.2 — Free plan: explain the limit as a feature, not a restriction
Current: `"3 active dynamic QR codes (8 types available)"`
Rewrite: `"3 dynamic QR codes — enough to start. Unlimited static codes."`
The current copy leads with the limit. Lead with what they get.
### 6.3 — Annual pricing
There's no annual discount mentioned anywhere in the pricing UI. If Stripe supports annual billing, surface it. A toggle (Monthly / Annual — save 20%) on the pricing page typically lifts plan revenue by 1530% by locking in longer commitments. Check `src/lib/stripe.ts` for whether annual price IDs exist.
---
## Section 7 — Dashboard first-run experience (23 hours)
### 7.1 — Empty state when user has 0 QR codes
**File:** `src/app/(main)/(app)/dashboard/page.tsx`
Currently, a new user lands on a dashboard with stats showing all zeros and an empty grid. There's a loading skeleton for QR codes but no empty state when `qrCodes.length === 0`.
Add an empty state component:
```tsx
{!loading && qrCodes.length === 0 && (
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl">
<QrCode className="w-12 h-12 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-700 mb-2">Create your first QR code</h3>
<p className="text-gray-500 mb-6 max-w-sm mx-auto">
You have 3 free dynamic QR codes. They redirect wherever you want and track every scan.
</p>
<Link href="/create">
<Button>Create QR Code it takes 90 seconds</Button>
</Link>
</div>
)}
```
### 7.2 — Remove the mock QR codes from the dashboard code
**File:** `src/app/(main)/(app)/dashboard/page.tsx`, lines ~45100
There's a `mockQRCodes` array defined but it doesn't appear to be rendered (the real fetch path is used). Verify and remove the dead code to keep the file clean.
### 7.3 — Dashboard subtitle should be contextual
**File:** `src/app/(main)/(app)/dashboard/page.tsx`
Current subtitle: `{t('dashboard.subtitle')}` → "Manage and track your QR codes"
For users with 0 QR codes, show: *"Start here — create your first QR code in under 2 minutes"*
For users with QR codes, show the current text.
```tsx
<p className="text-gray-600 mt-2">
{qrCodes.length === 0
? 'Start here — create your first QR code in under 2 minutes'
: t('dashboard.subtitle')}
</p>
```
---
## Metrics to watch
| Thing changed | Metric to check | Where |
|---|---|---|
| Welcome email | Cohort retention week 1 | PostHog → Retention |
| Signup form (Google first, remove confirm PW) | Signup completion rate | PostHog → Funnels |
| Cancel flow | Cancelled users / month | Stripe dashboard |
| Hero headline | Bounce rate on `/` | PostHog → Paths |
| Barcode + Teams blog posts | GSC impressions & position | Search Console |
| Cited statistics in posts | AI citations/day | AI perf CSV |
| Empty state dashboard | `/create` conversion from dashboard | PostHog → Paths |
---
## What not to do right now
- Don't build new QR code types — the product breadth is already sufficient for the current user base
- Don't chase Google Ads or paid social — organic signals are still too early to know which pages convert, and CAC will be high with a 10% retention rate
- Don't redesign the homepage visually — copy and CRO changes will outperform a design refresh at this stage
- Don't add more blog posts until the existing ones have cited sources and proper date signals (Sections 4.4 and 5.1)