Email retention
This commit is contained in:
@@ -1,34 +1,34 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(docker-compose:*)",
|
"Bash(docker-compose:*)",
|
||||||
"Bash(docker container prune:*)",
|
"Bash(docker container prune:*)",
|
||||||
"Bash(npx prisma migrate dev:*)",
|
"Bash(npx prisma migrate dev:*)",
|
||||||
"Bash(npx prisma:*)",
|
"Bash(npx prisma:*)",
|
||||||
"Bash(npm run dev)",
|
"Bash(npm run dev)",
|
||||||
"Bash(timeout:*)",
|
"Bash(timeout:*)",
|
||||||
"Bash(taskkill:*)",
|
"Bash(taskkill:*)",
|
||||||
"Bash(npx kill-port:*)",
|
"Bash(npx kill-port:*)",
|
||||||
"Bash(docker compose:*)",
|
"Bash(docker compose:*)",
|
||||||
"Bash(curl -I https://fonts.googleapis.com)",
|
"Bash(curl -I https://fonts.googleapis.com)",
|
||||||
"Bash(wsl:*)",
|
"Bash(wsl:*)",
|
||||||
"Read(//c/Users/a931627/.ssh/**)",
|
"Read(//c/Users/a931627/.ssh/**)",
|
||||||
"Bash(ssh-keygen:*)",
|
"Bash(ssh-keygen:*)",
|
||||||
"Bash(cat:*)",
|
"Bash(cat:*)",
|
||||||
"Bash(git remote add:*)",
|
"Bash(git remote add:*)",
|
||||||
"Bash(git push:*)",
|
"Bash(git push:*)",
|
||||||
"Bash(git remote set-url:*)",
|
"Bash(git remote set-url:*)",
|
||||||
"Bash(npm install:*)",
|
"Bash(npm install:*)",
|
||||||
"Bash(npm run build:*)",
|
"Bash(npm run build:*)",
|
||||||
"Bash(ls:*)",
|
"Bash(ls:*)",
|
||||||
"Bash(curl:*)",
|
"Bash(curl:*)",
|
||||||
"Bash(echo \"\n\n## CSRF Debug aktiviert!\n\nBitte teste jetzt:\n1. Browser zu http://localhost:3050/create\n2. Dynamic QR Code erstellen versuchen\n3. Server-Logs zeigen jetzt [CSRF Debug] Output\n\nIch sehe dann:\n- Ob headerToken vorhanden ist\n- Ob cookieToken vorhanden ist \n- Ob sie übereinstimmen\n\n---\n\nStripe Portal 500 Error ist separates Problem:\nhttps://dashboard.stripe.com/test/settings/billing/portal\n→ Customer Portal Configuration muss erstellt werden\n\")",
|
"Bash(echo \"\n\n## CSRF Debug aktiviert!\n\nBitte teste jetzt:\n1. Browser zu http://localhost:3050/create\n2. Dynamic QR Code erstellen versuchen\n3. Server-Logs zeigen jetzt [CSRF Debug] Output\n\nIch sehe dann:\n- Ob headerToken vorhanden ist\n- Ob cookieToken vorhanden ist \n- Ob sie übereinstimmen\n\n---\n\nStripe Portal 500 Error ist separates Problem:\nhttps://dashboard.stripe.com/test/settings/billing/portal\n→ Customer Portal Configuration muss erstellt werden\n\")",
|
||||||
"Bash(pkill:*)",
|
"Bash(pkill:*)",
|
||||||
"Skill(shadcn-ui)",
|
"Skill(shadcn-ui)",
|
||||||
"Bash(find:*)",
|
"Bash(find:*)",
|
||||||
"Bash(ls -la \"/c/Users/User/Documents/QR-master/src/app/\\(main\\)/\\(marketing\\)/\")"
|
"Bash(ls -la \"/c/Users/User/Documents/QR-master/src/app/\\(main\\)/\\(marketing\\)/\")"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
.env.example
15
.env.example
@@ -12,6 +12,15 @@ NEXTAUTH_SECRET=CHANGE_ME
|
|||||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
REDIS_URL=redis://redis:6379
|
REDIS_URL=redis://redis:6379
|
||||||
IP_SALT=CHANGE_ME_SALT
|
IP_SALT=CHANGE_ME_SALT
|
||||||
ENABLE_DEMO=true
|
ENABLE_DEMO=true
|
||||||
|
|
||||||
|
# SMTP (for welcome + retention emails via nodemailer)
|
||||||
|
SMTP_HOST=smtp.qrmaster.net
|
||||||
|
SMTP_PORT=465
|
||||||
|
SMTP_USER=timo@qrmaster.net
|
||||||
|
SMTP_PASS=
|
||||||
|
|
||||||
|
# Cron job protection — generate with: openssl rand -base64 32
|
||||||
|
CRON_SECRET=
|
||||||
|
|||||||
@@ -1,249 +1,249 @@
|
|||||||
# QR Master — Deep Analysis & Growth Potential
|
# QR Master — Deep Analysis & Growth Potential
|
||||||
**Date:** April 1, 2026 | **Project Age:** 3 months (launched Jan 1, 2026)
|
**Date:** April 1, 2026 | **Project Age:** 3 months (launched Jan 1, 2026)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Executive Summary
|
## Executive Summary
|
||||||
|
|
||||||
QR Master is a young but well-built QR code SaaS entering a **$1–7B market growing at 16%+ CAGR**. After just 3 months, the numbers tell an interesting story: explosive impression growth in Google Search (0 → 4,100/month), strong AI citation momentum (1,849 citations across 11 pages), and 169 unique visitors in the last 90 days with promising engagement metrics (8m 36s avg session, 16% bounce rate). However, organic search clicks remain very low, and there are zero paying customers visible yet. The opportunity is real, but execution over the next 6 months will determine everything.
|
QR Master is a young but well-built QR code SaaS entering a **$1–7B market growing at 16%+ CAGR**. After just 3 months, the numbers tell an interesting story: explosive impression growth in Google Search (0 → 4,100/month), strong AI citation momentum (1,849 citations across 11 pages), and 169 unique visitors in the last 90 days with promising engagement metrics (8m 36s avg session, 16% bounce rate). However, organic search clicks remain very low, and there are zero paying customers visible yet. The opportunity is real, but execution over the next 6 months will determine everything.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Your Numbers — What They Tell Me
|
## 1. Your Numbers — What They Tell Me
|
||||||
|
|
||||||
### Google Search Console (Jan–Mar 2026)
|
### Google Search Console (Jan–Mar 2026)
|
||||||
|
|
||||||
| Metric | January | February | March | Growth |
|
| Metric | January | February | March | Growth |
|
||||||
|--------|---------|----------|-------|--------|
|
|--------|---------|----------|-------|--------|
|
||||||
| Impressions | 158 | 44 | 4,115 | **+2,504%** |
|
| Impressions | 158 | 44 | 4,115 | **+2,504%** |
|
||||||
| Clicks | 0 | 0 | 23 | From zero |
|
| Clicks | 0 | 0 | 23 | From zero |
|
||||||
| Avg Position | ~1.3 | ~3.3 | ~68.4 | Widened (more keywords) |
|
| Avg Position | ~1.3 | ~3.3 | ~68.4 | Widened (more keywords) |
|
||||||
|
|
||||||
**What this means:** Google discovered your site in late January, but the real indexing explosion happened in March. The jump from 44 impressions in February to 4,115 in March is dramatic — this is the "Google sandbox" lifting. The average position being ~68 means most of your keywords are on pages 6–7 of Google. That's normal for a 3-month-old domain, but it also means you're not getting meaningful organic traffic yet from competitive terms.
|
**What this means:** Google discovered your site in late January, but the real indexing explosion happened in March. The jump from 44 impressions in February to 4,115 in March is dramatic — this is the "Google sandbox" lifting. The average position being ~68 means most of your keywords are on pages 6–7 of Google. That's normal for a 3-month-old domain, but it also means you're not getting meaningful organic traffic yet from competitive terms.
|
||||||
|
|
||||||
**Top performing pages by impressions:**
|
**Top performing pages by impressions:**
|
||||||
|
|
||||||
| Page | Impressions | Clicks | Avg Position |
|
| Page | Impressions | Clicks | Avg Position |
|
||||||
|------|-------------|--------|-------------|
|
|------|-------------|--------|-------------|
|
||||||
| Homepage | 356 | 15 | 4.0 |
|
| Homepage | 356 | 15 | 4.0 |
|
||||||
| Barcode Generator | 1,160 | 4 | 71.4 |
|
| Barcode Generator | 1,160 | 4 | 71.4 |
|
||||||
| Dynamic QR Code Generator | 536 | 0 | 81.1 |
|
| Dynamic QR Code Generator | 536 | 0 | 81.1 |
|
||||||
| Restaurant Menu QR Blog | 425 | 0 | 80.9 |
|
| Restaurant Menu QR Blog | 425 | 0 | 80.9 |
|
||||||
| Geolocation QR Code | 281 | 0 | 73.2 |
|
| Geolocation QR Code | 281 | 0 | 73.2 |
|
||||||
| URL QR Code | 205 | 0 | 83.6 |
|
| URL QR Code | 205 | 0 | 83.6 |
|
||||||
| Twitter QR Code | 169 | 0 | 71.3 |
|
| Twitter QR Code | 169 | 0 | 71.3 |
|
||||||
|
|
||||||
**Key insight:** Your barcode generator page has 1,160 impressions but only 4 clicks because it's ranking at position 71. If you can move that to page 1, even position 8–10, you'd capture 20–50 clicks/day from that keyword cluster alone. Same story for "dynamic QR code generator" (536 impressions, position 81).
|
**Key insight:** Your barcode generator page has 1,160 impressions but only 4 clicks because it's ranking at position 71. If you can move that to page 1, even position 8–10, you'd capture 20–50 clicks/day from that keyword cluster alone. Same story for "dynamic QR code generator" (536 impressions, position 81).
|
||||||
|
|
||||||
### AI Citations (Google AI Overviews)
|
### AI Citations (Google AI Overviews)
|
||||||
|
|
||||||
| Week | Citations | Avg/Day | Trend |
|
| Week | Citations | Avg/Day | Trend |
|
||||||
|------|-----------|---------|-------|
|
|------|-----------|---------|-------|
|
||||||
| Week 9 (late Feb) | 123 | 30.8 | Baseline |
|
| Week 9 (late Feb) | 123 | 30.8 | Baseline |
|
||||||
| Week 10 | 328 | 46.9 | +167% |
|
| Week 10 | 328 | 46.9 | +167% |
|
||||||
| Week 11 | 343 | 49.0 | +5% |
|
| Week 11 | 343 | 49.0 | +5% |
|
||||||
| Week 12 | 514 | 73.4 | +50% |
|
| Week 12 | 514 | 73.4 | +50% |
|
||||||
| Week 13 | 541 | 77.3 | +5% |
|
| Week 13 | 541 | 77.3 | +5% |
|
||||||
|
|
||||||
**Total: 1,849 citations across up to 11 unique pages.**
|
**Total: 1,849 citations across up to 11 unique pages.**
|
||||||
|
|
||||||
This is genuinely impressive for a 3-month-old site. AI citations at 77/day means Google's AI is actively referencing your content when answering QR-related queries. This is a leading indicator — AI citations often precede organic ranking improvements. You're building topical authority faster than traditional SEO alone would deliver.
|
This is genuinely impressive for a 3-month-old site. AI citations at 77/day means Google's AI is actively referencing your content when answering QR-related queries. This is a leading indicator — AI citations often precede organic ranking improvements. You're building topical authority faster than traditional SEO alone would deliver.
|
||||||
|
|
||||||
### PostHog Analytics (Last 90 Days)
|
### PostHog Analytics (Last 90 Days)
|
||||||
|
|
||||||
| Metric | Value | Assessment |
|
| Metric | Value | Assessment |
|
||||||
|--------|-------|-----------|
|
|--------|-------|-----------|
|
||||||
| Unique Visitors | 169 | Low, but expected for 3 months |
|
| Unique Visitors | 169 | Low, but expected for 3 months |
|
||||||
| Page Views | 2,120 | ~12.5 pages/visitor — very high engagement |
|
| Page Views | 2,120 | ~12.5 pages/visitor — very high engagement |
|
||||||
| Sessions | 417 | 2.5 sessions/visitor average |
|
| Sessions | 417 | 2.5 sessions/visitor average |
|
||||||
| Session Duration | 8m 36s | Excellent — users are exploring deeply |
|
| Session Duration | 8m 36s | Excellent — users are exploring deeply |
|
||||||
| Bounce Rate | 16% | Outstanding (industry avg is 40-60%) |
|
| Bounce Rate | 16% | Outstanding (industry avg is 40-60%) |
|
||||||
|
|
||||||
**Traffic sources:**
|
**Traffic sources:**
|
||||||
|
|
||||||
| Channel | Visitors | Views |
|
| Channel | Visitors | Views |
|
||||||
|---------|----------|-------|
|
|---------|----------|-------|
|
||||||
| Referral | 100 | 803 |
|
| Referral | 100 | 803 |
|
||||||
| Organic Social | 64 | 89 |
|
| Organic Social | 64 | 89 |
|
||||||
| Direct | 17 | 1,123 |
|
| Direct | 17 | 1,123 |
|
||||||
| Organic Search | 6 | 70 |
|
| Organic Search | 6 | 70 |
|
||||||
|
|
||||||
**Critical insight:** Your 16% bounce rate and 8.5-minute session duration are exceptional. People who find your site actually use it. The problem isn't product quality — it's distribution. Only 6 visitors from organic search in 90 days confirms you're still in the early SEO growth phase.
|
**Critical insight:** Your 16% bounce rate and 8.5-minute session duration are exceptional. People who find your site actually use it. The problem isn't product quality — it's distribution. Only 6 visitors from organic search in 90 days confirms you're still in the early SEO growth phase.
|
||||||
|
|
||||||
**Retention concern:** The cohort data shows near-zero retention after week 1 for most cohorts. This is the biggest red flag — users come, try the tool, but don't come back. This is typical for free QR generator users, but it means conversion to paid will depend heavily on capturing value during that first session.
|
**Retention concern:** The cohort data shows near-zero retention after week 1 for most cohorts. This is the biggest red flag — users come, try the tool, but don't come back. This is typical for free QR generator users, but it means conversion to paid will depend heavily on capturing value during that first session.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Competitive Landscape
|
## 2. Competitive Landscape
|
||||||
|
|
||||||
### The Market You're Entering
|
### The Market You're Entering
|
||||||
|
|
||||||
The QR code generator market is valued at **$1.1–6.8B in 2025** (estimates vary by definition) and growing at **16–17% CAGR**. Dynamic QR codes specifically are the revenue driver, accounting for 55-64% of market value.
|
The QR code generator market is valued at **$1.1–6.8B in 2025** (estimates vary by definition) and growing at **16–17% CAGR**. Dynamic QR codes specifically are the revenue driver, accounting for 55-64% of market value.
|
||||||
|
|
||||||
### Key Competitors
|
### Key Competitors
|
||||||
|
|
||||||
| Company | Est. Revenue (2025) | Pricing | Key Strength |
|
| Company | Est. Revenue (2025) | Pricing | Key Strength |
|
||||||
|---------|-------------------|---------|-------------|
|
|---------|-------------------|---------|-------------|
|
||||||
| **Flowcode** | $15.7M | Free / $5-25/mo | VC-backed, design-focused, landing pages |
|
| **Flowcode** | $15.7M | Free / $5-25/mo | VC-backed, design-focused, landing pages |
|
||||||
| **Bitly** | $200M+ (link shortening + QR) | $8–199/mo | Brand recognition, link ecosystem |
|
| **Bitly** | $200M+ (link shortening + QR) | $8–199/mo | Brand recognition, link ecosystem |
|
||||||
| **Uniqode** (fka Beaconstac) | ~$10M est. | $5–99/mo | Enterprise (SOC2, HIPAA, ISO) |
|
| **Uniqode** (fka Beaconstac) | ~$10M est. | $5–99/mo | Enterprise (SOC2, HIPAA, ISO) |
|
||||||
| **QR Tiger** | ~$5M est. | $7–37/mo | SEO-aggressive, content marketing |
|
| **QR Tiger** | ~$5M est. | $7–37/mo | SEO-aggressive, content marketing |
|
||||||
| **Trycon/Scanova** | $7.2M | $5–49/mo | 2,000+ businesses, enterprise |
|
| **Trycon/Scanova** | $7.2M | $5–49/mo | 2,000+ businesses, enterprise |
|
||||||
| **Mobilo** | $5.4M | $4.99–14.99/mo | Digital business cards focus |
|
| **Mobilo** | $5.4M | $4.99–14.99/mo | Digital business cards focus |
|
||||||
| **Unitag** | $2.3M | Free / $9.90+/mo | European market, design tools |
|
| **Unitag** | $2.3M | Free / $9.90+/mo | European market, design tools |
|
||||||
| **QR Code Creator** | $1.5M | $4.99–14.99/mo | Long-established (since 2009) |
|
| **QR Code Creator** | $1.5M | $4.99–14.99/mo | Long-established (since 2009) |
|
||||||
| **QRCode Monkey** | Unknown | Free (ad-supported) | SEO dominant, free tools |
|
| **QRCode Monkey** | Unknown | Free (ad-supported) | SEO dominant, free tools |
|
||||||
|
|
||||||
### What This Means for QR Master
|
### What This Means for QR Master
|
||||||
|
|
||||||
The market has room. Even QR Code Creator, which has been around since 2009, only does $1.5M. The winner-take-all dynamics of pure SaaS don't apply as strongly here because QR codes are a utility — businesses often try 2-3 tools before settling. Key entry angles that still work: niche verticals (restaurants, real estate), better free tier to build SEO traffic, and superior analytics.
|
The market has room. Even QR Code Creator, which has been around since 2009, only does $1.5M. The winner-take-all dynamics of pure SaaS don't apply as strongly here because QR codes are a utility — businesses often try 2-3 tools before settling. Key entry angles that still work: niche verticals (restaurants, real estate), better free tier to build SEO traffic, and superior analytics.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. SEO Assessment
|
## 3. SEO Assessment
|
||||||
|
|
||||||
### Current State: Early but Promising
|
### Current State: Early but Promising
|
||||||
|
|
||||||
Your site is showing 700+ unique keywords with impressions — that's a lot of keyword surface area for a 3-month-old site. But almost all rankings are on pages 5-10 of Google (positions 50-100).
|
Your site is showing 700+ unique keywords with impressions — that's a lot of keyword surface area for a 3-month-old site. But almost all rankings are on pages 5-10 of Google (positions 50-100).
|
||||||
|
|
||||||
### Biggest Keyword Opportunities (High Impression, Improvable Position)
|
### Biggest Keyword Opportunities (High Impression, Improvable Position)
|
||||||
|
|
||||||
| Keyword | Monthly Impressions | Current Position | Difficulty |
|
| Keyword | Monthly Impressions | Current Position | Difficulty |
|
||||||
|---------|-------------------|-----------------|-----------|
|
|---------|-------------------|-----------------|-----------|
|
||||||
| barcode generator | 148+ | 74.8 | Very High |
|
| barcode generator | 148+ | 74.8 | Very High |
|
||||||
| dynamic qr code | 115+ | 80.3 | High |
|
| dynamic qr code | 115+ | 80.3 | High |
|
||||||
| dynamic qr code generator | 81+ | 85.8 | High |
|
| dynamic qr code generator | 81+ | 85.8 | High |
|
||||||
| restaurant qr code | 51+ | 75.0 | Medium |
|
| restaurant qr code | 51+ | 75.0 | Medium |
|
||||||
| qr code restaurant menu | 48+ | 82.8 | Medium |
|
| qr code restaurant menu | 48+ | 82.8 | Medium |
|
||||||
| twitter qr code | 46+ | 76.7 | Low-Medium |
|
| twitter qr code | 46+ | 76.7 | Low-Medium |
|
||||||
| instagram qr code generator | 35+ | 54.9 | Medium |
|
| instagram qr code generator | 35+ | 54.9 | Medium |
|
||||||
| bulk qr code generator | 28+ | 94.4 | Medium |
|
| bulk qr code generator | 28+ | 94.4 | Medium |
|
||||||
| vcard qr code generator | 9+ | 74.9 | Low-Medium |
|
| vcard qr code generator | 9+ | 74.9 | Low-Medium |
|
||||||
| teams qr code | 3+ | 34.7 | Low |
|
| teams qr code | 3+ | 34.7 | Low |
|
||||||
|
|
||||||
### Quick Wins (Already Close to Page 1)
|
### Quick Wins (Already Close to Page 1)
|
||||||
|
|
||||||
- **"teams qr code"** — Position 34.7 with dedicated tool page already built. You already have traffic to this page (20 visitors in PostHog). Push this to page 1.
|
- **"teams qr code"** — Position 34.7 with dedicated tool page already built. You already have traffic to this page (20 visitors in PostHog). Push this to page 1.
|
||||||
- **"zoom qr code"** — Position 40.3. Similar opportunity.
|
- **"zoom qr code"** — Position 40.3. Similar opportunity.
|
||||||
- **"qrmaster"** branded terms — Position 4.3 (already strong, but should be #1)
|
- **"qrmaster"** branded terms — Position 4.3 (already strong, but should be #1)
|
||||||
- **"qr master"** — Position 8.1. Should be #1 for your own brand.
|
- **"qr master"** — Position 8.1. Should be #1 for your own brand.
|
||||||
|
|
||||||
### What's Holding You Back
|
### What's Holding You Back
|
||||||
|
|
||||||
1. **Domain age (3 months):** Google inherently trusts older domains more. This improves automatically with time.
|
1. **Domain age (3 months):** Google inherently trusts older domains more. This improves automatically with time.
|
||||||
2. **Backlink profile:** Likely thin. Your referral traffic (100 visitors) suggests some links, but you need more authoritative ones.
|
2. **Backlink profile:** Likely thin. Your referral traffic (100 visitors) suggests some links, but you need more authoritative ones.
|
||||||
3. **Competition on head terms:** "QR code generator" is dominated by Bitly, QRCode Monkey, QR Tiger with DA 50+ domains. You won't rank for this soon.
|
3. **Competition on head terms:** "QR code generator" is dominated by Bitly, QRCode Monkey, QR Tiger with DA 50+ domains. You won't rank for this soon.
|
||||||
4. **Content depth vs. competitors:** QR Tiger and Uniqode have 100+ blog posts each. You're early in content production.
|
4. **Content depth vs. competitors:** QR Tiger and Uniqode have 100+ blog posts each. You're early in content production.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Revenue Potential — Realistic Projections
|
## 4. Revenue Potential — Realistic Projections
|
||||||
|
|
||||||
### Assumptions
|
### Assumptions
|
||||||
|
|
||||||
Based on your current pricing model (FREE / PRO / BUSINESS tiers) and market benchmarks:
|
Based on your current pricing model (FREE / PRO / BUSINESS tiers) and market benchmarks:
|
||||||
|
|
||||||
- **Free-to-paid conversion rate for QR tools:** 2-4% (industry benchmark)
|
- **Free-to-paid conversion rate for QR tools:** 2-4% (industry benchmark)
|
||||||
- **Average revenue per user (ARPU):** $10-20/month for SMB QR tools
|
- **Average revenue per user (ARPU):** $10-20/month for SMB QR tools
|
||||||
- **Churn rate:** 5-8% monthly for SMB SaaS
|
- **Churn rate:** 5-8% monthly for SMB SaaS
|
||||||
|
|
||||||
### Scenario Modeling
|
### Scenario Modeling
|
||||||
|
|
||||||
#### Conservative (SEO-only growth, no paid acquisition)
|
#### Conservative (SEO-only growth, no paid acquisition)
|
||||||
|
|
||||||
| Month | Monthly Visitors | Signups (15% CVR) | Paying Users (cum.) | MRR |
|
| Month | Monthly Visitors | Signups (15% CVR) | Paying Users (cum.) | MRR |
|
||||||
|-------|-----------------|-------------------|--------------------|----|
|
|-------|-----------------|-------------------|--------------------|----|
|
||||||
| Month 3 (now) | 170 | 39 | 0 | $0 |
|
| Month 3 (now) | 170 | 39 | 0 | $0 |
|
||||||
| Month 6 | 1,000 | 150 | 8 | $120 |
|
| Month 6 | 1,000 | 150 | 8 | $120 |
|
||||||
| Month 12 | 5,000 | 750 | 60 | $900 |
|
| Month 12 | 5,000 | 750 | 60 | $900 |
|
||||||
| Month 18 | 15,000 | 2,250 | 200 | $3,000 |
|
| Month 18 | 15,000 | 2,250 | 200 | $3,000 |
|
||||||
| Month 24 | 40,000 | 6,000 | 500 | $7,500 |
|
| Month 24 | 40,000 | 6,000 | 500 | $7,500 |
|
||||||
|
|
||||||
**Year 1 ARR: ~$10,800 | Year 2 ARR: ~$90,000**
|
**Year 1 ARR: ~$10,800 | Year 2 ARR: ~$90,000**
|
||||||
|
|
||||||
#### Moderate (SEO + content marketing + some link building)
|
#### Moderate (SEO + content marketing + some link building)
|
||||||
|
|
||||||
| Month | Monthly Visitors | Signups | Paying Users (cum.) | MRR |
|
| Month | Monthly Visitors | Signups | Paying Users (cum.) | MRR |
|
||||||
|-------|-----------------|---------|--------------------|----|
|
|-------|-----------------|---------|--------------------|----|
|
||||||
| Month 6 | 3,000 | 450 | 25 | $375 |
|
| Month 6 | 3,000 | 450 | 25 | $375 |
|
||||||
| Month 12 | 15,000 | 2,250 | 180 | $2,700 |
|
| Month 12 | 15,000 | 2,250 | 180 | $2,700 |
|
||||||
| Month 18 | 40,000 | 6,000 | 500 | $7,500 |
|
| Month 18 | 40,000 | 6,000 | 500 | $7,500 |
|
||||||
| Month 24 | 80,000 | 12,000 | 1,200 | $18,000 |
|
| Month 24 | 80,000 | 12,000 | 1,200 | $18,000 |
|
||||||
|
|
||||||
**Year 1 ARR: ~$32,400 | Year 2 ARR: ~$216,000**
|
**Year 1 ARR: ~$32,400 | Year 2 ARR: ~$216,000**
|
||||||
|
|
||||||
#### Aggressive (SEO + paid + partnerships + viral loops)
|
#### Aggressive (SEO + paid + partnerships + viral loops)
|
||||||
|
|
||||||
| Month | Monthly Visitors | Signups | Paying Users (cum.) | MRR |
|
| Month | Monthly Visitors | Signups | Paying Users (cum.) | MRR |
|
||||||
|-------|-----------------|---------|--------------------|----|
|
|-------|-----------------|---------|--------------------|----|
|
||||||
| Month 12 | 50,000 | 7,500 | 600 | $9,000 |
|
| Month 12 | 50,000 | 7,500 | 600 | $9,000 |
|
||||||
| Month 24 | 200,000 | 30,000 | 3,000 | $45,000 |
|
| Month 24 | 200,000 | 30,000 | 3,000 | $45,000 |
|
||||||
|
|
||||||
**Year 2 ARR: ~$540,000**
|
**Year 2 ARR: ~$540,000**
|
||||||
|
|
||||||
### Revenue Benchmarks from Comparable Companies
|
### Revenue Benchmarks from Comparable Companies
|
||||||
|
|
||||||
- QR Code Creator (17 years old): **$1.5M/year**
|
- QR Code Creator (17 years old): **$1.5M/year**
|
||||||
- Unitag (10+ years old): **$2.3M/year**
|
- Unitag (10+ years old): **$2.3M/year**
|
||||||
- Flowcode (7 years, VC-backed): **$15.7M/year**
|
- Flowcode (7 years, VC-backed): **$15.7M/year**
|
||||||
- Indie SaaS benchmarks: $5K–25K MRR is very achievable within 18-24 months with focused execution
|
- Indie SaaS benchmarks: $5K–25K MRR is very achievable within 18-24 months with focused execution
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Strengths & Risks
|
## 5. Strengths & Risks
|
||||||
|
|
||||||
### What's Working
|
### What's Working
|
||||||
|
|
||||||
1. **Product quality is high.** 8.5-minute sessions, 16% bounce rate, 12.5 pages/visitor — these are remarkable engagement metrics. The product holds attention.
|
1. **Product quality is high.** 8.5-minute sessions, 16% bounce rate, 12.5 pages/visitor — these are remarkable engagement metrics. The product holds attention.
|
||||||
2. **AI citations growing fast.** 77 citations/day after 3 months is a strong signal Google's AI trusts your content.
|
2. **AI citations growing fast.** 77 citations/day after 3 months is a strong signal Google's AI trusts your content.
|
||||||
3. **Broad keyword footprint.** 700+ keywords showing impressions means Google is indexing and considering your pages for a wide range of queries.
|
3. **Broad keyword footprint.** 700+ keywords showing impressions means Google is indexing and considering your pages for a wide range of queries.
|
||||||
4. **Niche tool pages (Teams, Zoom, WiFi, Instagram).** These long-tail pages are your fastest path to page 1 rankings.
|
4. **Niche tool pages (Teams, Zoom, WiFi, Instagram).** These long-tail pages are your fastest path to page 1 rankings.
|
||||||
5. **Technical foundation is solid.** Next.js 14, Stripe, analytics, dynamic QR codes — the infrastructure is already at a paid-product level.
|
5. **Technical foundation is solid.** Next.js 14, Stripe, analytics, dynamic QR codes — the infrastructure is already at a paid-product level.
|
||||||
|
|
||||||
### Risks & Concerns
|
### Risks & Concerns
|
||||||
|
|
||||||
1. **Near-zero retention.** Cohort data shows almost no users returning after week 1. Without solving retention, paid conversion will be extremely difficult.
|
1. **Near-zero retention.** Cohort data shows almost no users returning after week 1. Without solving retention, paid conversion will be extremely difficult.
|
||||||
2. **No revenue yet.** 3 months with 0 MRR is normal, but the clock is ticking. Need to see first paying user by month 4-5.
|
2. **No revenue yet.** 3 months with 0 MRR is normal, but the clock is ticking. Need to see first paying user by month 4-5.
|
||||||
3. **Competitive head terms are years away.** You won't rank for "QR code generator" anytime soon. The long-tail strategy is correct but requires patience.
|
3. **Competitive head terms are years away.** You won't rank for "QR code generator" anytime soon. The long-tail strategy is correct but requires patience.
|
||||||
4. **Single-channel dependency.** Currently SEO-reliant. If Google changes its algorithm or sandbox behavior, growth stalls.
|
4. **Single-channel dependency.** Currently SEO-reliant. If Google changes its algorithm or sandbox behavior, growth stalls.
|
||||||
5. **service_account.json in root.** If this is a live credential, it's a security risk. Remove from version control immediately.
|
5. **service_account.json in root.** If this is a live credential, it's a security risk. Remove from version control immediately.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Recommendations — Next 90 Days
|
## 6. Recommendations — Next 90 Days
|
||||||
|
|
||||||
### Immediate (This Month)
|
### Immediate (This Month)
|
||||||
|
|
||||||
1. **Fix retention first.** Add email capture on first QR creation, build a "My QR Codes" dashboard value prop, and implement a follow-up email sequence showing scan analytics. Users need a reason to come back.
|
1. **Fix retention first.** Add email capture on first QR creation, build a "My QR Codes" dashboard value prop, and implement a follow-up email sequence showing scan analytics. Users need a reason to come back.
|
||||||
2. **Push "quick win" keywords to page 1.** Teams QR code (pos 34), Zoom QR code (pos 40), and your branded terms. These need targeted backlinks and content optimization.
|
2. **Push "quick win" keywords to page 1.** Teams QR code (pos 34), Zoom QR code (pos 40), and your branded terms. These need targeted backlinks and content optimization.
|
||||||
3. **Add pricing friction at the right moment.** If you're not gating anything, users have no reason to pay. Consider limiting dynamic QR codes to 1-3 on free tier, then upsell.
|
3. **Add pricing friction at the right moment.** If you're not gating anything, users have no reason to pay. Consider limiting dynamic QR codes to 1-3 on free tier, then upsell.
|
||||||
|
|
||||||
### Short-term (Months 4-6)
|
### Short-term (Months 4-6)
|
||||||
|
|
||||||
4. **Build 10-15 high-quality backlinks.** Guest posts on marketing blogs, HARO/Connectively responses, tool directories. Domain authority is your bottleneck.
|
4. **Build 10-15 high-quality backlinks.** Guest posts on marketing blogs, HARO/Connectively responses, tool directories. Domain authority is your bottleneck.
|
||||||
5. **Double down on restaurant/menu niche.** You have 425 impressions on the restaurant menu QR blog. This is a vertical where local businesses actually pay for QR tools. Build a dedicated landing page.
|
5. **Double down on restaurant/menu niche.** You have 425 impressions on the restaurant menu QR blog. This is a vertical where local businesses actually pay for QR tools. Build a dedicated landing page.
|
||||||
6. **Launch on Product Hunt, Indie Hackers, and relevant subreddits.** Free exposure that also builds backlinks.
|
6. **Launch on Product Hunt, Indie Hackers, and relevant subreddits.** Free exposure that also builds backlinks.
|
||||||
|
|
||||||
### Medium-term (Months 6-12)
|
### Medium-term (Months 6-12)
|
||||||
|
|
||||||
7. **Expand content to 50+ blog posts.** Target every long-tail QR keyword cluster. QR Tiger's content machine is the model to emulate.
|
7. **Expand content to 50+ blog posts.** Target every long-tail QR keyword cluster. QR Tiger's content machine is the model to emulate.
|
||||||
8. **Add a freemium viral loop.** "Powered by QR Master" branding on free QR codes (with option to remove on paid plan).
|
8. **Add a freemium viral loop.** "Powered by QR Master" branding on free QR codes (with option to remove on paid plan).
|
||||||
9. **Consider German market positioning.** Your GSC data shows German-language queries (dynamische QR codes, etc.) and you appear to be German-speaking. The German QR code market is underserved compared to English.
|
9. **Consider German market positioning.** Your GSC data shows German-language queries (dynamische QR codes, etc.) and you appear to be German-speaking. The German QR code market is underserved compared to English.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Bottom Line
|
## 7. Bottom Line
|
||||||
|
|
||||||
**Is there real potential here?** Yes, absolutely. The QR code market is large, growing, and the barrier to entry is manageable with good technical execution — which you clearly have. Companies doing $1.5M–$15M in this space prove the model works.
|
**Is there real potential here?** Yes, absolutely. The QR code market is large, growing, and the barrier to entry is manageable with good technical execution — which you clearly have. Companies doing $1.5M–$15M in this space prove the model works.
|
||||||
|
|
||||||
**How much can you realistically make?** With sustained effort, **$3K–$10K MRR by month 18** is realistic under a moderate growth scenario. That's $36K–$120K ARR — meaningful side-project revenue. The top end of $200K+ ARR is achievable within 24 months if you invest in content, backlinks, and conversion optimization.
|
**How much can you realistically make?** With sustained effort, **$3K–$10K MRR by month 18** is realistic under a moderate growth scenario. That's $36K–$120K ARR — meaningful side-project revenue. The top end of $200K+ ARR is achievable within 24 months if you invest in content, backlinks, and conversion optimization.
|
||||||
|
|
||||||
**What's the biggest risk?** Not retention, not competition — it's giving up too early. SEO-driven SaaS is a compounding game. Months 1-6 feel slow because organic traffic takes time to build. The hockey stick starts around months 9-12 when domain authority compounds with content volume. QR Code Creator took 17 years to hit $1.5M, but the market was 10x smaller then.
|
**What's the biggest risk?** Not retention, not competition — it's giving up too early. SEO-driven SaaS is a compounding game. Months 1-6 feel slow because organic traffic takes time to build. The hockey stick starts around months 9-12 when domain authority compounds with content volume. QR Code Creator took 17 years to hit $1.5M, but the market was 10x smaller then.
|
||||||
|
|
||||||
**Your 3-month scorecard: B+.** Excellent product, strong engagement, growing search visibility. The missing pieces are retention mechanics, first revenue, and backlink authority. All fixable.
|
**Your 3-month scorecard: B+.** Excellent product, strong engagement, growing search visibility. The missing pieces are retention mechanics, first revenue, and backlink authority. All fixable.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Analysis based on Google Search Console data (Jan–Mar 2026), PostHog analytics (90 days), Google AI Performance data, and market research as of April 2026.*
|
*Analysis based on Google Search Console data (Jan–Mar 2026), PostHog analytics (90 days), Google AI Performance data, and market research as of April 2026.*
|
||||||
|
|
||||||
*Sources: [Mordor Intelligence QR Market Report](https://www.mordorintelligence.com/industry-reports/qr-codes-market), [GetLatka QR SaaS Companies](https://getlatka.com/companies/industries/i-qr-code-generator-software), [Bitly QR Code Statistics](https://bitly.com/blog/qr-code-statistics/), [360iResearch QR Market](https://www.360iresearch.com/library/intelligence/qr-code-generator), [QR Code Chimp Statistics](https://www.qrcodechimp.com/qr-code-statistics/)*
|
*Sources: [Mordor Intelligence QR Market Report](https://www.mordorintelligence.com/industry-reports/qr-codes-market), [GetLatka QR SaaS Companies](https://getlatka.com/companies/industries/i-qr-code-generator-software), [Bitly QR Code Statistics](https://bitly.com/blog/qr-code-statistics/), [360iResearch QR Market](https://www.360iresearch.com/library/intelligence/qr-code-generator), [QR Code Chimp Statistics](https://www.qrcodechimp.com/qr-code-statistics/)*
|
||||||
|
|||||||
922
TODO.md
922
TODO.md
@@ -1,461 +1,461 @@
|
|||||||
# QR Master — Growth TODO
|
# QR Master — Growth TODO
|
||||||
|
|
||||||
Based on: 3-month audit (PostHog + GSC + AI citations + full codebase review, April 2026)
|
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
|
Domain age at audit: ~3 months | Visitors (90d): 169 | AI citations (33d): 1,849
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How to use this file
|
## How to use this file
|
||||||
|
|
||||||
Sections are ordered by impact-per-hour-of-work. Don't skip to section 5 before section 1.
|
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.
|
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)
|
## 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.
|
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)
|
### 1.1 — Welcome email sequence (3 emails)
|
||||||
|
|
||||||
**File to edit:** `src/lib/email.ts`
|
**File to edit:** `src/lib/email.ts`
|
||||||
Add three new exported functions after the existing `sendPasswordResetEmail`:
|
Add three new exported functions after the existing `sendPasswordResetEmail`:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// Email 1: Sent immediately on signup
|
// Email 1: Sent immediately on signup
|
||||||
export async function sendWelcomeEmail(email: string, name: string) {}
|
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)
|
// 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) {}
|
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
|
// 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) {}
|
export async function sendUpgradeNudgeEmail(email: string, name: string, qrCount: number) {}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Email 1 — Welcome (Day 0)**
|
**Email 1 — Welcome (Day 0)**
|
||||||
- Subject: `Your QR Master account is ready`
|
- Subject: `Your QR Master account is ready`
|
||||||
- Body: One action only → create first QR code. Link to `/create`.
|
- Body: One action only → create first QR code. Link to `/create`.
|
||||||
- Mention the 3 free dynamic codes.
|
- Mention the 3 free dynamic codes.
|
||||||
- 4–5 sentences max, plain layout.
|
- 4–5 sentences max, plain layout.
|
||||||
- Sender: `Timo from QR Master <timo@qrmaster.net>`
|
- Sender: `Timo from QR Master <timo@qrmaster.net>`
|
||||||
|
|
||||||
**Email 2 — Activation nudge (Day 3, no QR codes created)**
|
**Email 2 — Activation nudge (Day 3, no QR codes created)**
|
||||||
- Subject: `Still haven't made your first QR code?`
|
- Subject: `Still haven't made your first QR code?`
|
||||||
- Trigger condition: `user.createdAt < now - 3 days AND qrCodes.count === 0`
|
- Trigger condition: `user.createdAt < now - 3 days AND qrCodes.count === 0`
|
||||||
- Body: One screenshot or step-by-step (3 steps). One CTA → `/create`.
|
- Body: One screenshot or step-by-step (3 steps). One CTA → `/create`.
|
||||||
- Don't send if they've already created one.
|
- Don't send if they've already created one.
|
||||||
|
|
||||||
**Email 3 — Upgrade nudge (Day 7, has codes, still FREE)**
|
**Email 3 — Upgrade nudge (Day 7, has codes, still FREE)**
|
||||||
- Subject: `You've created {n} QR codes — here's what you're missing`
|
- 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`
|
- 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.
|
- Body: 3 specific things they can't do on free: analytics breakdown, custom branding, more than 3 codes.
|
||||||
- CTA → `/pricing`
|
- CTA → `/pricing`
|
||||||
|
|
||||||
**File to edit:** `src/app/(main)/api/auth/signup/route.ts`
|
**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`):
|
Add `sendWelcomeEmail` call after the user is created (line ~88, after `db.user.create`):
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// After db.user.create(...)
|
// After db.user.create(...)
|
||||||
try {
|
try {
|
||||||
await sendWelcomeEmail(user.email, user.name ?? 'there');
|
await sendWelcomeEmail(user.email, user.name ?? 'there');
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
// Don't fail signup if email fails
|
// Don't fail signup if email fails
|
||||||
console.error('Welcome email failed:', emailError);
|
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:
|
**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
|
```json
|
||||||
// vercel.json
|
// vercel.json
|
||||||
{
|
{
|
||||||
"crons": [{ "path": "/api/cron/retention-emails", "schedule": "0 10 * * *" }]
|
"crons": [{ "path": "/api/cron/retention-emails", "schedule": "0 10 * * *" }]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 1.2 — Cancel flow (before Stripe portal)
|
### 1.2 — Cancel flow (before Stripe portal)
|
||||||
|
|
||||||
**File to edit:** `src/app/(main)/(app)/settings/page.tsx`
|
**File to edit:** `src/app/(main)/(app)/settings/page.tsx`
|
||||||
Right now: button → Stripe portal. Users cancel without any friction or reason capture.
|
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:
|
Replace the `Manage Subscription` button with a handler that opens a modal first:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// Add state
|
// Add state
|
||||||
const [showCancelModal, setShowCancelModal] = useState(false);
|
const [showCancelModal, setShowCancelModal] = useState(false);
|
||||||
const [cancelReason, setCancelReason] = useState('');
|
const [cancelReason, setCancelReason] = useState('');
|
||||||
|
|
||||||
// Replace direct portal redirect with:
|
// Replace direct portal redirect with:
|
||||||
const handleManageSubscription = () => {
|
const handleManageSubscription = () => {
|
||||||
if (plan !== 'FREE') {
|
if (plan !== 'FREE') {
|
||||||
setShowCancelModal(true); // intercept — show exit survey first
|
setShowCancelModal(true); // intercept — show exit survey first
|
||||||
} else {
|
} else {
|
||||||
// FREE users can just go to portal normally
|
// FREE users can just go to portal normally
|
||||||
openStripePortal();
|
openStripePortal();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
**Cancel modal — 4 steps:**
|
**Cancel modal — 4 steps:**
|
||||||
|
|
||||||
1. **Survey:** "Before you go — what's the main reason?" (radio: too expensive / not using it / missing feature / other)
|
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:
|
2. **Save offer** based on reason:
|
||||||
- `too_expensive` → *"What if we gave you 25% off for 2 months?"* → button calls `/api/stripe/apply-discount`
|
- `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
|
- `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
|
- `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
|
- `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
|
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.
|
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.
|
**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 (2–3 hours of work, immediate conversion lift)
|
## Section 2 — Signup flow (2–3 hours of work, immediate conversion lift)
|
||||||
|
|
||||||
Current form: Name → Email → Password → Confirm Password → [Create Account] → divider → Google button
|
Current form: Name → Email → Password → Confirm Password → [Create Account] → divider → Google button
|
||||||
Problem: Google should be first. Confirm Password should not exist.
|
Problem: Google should be first. Confirm Password should not exist.
|
||||||
|
|
||||||
### 2.1 — Move Google OAuth above the form
|
### 2.1 — Move Google OAuth above the form
|
||||||
|
|
||||||
**File:** `src/app/(main)/(auth)/signup/SignupClient.tsx`
|
**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.
|
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
|
```tsx
|
||||||
// New order in the Card:
|
// New order in the Card:
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
{/* Google FIRST */}
|
{/* Google FIRST */}
|
||||||
<Button type="button" variant="outline" className="w-full" onClick={handleGoogleSignIn}>
|
<Button type="button" variant="outline" className="w-full" onClick={handleGoogleSignIn}>
|
||||||
{/* Google SVG */} Sign up with Google
|
{/* Google SVG */} Sign up with Google
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="relative my-6">
|
<div className="relative my-6">
|
||||||
{/* divider */}
|
{/* divider */}
|
||||||
<span className="px-2 bg-white text-gray-500">Or sign up with email</span>
|
<span className="px-2 bg-white text-gray-500">Or sign up with email</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email form SECOND */}
|
{/* Email form SECOND */}
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
...
|
...
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.2 — Remove the Confirm Password field
|
### 2.2 — Remove the Confirm Password field
|
||||||
|
|
||||||
**File:** `src/app/(main)/(auth)/signup/SignupClient.tsx`
|
**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:
|
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
|
```tsx
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
// In the Input:
|
// In the Input:
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
label="Password"
|
label="Password"
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
className="absolute right-3 top-9 text-gray-400 hover:text-gray-600"
|
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" />}
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.3 — Add value reinforcement to the signup page
|
### 2.3 — Add value reinforcement to the signup page
|
||||||
|
|
||||||
**File:** `src/app/(main)/(auth)/signup/SignupClient.tsx`
|
**File:** `src/app/(main)/(auth)/signup/SignupClient.tsx`
|
||||||
|
|
||||||
Change the subtitle under "Create Account" from:
|
Change the subtitle under "Create Account" from:
|
||||||
> *"Start creating QR codes in seconds"*
|
> *"Start creating QR codes in seconds"*
|
||||||
|
|
||||||
To:
|
To:
|
||||||
> *"No credit card required — 3 dynamic QR codes free forever"*
|
> *"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:
|
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
|
```tsx
|
||||||
<p className="text-center text-xs text-gray-400 mt-4">
|
<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>
|
Already have an account? <Link href="/login">Sign in</Link> · <Link href="/">Back to home</Link>
|
||||||
</p>
|
</p>
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Section 3 — Homepage copy (half a day)
|
## Section 3 — Homepage copy (half a day)
|
||||||
|
|
||||||
### 3.1 — Headline rewrite
|
### 3.1 — Headline rewrite
|
||||||
|
|
||||||
**File:** `src/i18n/en.json`
|
**File:** `src/i18n/en.json`
|
||||||
Current: `"title": "Create QR Codes That Work Everywhere"`
|
Current: `"title": "Create QR Codes That Work Everywhere"`
|
||||||
|
|
||||||
Replace with one of (pick based on your gut for the audience):
|
Replace with one of (pick based on your gut for the audience):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"title": "The Free QR Code Generator That Doesn't Expire"
|
"title": "The Free QR Code Generator That Doesn't Expire"
|
||||||
```
|
```
|
||||||
or
|
or
|
||||||
```json
|
```json
|
||||||
"title": "Track Every Scan. Update Every Link. Free Forever."
|
"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."*
|
Subtitle — current: *"Generate static and dynamic QR codes with tracking, custom branding, and bulk generation. Free forever."*
|
||||||
Replace:
|
Replace:
|
||||||
```json
|
```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."
|
"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
|
### 3.2 — Feature bullets: features → outcomes
|
||||||
|
|
||||||
**File:** `src/i18n/en.json`, `hero.features` array
|
**File:** `src/i18n/en.json`, `hero.features` array
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"features": [
|
"features": [
|
||||||
"Change your link anytime — no reprinting needed",
|
"Change your link anytime — no reprinting needed",
|
||||||
"See scan location, device, and time of day",
|
"See scan location, device, and time of day",
|
||||||
"Your QR codes work forever, even on the free plan",
|
"Your QR codes work forever, even on the free plan",
|
||||||
"Match your brand: custom colors in under a minute"
|
"Match your brand: custom colors in under a minute"
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.3 — Hero right column: replace animation with product screenshot
|
### 3.3 — Hero right column: replace animation with product screenshot
|
||||||
|
|
||||||
**File:** `src/components/marketing/Hero.tsx`
|
**File:** `src/components/marketing/Hero.tsx`
|
||||||
|
|
||||||
The flipping cards grid (lines ~130–175) should be replaced with either:
|
The flipping cards grid (lines ~130–175) should be replaced with either:
|
||||||
- A static screenshot of the analytics dashboard (real scan data, anonymized)
|
- 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: 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.
|
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
|
### 3.4 — Add a social proof number to the hero
|
||||||
|
|
||||||
**File:** `src/components/marketing/Hero.tsx` or `src/components/marketing/StatsStrip.tsx`
|
**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:
|
Add one concrete number directly in or immediately below the hero — before the fold. Options:
|
||||||
- `"1M+ QR codes generated"` (if true or close)
|
- `"1M+ QR codes generated"` (if true or close)
|
||||||
- `"Trusted by 500+ businesses"` (if defensible)
|
- `"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)
|
- `"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.
|
The last one is genuinely differentiated and verified.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Section 4 — Content: the 5 posts that will move GSC rankings
|
## 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.
|
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
|
### 4.1 — Barcode generator companion post
|
||||||
|
|
||||||
**Current situation:** `/tools/barcode-generator` has 1,160 GSC impressions at position 71. No blog post supports it.
|
**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`)
|
**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"*
|
**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`
|
**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`.
|
**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
|
### 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.
|
**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`
|
**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"*
|
**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).
|
**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
|
### 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.
|
**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)`).
|
**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 (3–5 questions), and an internal link to the main QR generator.
|
**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 (3–5 questions), and an internal link to the main QR generator.
|
||||||
|
|
||||||
### 4.4 — Fix the 2025-dated content
|
### 4.4 — Fix the 2025-dated content
|
||||||
|
|
||||||
**File:** `src/lib/blog-data.ts`
|
**File:** `src/lib/blog-data.ts`
|
||||||
|
|
||||||
The post with slug `qr-code-tracking-guide-2025` has `2025` in its URL. Update:
|
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`
|
- `slug`: change to `qr-code-tracking-guide` or `qr-code-tracking-guide-2026`
|
||||||
- `title`: update year references in the content
|
- `title`: update year references in the content
|
||||||
- `dateModified`: set to current date
|
- `dateModified`: set to current date
|
||||||
- Add a redirect in `next.config.mjs` from the old slug to the new one:
|
- Add a redirect in `next.config.mjs` from the old slug to the new one:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
async redirects() {
|
async redirects() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: '/blog/qr-code-tracking-guide-2025',
|
source: '/blog/qr-code-tracking-guide-2025',
|
||||||
destination: '/blog/qr-code-tracking-guide-2026',
|
destination: '/blog/qr-code-tracking-guide-2026',
|
||||||
permanent: true,
|
permanent: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.5 — "Best QR code generator 2026" — make it actually compete
|
### 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.
|
**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:
|
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)
|
- 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)
|
- Honest pros/cons for each (being fake-neutral is immediately obvious and AI systems penalise it)
|
||||||
- A `FAQPage` schema block
|
- A `FAQPage` schema block
|
||||||
- Statistics with cited sources (e.g., "QR code scans grew X% in 2025 — [source]")
|
- Statistics with cited sources (e.g., "QR code scans grew X% in 2025 — [source]")
|
||||||
- `dateModified` updated to current month
|
- `dateModified` updated to current month
|
||||||
|
|
||||||
This post alone, done properly, is the highest-value AI-citation target on the whole site.
|
This post alone, done properly, is the highest-value AI-citation target on the whole site.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Section 5 — AI SEO quick fixes (1–2 hours)
|
## Section 5 — AI SEO quick fixes (1–2 hours)
|
||||||
|
|
||||||
These are code-level changes, not content work.
|
These are code-level changes, not content work.
|
||||||
|
|
||||||
### 5.1 — Add cited sources to every statistic in blog posts
|
### 5.1 — Add cited sources to every statistic in blog posts
|
||||||
|
|
||||||
**File:** `src/lib/blog-data.ts`
|
**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:
|
Every blog post `content` field that contains a statistic without a source link needs one. Example fix in the restaurant post:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<!-- Before -->
|
<!-- Before -->
|
||||||
<p>over 60% of restaurants that adopted QR menus during 2020–2021 kept them afterward</p>
|
<p>over 60% of restaurants that adopted QR menus during 2020–2021 kept them afterward</p>
|
||||||
|
|
||||||
<!-- After -->
|
<!-- After -->
|
||||||
<p>over 60% of restaurants that adopted QR menus during 2020–2021 kept them afterward
|
<p>over 60% of restaurants that adopted QR menus during 2020–2021 kept them afterward
|
||||||
<a href="https://nationalrestaurantassociation.org/..." target="_blank" rel="noopener">[NRA, 2022]</a></p>
|
<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.
|
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
|
### 5.2 — Add definition blocks to tool pages
|
||||||
|
|
||||||
**Which pages:** barcode generator, WiFi QR generator, Teams QR generator, URL QR generator
|
**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 40–60 words. AI systems extract from the opening paragraph first.
|
**What to add:** A one-paragraph definition in the first `<p>` tag that directly answers "What is a [X] generator?" in 40–60 words. AI systems extract from the opening paragraph first.
|
||||||
|
|
||||||
Example for barcode generator:
|
Example for barcode generator:
|
||||||
```html
|
```html
|
||||||
<p>A barcode generator creates machine-readable linear barcodes (EAN-13, UPC-A, Code 128)
|
<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
|
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
|
and are scanned by dedicated readers. Use this free tool to generate printable barcodes
|
||||||
for retail products, inventory labels, or shipping.</p>
|
for retail products, inventory labels, or shipping.</p>
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5.3 — Add `dateModified` display to blog post pages
|
### 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/`
|
**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.
|
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)
|
## Section 6 — Pricing page (1 hour)
|
||||||
|
|
||||||
**File:** `src/i18n/en.json`, pricing section + the Pricing component
|
**File:** `src/i18n/en.json`, pricing section + the Pricing component
|
||||||
|
|
||||||
### 6.1 — Add upgrade trigger context to the Pro plan
|
### 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:
|
Under the Pro plan title, add a single explanatory sentence that explains *when* someone should upgrade:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"pro": {
|
"pro": {
|
||||||
"trigger": "When you hit 3 dynamic QR codes or need scan analytics"
|
"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.
|
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
|
### 6.2 — Free plan: explain the limit as a feature, not a restriction
|
||||||
|
|
||||||
Current: `"3 active dynamic QR codes (8 types available)"`
|
Current: `"3 active dynamic QR codes (8 types available)"`
|
||||||
Rewrite: `"3 dynamic QR codes — enough to start. Unlimited static codes."`
|
Rewrite: `"3 dynamic QR codes — enough to start. Unlimited static codes."`
|
||||||
|
|
||||||
The current copy leads with the limit. Lead with what they get.
|
The current copy leads with the limit. Lead with what they get.
|
||||||
|
|
||||||
### 6.3 — Annual pricing
|
### 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 15–30% by locking in longer commitments. Check `src/lib/stripe.ts` for whether annual price IDs exist.
|
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 15–30% by locking in longer commitments. Check `src/lib/stripe.ts` for whether annual price IDs exist.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Section 7 — Dashboard first-run experience (2–3 hours)
|
## Section 7 — Dashboard first-run experience (2–3 hours)
|
||||||
|
|
||||||
### 7.1 — Empty state when user has 0 QR codes
|
### 7.1 — Empty state when user has 0 QR codes
|
||||||
|
|
||||||
**File:** `src/app/(main)/(app)/dashboard/page.tsx`
|
**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`.
|
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:
|
Add an empty state component:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
{!loading && qrCodes.length === 0 && (
|
{!loading && qrCodes.length === 0 && (
|
||||||
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl">
|
<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" />
|
<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>
|
<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">
|
<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.
|
You have 3 free dynamic QR codes. They redirect wherever you want and track every scan.
|
||||||
</p>
|
</p>
|
||||||
<Link href="/create">
|
<Link href="/create">
|
||||||
<Button>Create QR Code — it takes 90 seconds</Button>
|
<Button>Create QR Code — it takes 90 seconds</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7.2 — Remove the mock QR codes from the dashboard code
|
### 7.2 — Remove the mock QR codes from the dashboard code
|
||||||
|
|
||||||
**File:** `src/app/(main)/(app)/dashboard/page.tsx`, lines ~45–100
|
**File:** `src/app/(main)/(app)/dashboard/page.tsx`, lines ~45–100
|
||||||
|
|
||||||
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.
|
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
|
### 7.3 — Dashboard subtitle should be contextual
|
||||||
|
|
||||||
**File:** `src/app/(main)/(app)/dashboard/page.tsx`
|
**File:** `src/app/(main)/(app)/dashboard/page.tsx`
|
||||||
|
|
||||||
Current subtitle: `{t('dashboard.subtitle')}` → "Manage and track your QR codes"
|
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 0 QR codes, show: *"Start here — create your first QR code in under 2 minutes"*
|
||||||
For users with QR codes, show the current text.
|
For users with QR codes, show the current text.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<p className="text-gray-600 mt-2">
|
<p className="text-gray-600 mt-2">
|
||||||
{qrCodes.length === 0
|
{qrCodes.length === 0
|
||||||
? 'Start here — create your first QR code in under 2 minutes'
|
? 'Start here — create your first QR code in under 2 minutes'
|
||||||
: t('dashboard.subtitle')}
|
: t('dashboard.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Metrics to watch
|
## Metrics to watch
|
||||||
|
|
||||||
| Thing changed | Metric to check | Where |
|
| Thing changed | Metric to check | Where |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Welcome email | Cohort retention week 1 | PostHog → Retention |
|
| Welcome email | Cohort retention week 1 | PostHog → Retention |
|
||||||
| Signup form (Google first, remove confirm PW) | Signup completion rate | PostHog → Funnels |
|
| Signup form (Google first, remove confirm PW) | Signup completion rate | PostHog → Funnels |
|
||||||
| Cancel flow | Cancelled users / month | Stripe dashboard |
|
| Cancel flow | Cancelled users / month | Stripe dashboard |
|
||||||
| Hero headline | Bounce rate on `/` | PostHog → Paths |
|
| Hero headline | Bounce rate on `/` | PostHog → Paths |
|
||||||
| Barcode + Teams blog posts | GSC impressions & position | Search Console |
|
| Barcode + Teams blog posts | GSC impressions & position | Search Console |
|
||||||
| Cited statistics in posts | AI citations/day | AI perf CSV |
|
| Cited statistics in posts | AI citations/day | AI perf CSV |
|
||||||
| Empty state dashboard | `/create` conversion from dashboard | PostHog → Paths |
|
| Empty state dashboard | `/create` conversion from dashboard | PostHog → Paths |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What not to do right now
|
## 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 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 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 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)
|
- Don't add more blog posts until the existing ones have cited sources and proper date signals (Sections 4.4 and 5.1)
|
||||||
|
|||||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -16,6 +16,7 @@
|
|||||||
"@prisma/client": "^5.7.0",
|
"@prisma/client": "^5.7.0",
|
||||||
"@stripe/stripe-js": "^8.0.0",
|
"@stripe/stripe-js": "^8.0.0",
|
||||||
"@types/d3-scale": "^4.0.9",
|
"@types/d3-scale": "^4.0.9",
|
||||||
|
"@types/nodemailer": "^7.0.11",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"chart.js": "^4.4.0",
|
"chart.js": "^4.4.0",
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "^14.2.35",
|
"next": "^14.2.35",
|
||||||
"next-auth": "^4.24.5",
|
"next-auth": "^4.24.5",
|
||||||
|
"nodemailer": "^8.0.4",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
"posthog-js": "^1.332.0",
|
"posthog-js": "^1.332.0",
|
||||||
"qr-code-styling": "^1.9.2",
|
"qr-code-styling": "^1.9.2",
|
||||||
@@ -4094,6 +4096,15 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "7.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
|
||||||
|
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/pako": {
|
"node_modules/@types/pako": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||||
@@ -9625,6 +9636,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "8.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
|
||||||
|
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/normalize-path": {
|
"node_modules/normalize-path": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"@prisma/client": "^5.7.0",
|
"@prisma/client": "^5.7.0",
|
||||||
"@stripe/stripe-js": "^8.0.0",
|
"@stripe/stripe-js": "^8.0.0",
|
||||||
"@types/d3-scale": "^4.0.9",
|
"@types/d3-scale": "^4.0.9",
|
||||||
|
"@types/nodemailer": "^7.0.11",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"chart.js": "^4.4.0",
|
"chart.js": "^4.4.0",
|
||||||
@@ -55,6 +56,7 @@
|
|||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "^14.2.35",
|
"next": "^14.2.35",
|
||||||
"next-auth": "^4.24.5",
|
"next-auth": "^4.24.5",
|
||||||
|
"nodemailer": "^8.0.4",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
"posthog-js": "^1.332.0",
|
"posthog-js": "^1.332.0",
|
||||||
"qr-code-styling": "^1.9.2",
|
"qr-code-styling": "^1.9.2",
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// This is your Prisma schema file,
|
// This is your Prisma schema file,
|
||||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
@@ -32,6 +32,11 @@ model User {
|
|||||||
resetPasswordToken String? @unique
|
resetPasswordToken String? @unique
|
||||||
resetPasswordExpires DateTime?
|
resetPasswordExpires DateTime?
|
||||||
|
|
||||||
|
// Retention email tracking
|
||||||
|
activationNudgeSentAt DateTime?
|
||||||
|
upgradeNudgeSentAt DateTime?
|
||||||
|
thirtyDayNudgeSentAt DateTime?
|
||||||
|
|
||||||
qrCodes QRCode[]
|
qrCodes QRCode[]
|
||||||
integrations Integration[]
|
integrations Integration[]
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
@@ -176,4 +181,4 @@ model Lead {
|
|||||||
annualSavings Float?
|
annualSavings Float?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/email-hero-light.png
Normal file
BIN
public/email-hero-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 531 KiB |
@@ -12,6 +12,7 @@ import { useTranslation } from '@/hooks/useTranslation';
|
|||||||
import { useCsrf } from '@/hooks/useCsrf';
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
import { showToast } from '@/components/ui/Toast';
|
import { showToast } from '@/components/ui/Toast';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/Dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/Dialog';
|
||||||
|
import { QrCode } from 'lucide-react';
|
||||||
|
|
||||||
interface QRCodeData {
|
interface QRCodeData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -45,68 +46,6 @@ export default function DashboardPage() {
|
|||||||
});
|
});
|
||||||
const [analyticsData, setAnalyticsData] = useState<any>(null);
|
const [analyticsData, setAnalyticsData] = useState<any>(null);
|
||||||
|
|
||||||
const mockQRCodes = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
title: 'Support Phone',
|
|
||||||
type: 'DYNAMIC' as const,
|
|
||||||
contentType: 'PHONE',
|
|
||||||
slug: 'support-phone-demo',
|
|
||||||
status: 'ACTIVE' as const,
|
|
||||||
createdAt: '2025-08-07T10:00:00Z',
|
|
||||||
scans: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
title: 'Event Details',
|
|
||||||
type: 'DYNAMIC' as const,
|
|
||||||
contentType: 'URL',
|
|
||||||
slug: 'event-details-demo',
|
|
||||||
status: 'ACTIVE' as const,
|
|
||||||
createdAt: '2025-08-07T10:01:00Z',
|
|
||||||
scans: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
title: 'Product Demo',
|
|
||||||
type: 'DYNAMIC' as const,
|
|
||||||
contentType: 'URL',
|
|
||||||
slug: 'product-demo-qr',
|
|
||||||
status: 'ACTIVE' as const,
|
|
||||||
createdAt: '2025-08-07T10:02:00Z',
|
|
||||||
scans: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
title: 'Company Website',
|
|
||||||
type: 'DYNAMIC' as const,
|
|
||||||
contentType: 'URL',
|
|
||||||
slug: 'company-website-qr',
|
|
||||||
status: 'ACTIVE' as const,
|
|
||||||
createdAt: '2025-08-07T10:03:00Z',
|
|
||||||
scans: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '5',
|
|
||||||
title: 'Contact Card',
|
|
||||||
type: 'DYNAMIC' as const,
|
|
||||||
contentType: 'VCARD',
|
|
||||||
slug: 'contact-card-qr',
|
|
||||||
status: 'ACTIVE' as const,
|
|
||||||
createdAt: '2025-08-07T10:04:00Z',
|
|
||||||
scans: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '6',
|
|
||||||
title: 'Event Details',
|
|
||||||
type: 'DYNAMIC' as const,
|
|
||||||
contentType: 'URL',
|
|
||||||
slug: 'event-details-dup',
|
|
||||||
status: 'ACTIVE' as const,
|
|
||||||
createdAt: '2025-08-07T10:05:00Z',
|
|
||||||
scans: 0,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const blogPosts = [
|
const blogPosts = [
|
||||||
// NEW POSTS
|
// NEW POSTS
|
||||||
@@ -384,7 +323,11 @@ export default function DashboardPage() {
|
|||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">{t('dashboard.title')}</h1>
|
<h1 className="text-3xl font-bold text-gray-900">{t('dashboard.title')}</h1>
|
||||||
<p className="text-gray-600 mt-2">{t('dashboard.subtitle')}</p>
|
<p className="text-gray-600 mt-2">
|
||||||
|
{!loading && qrCodes.length === 0
|
||||||
|
? 'Start here — create your first QR code in under 2 minutes'
|
||||||
|
: t('dashboard.subtitle')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Badge variant={getPlanBadgeColor(userPlan)} className="text-lg px-4 py-2">
|
<Badge variant={getPlanBadgeColor(userPlan)} className="text-lg px-4 py-2">
|
||||||
@@ -445,6 +388,17 @@ export default function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : 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>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{qrCodes.map((qr) => (
|
{qrCodes.map((qr) => (
|
||||||
|
|||||||
@@ -1,179 +1,179 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
type Step = 'use-case' | 'region' | 'result';
|
type Step = 'use-case' | 'region' | 'result';
|
||||||
|
|
||||||
type Result = {
|
type Result = {
|
||||||
format: string;
|
format: string;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
example: string;
|
example: string;
|
||||||
color: string;
|
color: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const RESULTS: Record<string, Result> = {
|
const RESULTS: Record<string, Result> = {
|
||||||
'ean13': {
|
'ean13': {
|
||||||
format: 'EAN-13',
|
format: 'EAN-13',
|
||||||
label: 'EAN-13',
|
label: 'EAN-13',
|
||||||
description: 'The global retail standard. Used on consumer products sold in supermarkets, pharmacies, and online shops worldwide.',
|
description: 'The global retail standard. Used on consumer products sold in supermarkets, pharmacies, and online shops worldwide.',
|
||||||
example: '4006381333931 (a common product barcode)',
|
example: '4006381333931 (a common product barcode)',
|
||||||
color: 'blue',
|
color: 'blue',
|
||||||
},
|
},
|
||||||
'upca': {
|
'upca': {
|
||||||
format: 'UPC-A',
|
format: 'UPC-A',
|
||||||
label: 'UPC-A',
|
label: 'UPC-A',
|
||||||
description: 'The North American retail standard. Functionally equivalent to EAN-13 but with 12 digits. Required by US and Canadian retailers.',
|
description: 'The North American retail standard. Functionally equivalent to EAN-13 but with 12 digits. Required by US and Canadian retailers.',
|
||||||
example: '012345678905',
|
example: '012345678905',
|
||||||
color: 'indigo',
|
color: 'indigo',
|
||||||
},
|
},
|
||||||
'code128': {
|
'code128': {
|
||||||
format: 'Code 128',
|
format: 'Code 128',
|
||||||
label: 'Code 128',
|
label: 'Code 128',
|
||||||
description: 'The most versatile barcode. Supports letters, numbers, and special characters. Used in shipping labels, inventory systems, and internal tracking.',
|
description: 'The most versatile barcode. Supports letters, numbers, and special characters. Used in shipping labels, inventory systems, and internal tracking.',
|
||||||
example: 'SHIP-2026-ABC-001',
|
example: 'SHIP-2026-ABC-001',
|
||||||
color: 'emerald',
|
color: 'emerald',
|
||||||
},
|
},
|
||||||
'code39': {
|
'code39': {
|
||||||
format: 'Code 39',
|
format: 'Code 39',
|
||||||
label: 'Code 39',
|
label: 'Code 39',
|
||||||
description: 'A legacy alphanumeric format still widely used in automotive, defense, and industrial environments. Simpler than Code 128 but less compact.',
|
description: 'A legacy alphanumeric format still widely used in automotive, defense, and industrial environments. Simpler than Code 128 but less compact.',
|
||||||
example: 'PART-7734-A',
|
example: 'PART-7734-A',
|
||||||
color: 'orange',
|
color: 'orange',
|
||||||
},
|
},
|
||||||
'msi': {
|
'msi': {
|
||||||
format: 'MSI',
|
format: 'MSI',
|
||||||
label: 'MSI',
|
label: 'MSI',
|
||||||
description: 'Designed for inventory and shelf labeling in retail warehouses. Numeric-only. Used for bin locations, shelf tags, and stockroom management.',
|
description: 'Designed for inventory and shelf labeling in retail warehouses. Numeric-only. Used for bin locations, shelf tags, and stockroom management.',
|
||||||
example: '123456',
|
example: '123456',
|
||||||
color: 'purple',
|
color: 'purple',
|
||||||
},
|
},
|
||||||
'pharmacode': {
|
'pharmacode': {
|
||||||
format: 'Pharmacode',
|
format: 'Pharmacode',
|
||||||
label: 'Pharmacode',
|
label: 'Pharmacode',
|
||||||
description: 'A pharmaceutical packaging standard used to verify correct product packaging. Encodes a single numeric value (3–131071).',
|
description: 'A pharmaceutical packaging standard used to verify correct product packaging. Encodes a single numeric value (3–131071).',
|
||||||
example: '12345',
|
example: '12345',
|
||||||
color: 'red',
|
color: 'red',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const colorMap: Record<string, string> = {
|
const colorMap: Record<string, string> = {
|
||||||
blue: 'bg-blue-50 border-blue-300 text-blue-900',
|
blue: 'bg-blue-50 border-blue-300 text-blue-900',
|
||||||
indigo: 'bg-indigo-50 border-indigo-300 text-indigo-900',
|
indigo: 'bg-indigo-50 border-indigo-300 text-indigo-900',
|
||||||
emerald: 'bg-emerald-50 border-emerald-300 text-emerald-900',
|
emerald: 'bg-emerald-50 border-emerald-300 text-emerald-900',
|
||||||
orange: 'bg-orange-50 border-orange-300 text-orange-900',
|
orange: 'bg-orange-50 border-orange-300 text-orange-900',
|
||||||
purple: 'bg-purple-50 border-purple-300 text-purple-900',
|
purple: 'bg-purple-50 border-purple-300 text-purple-900',
|
||||||
red: 'bg-red-50 border-red-300 text-red-900',
|
red: 'bg-red-50 border-red-300 text-red-900',
|
||||||
};
|
};
|
||||||
|
|
||||||
const badgeMap: Record<string, string> = {
|
const badgeMap: Record<string, string> = {
|
||||||
blue: 'bg-blue-100 text-blue-800',
|
blue: 'bg-blue-100 text-blue-800',
|
||||||
indigo: 'bg-indigo-100 text-indigo-800',
|
indigo: 'bg-indigo-100 text-indigo-800',
|
||||||
emerald: 'bg-emerald-100 text-emerald-800',
|
emerald: 'bg-emerald-100 text-emerald-800',
|
||||||
orange: 'bg-orange-100 text-orange-800',
|
orange: 'bg-orange-100 text-orange-800',
|
||||||
purple: 'bg-purple-100 text-purple-800',
|
purple: 'bg-purple-100 text-purple-800',
|
||||||
red: 'bg-red-100 text-red-800',
|
red: 'bg-red-100 text-red-800',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BarcodeFormatPicker() {
|
export function BarcodeFormatPicker() {
|
||||||
const [step, setStep] = useState<Step>('use-case');
|
const [step, setStep] = useState<Step>('use-case');
|
||||||
const [useCase, setUseCase] = useState<string>('');
|
const [useCase, setUseCase] = useState<string>('');
|
||||||
const [result, setResult] = useState<string>('');
|
const [result, setResult] = useState<string>('');
|
||||||
|
|
||||||
const selectUseCase = (value: string) => {
|
const selectUseCase = (value: string) => {
|
||||||
setUseCase(value);
|
setUseCase(value);
|
||||||
if (value === 'retail') {
|
if (value === 'retail') {
|
||||||
setStep('region');
|
setStep('region');
|
||||||
} else {
|
} else {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
logistics: 'code128',
|
logistics: 'code128',
|
||||||
inventory: 'code128',
|
inventory: 'code128',
|
||||||
industrial: 'code39',
|
industrial: 'code39',
|
||||||
warehouse: 'msi',
|
warehouse: 'msi',
|
||||||
pharma: 'pharmacode',
|
pharma: 'pharmacode',
|
||||||
};
|
};
|
||||||
setResult(map[value] ?? 'code128');
|
setResult(map[value] ?? 'code128');
|
||||||
setStep('result');
|
setStep('result');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectRegion = (region: string) => {
|
const selectRegion = (region: string) => {
|
||||||
setResult(region === 'us' ? 'upca' : 'ean13');
|
setResult(region === 'us' ? 'upca' : 'ean13');
|
||||||
setStep('result');
|
setStep('result');
|
||||||
};
|
};
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setStep('use-case');
|
setStep('use-case');
|
||||||
setUseCase('');
|
setUseCase('');
|
||||||
setResult('');
|
setResult('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = result ? RESULTS[result] : null;
|
const res = result ? RESULTS[result] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="not-prose my-8 rounded-2xl border border-slate-200 bg-slate-50 p-6">
|
<div className="not-prose my-8 rounded-2xl border border-slate-200 bg-slate-50 p-6">
|
||||||
<h3 className="text-lg font-bold text-slate-900 mb-1">Which barcode format do I need?</h3>
|
<h3 className="text-lg font-bold text-slate-900 mb-1">Which barcode format do I need?</h3>
|
||||||
<p className="text-sm text-slate-500 mb-5">Answer two quick questions to find the right format for your use case.</p>
|
<p className="text-sm text-slate-500 mb-5">Answer two quick questions to find the right format for your use case.</p>
|
||||||
|
|
||||||
{step === 'use-case' && (
|
{step === 'use-case' && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-slate-700 mb-3">What will you use the barcode for?</p>
|
<p className="text-sm font-semibold text-slate-700 mb-3">What will you use the barcode for?</p>
|
||||||
<div className="grid sm:grid-cols-2 gap-3">
|
<div className="grid sm:grid-cols-2 gap-3">
|
||||||
{[
|
{[
|
||||||
{ value: 'retail', label: 'Retail products', sub: 'Selling in stores or online' },
|
{ value: 'retail', label: 'Retail products', sub: 'Selling in stores or online' },
|
||||||
{ value: 'logistics', label: 'Shipping & logistics', sub: 'Parcel labels, supply chain' },
|
{ value: 'logistics', label: 'Shipping & logistics', sub: 'Parcel labels, supply chain' },
|
||||||
{ value: 'inventory', label: 'Inventory tracking', sub: 'Internal stock management' },
|
{ value: 'inventory', label: 'Inventory tracking', sub: 'Internal stock management' },
|
||||||
{ value: 'industrial', label: 'Industrial / automotive', sub: 'Manufacturing, defense' },
|
{ value: 'industrial', label: 'Industrial / automotive', sub: 'Manufacturing, defense' },
|
||||||
{ value: 'warehouse', label: 'Shelf & bin labeling', sub: 'Warehouse locations' },
|
{ value: 'warehouse', label: 'Shelf & bin labeling', sub: 'Warehouse locations' },
|
||||||
{ value: 'pharma', label: 'Pharmaceutical packaging', sub: 'Medication packaging control' },
|
{ value: 'pharma', label: 'Pharmaceutical packaging', sub: 'Medication packaging control' },
|
||||||
].map((opt) => (
|
].map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
onClick={() => selectUseCase(opt.value)}
|
onClick={() => selectUseCase(opt.value)}
|
||||||
className="text-left rounded-xl border border-slate-200 bg-white p-4 hover:border-blue-400 hover:bg-blue-50 transition-colors group"
|
className="text-left rounded-xl border border-slate-200 bg-white p-4 hover:border-blue-400 hover:bg-blue-50 transition-colors group"
|
||||||
>
|
>
|
||||||
<div className="font-semibold text-slate-900 group-hover:text-blue-800 text-sm">{opt.label}</div>
|
<div className="font-semibold text-slate-900 group-hover:text-blue-800 text-sm">{opt.label}</div>
|
||||||
<div className="text-xs text-slate-500 mt-0.5">{opt.sub}</div>
|
<div className="text-xs text-slate-500 mt-0.5">{opt.sub}</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 'region' && (
|
{step === 'region' && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-slate-700 mb-3">Where will you primarily sell?</p>
|
<p className="text-sm font-semibold text-slate-700 mb-3">Where will you primarily sell?</p>
|
||||||
<div className="grid sm:grid-cols-2 gap-3">
|
<div className="grid sm:grid-cols-2 gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => selectRegion('eu')}
|
onClick={() => selectRegion('eu')}
|
||||||
className="text-left rounded-xl border border-slate-200 bg-white p-4 hover:border-blue-400 hover:bg-blue-50 transition-colors group"
|
className="text-left rounded-xl border border-slate-200 bg-white p-4 hover:border-blue-400 hover:bg-blue-50 transition-colors group"
|
||||||
>
|
>
|
||||||
<div className="font-semibold text-slate-900 group-hover:text-blue-800 text-sm">Europe / International</div>
|
<div className="font-semibold text-slate-900 group-hover:text-blue-800 text-sm">Europe / International</div>
|
||||||
<div className="text-xs text-slate-500 mt-0.5">EU, UK, Asia, global retail</div>
|
<div className="text-xs text-slate-500 mt-0.5">EU, UK, Asia, global retail</div>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => selectRegion('us')}
|
onClick={() => selectRegion('us')}
|
||||||
className="text-left rounded-xl border border-slate-200 bg-white p-4 hover:border-blue-400 hover:bg-blue-50 transition-colors group"
|
className="text-left rounded-xl border border-slate-200 bg-white p-4 hover:border-blue-400 hover:bg-blue-50 transition-colors group"
|
||||||
>
|
>
|
||||||
<div className="font-semibold text-slate-900 group-hover:text-blue-800 text-sm">USA / Canada</div>
|
<div className="font-semibold text-slate-900 group-hover:text-blue-800 text-sm">USA / Canada</div>
|
||||||
<div className="text-xs text-slate-500 mt-0.5">North American retail market</div>
|
<div className="text-xs text-slate-500 mt-0.5">North American retail market</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={reset} className="mt-3 text-xs text-slate-400 hover:text-slate-600 underline">← Start over</button>
|
<button onClick={reset} className="mt-3 text-xs text-slate-400 hover:text-slate-600 underline">← Start over</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 'result' && res && (
|
{step === 'result' && res && (
|
||||||
<div className={`rounded-xl border-2 p-5 ${colorMap[res.color]}`}>
|
<div className={`rounded-xl border-2 p-5 ${colorMap[res.color]}`}>
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<span className={`text-xs font-bold px-2.5 py-1 rounded-full ${badgeMap[res.color]}`}>Recommended</span>
|
<span className={`text-xs font-bold px-2.5 py-1 rounded-full ${badgeMap[res.color]}`}>Recommended</span>
|
||||||
<span className="font-bold text-xl">{res.label}</span>
|
<span className="font-bold text-xl">{res.label}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm mb-2">{res.description}</p>
|
<p className="text-sm mb-2">{res.description}</p>
|
||||||
<p className="text-xs opacity-70 font-mono">Example: {res.example}</p>
|
<p className="text-xs opacity-70 font-mono">Example: {res.example}</p>
|
||||||
<button onClick={reset} className="mt-4 text-xs underline opacity-60 hover:opacity-90">← Try again</button>
|
<button onClick={reset} className="mt-4 text-xs underline opacity-60 hover:opacity-90">← Try again</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
import Link from 'next/link';
|
||||||
import TeamsGenerator from './TeamsGenerator';
|
import TeamsGenerator from './TeamsGenerator';
|
||||||
import { Users, Shield, Zap, Video, MessageCircle, Download, Share2 } from 'lucide-react';
|
import { Users, Shield, Zap, Video, MessageCircle, Download, Share2 } from 'lucide-react';
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
@@ -250,6 +251,18 @@ export default function TeamsQRCodePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* DEEP DIVE BLOG LINK */}
|
||||||
|
<section className="py-10 px-4 sm:px-6 lg:px-8 bg-white border-t border-slate-100">
|
||||||
|
<div className="max-w-3xl mx-auto text-center">
|
||||||
|
<p className="text-slate-600 text-base">
|
||||||
|
Want a deeper guide?{' '}
|
||||||
|
<Link href="/blog/microsoft-teams-qr-code" className="text-[#6264A7] font-semibold underline hover:opacity-80 transition-opacity">
|
||||||
|
How to Create a Microsoft Teams QR Code for Instant Meeting Joins →
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* RELATED TOOLS */}
|
{/* RELATED TOOLS */}
|
||||||
<RelatedTools />
|
<RelatedTools />
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { csrfProtection } from '@/lib/csrf';
|
|||||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||||
import { signupSchema, validateRequest } from '@/lib/validationSchemas';
|
import { signupSchema, validateRequest } from '@/lib/validationSchemas';
|
||||||
|
import { sendWelcomeEmail } from '@/lib/email';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -74,6 +75,13 @@ export async function POST(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send welcome email (fire-and-forget — never block signup)
|
||||||
|
try {
|
||||||
|
await sendWelcomeEmail(user.email, user.name ?? 'there');
|
||||||
|
} catch (emailError) {
|
||||||
|
console.error('Welcome email failed:', emailError);
|
||||||
|
}
|
||||||
|
|
||||||
// Create response
|
// Create response
|
||||||
const response = NextResponse.json({
|
const response = NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
113
src/app/(main)/api/cron/retention-emails/route.ts
Normal file
113
src/app/(main)/api/cron/retention-emails/route.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { sendActivationNudgeEmail, sendUpgradeNudgeEmail, sendThirtyDayNudgeEmail } from '@/lib/email';
|
||||||
|
|
||||||
|
// Protect with a shared secret — set CRON_SECRET in Vercel env vars
|
||||||
|
function isAuthorized(request: NextRequest): boolean {
|
||||||
|
const authHeader = request.headers.get('authorization');
|
||||||
|
const cronSecret = process.env.CRON_SECRET;
|
||||||
|
if (!cronSecret) return false;
|
||||||
|
return authHeader === `Bearer ${cronSecret}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
if (!isAuthorized(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
|
||||||
|
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
let activationSent = 0;
|
||||||
|
let upgradeSent = 0;
|
||||||
|
let thirtyDaySent = 0;
|
||||||
|
|
||||||
|
// Day-3: signed up > 3 days ago, never created a QR code, hasn't received this email yet
|
||||||
|
const activationCandidates = await db.user.findMany({
|
||||||
|
where: {
|
||||||
|
createdAt: { lt: threeDaysAgo },
|
||||||
|
activationNudgeSentAt: null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: { select: { qrCodes: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const user of activationCandidates) {
|
||||||
|
if (user._count.qrCodes === 0 && user.email) {
|
||||||
|
try {
|
||||||
|
await sendActivationNudgeEmail(user.email, user.name ?? 'there');
|
||||||
|
await db.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { activationNudgeSentAt: now },
|
||||||
|
});
|
||||||
|
activationSent++;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Activation nudge failed for ${user.email}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Day-7: signed up > 7 days ago, has ≥1 QR code, still FREE, hasn't received this email yet
|
||||||
|
const upgradeCandidates = await db.user.findMany({
|
||||||
|
where: {
|
||||||
|
createdAt: { lt: sevenDaysAgo },
|
||||||
|
upgradeNudgeSentAt: null,
|
||||||
|
plan: 'FREE',
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: { select: { qrCodes: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const user of upgradeCandidates) {
|
||||||
|
if (user._count.qrCodes > 0 && user.email) {
|
||||||
|
try {
|
||||||
|
await sendUpgradeNudgeEmail(user.email, user.name ?? 'there', user._count.qrCodes);
|
||||||
|
await db.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { upgradeNudgeSentAt: now },
|
||||||
|
});
|
||||||
|
upgradeSent++;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Upgrade nudge failed for ${user.email}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Day-30: signed up > 30 days ago, has ≥1 QR code, still FREE, hasn't received this email yet
|
||||||
|
const thirtyDayCandidates = await (db.user as any).findMany({
|
||||||
|
where: {
|
||||||
|
createdAt: { lt: thirtyDaysAgo },
|
||||||
|
thirtyDayNudgeSentAt: null,
|
||||||
|
plan: 'FREE',
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: { select: { qrCodes: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const user of thirtyDayCandidates) {
|
||||||
|
if (user._count.qrCodes > 0 && user.email) {
|
||||||
|
try {
|
||||||
|
await sendThirtyDayNudgeEmail(user.email, user.name ?? 'there', user._count.qrCodes);
|
||||||
|
await (db.user as any).update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { thirtyDayNudgeSentAt: now },
|
||||||
|
});
|
||||||
|
thirtyDaySent++;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`30-day nudge failed for ${user.email}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
activationNudgesSent: activationSent,
|
||||||
|
upgradeNudgesSent: upgradeSent,
|
||||||
|
thirtyDayNudgesSent: thirtyDaySent,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,139 @@
|
|||||||
import type { BlogPost } from "./types";
|
import type { BlogPost } from "./types";
|
||||||
|
|
||||||
export const blogPosts: BlogPost[] = [
|
export const blogPosts: BlogPost[] = [
|
||||||
|
// ==================================================================================
|
||||||
|
// NEW POSTS
|
||||||
|
// ==================================================================================
|
||||||
|
|
||||||
|
{
|
||||||
|
slug: "microsoft-teams-qr-code",
|
||||||
|
title: "How to Create a Microsoft Teams QR Code for Instant Meeting Joins",
|
||||||
|
description: "Step-by-step guide to creating a QR code for any Microsoft Teams meeting. Attendees scan once to join — no link typing needed. Free tool included.",
|
||||||
|
excerpt: "Step-by-step guide to creating a QR code for any Microsoft Teams meeting. Attendees scan once to join — no link typing needed.",
|
||||||
|
category: "Business Tools",
|
||||||
|
pillar: "use-cases",
|
||||||
|
published: true,
|
||||||
|
publishDate: "2026-04-02",
|
||||||
|
date: "April 2, 2026",
|
||||||
|
datePublished: "2026-04-02T09:00:00Z",
|
||||||
|
dateModified: "2026-04-02T09:00:00Z",
|
||||||
|
updatedAt: "2026-04-02",
|
||||||
|
authorSlug: "timo",
|
||||||
|
authorName: "Timo Knuth",
|
||||||
|
authorTitle: "QR Code & Marketing Expert",
|
||||||
|
readTime: "8 Min",
|
||||||
|
image: "/blog/teams-qr-code.png",
|
||||||
|
heroImage: "/blog/teams-qr-code.png",
|
||||||
|
imageAlt: "Microsoft Teams meeting room with QR code displayed on screen",
|
||||||
|
keywords: ["microsoft teams qr code", "teams meeting qr code", "teams qr code", "join teams meeting qr", "teams besprechung qr code"],
|
||||||
|
quickAnswer: `<p>Copy your Teams meeting URL → paste it into <a href="/tools/teams-qr-code">QR Master's free Teams QR generator</a> → download the code → display it in your meeting room or invitation. Attendees scan once to join instantly — no link typing needed.</p>`,
|
||||||
|
keySteps: [
|
||||||
|
"Open your Microsoft Teams calendar and click on the meeting to copy the Join link.",
|
||||||
|
"Go to qrmaster.net/tools/teams-qr-code and paste the link.",
|
||||||
|
"Customize: choose Teams purple (#6264A7) and add a label like 'Scan to Join'.",
|
||||||
|
"Download as PNG (for digital screens) or SVG (for print quality).",
|
||||||
|
"Display on room screens, printed invitations, or office signage.",
|
||||||
|
],
|
||||||
|
faq: [
|
||||||
|
{ question: "Does the QR code work for recurring Teams meetings?", answer: "Yes — recurring Teams meetings typically reuse the same join link, so one QR code works for every session." },
|
||||||
|
{ question: "Can guests without a Teams account join via QR code?", answer: "Yes. When they scan the code, Teams opens the web client which works without a Microsoft account." },
|
||||||
|
{ question: "What's the difference between a static and dynamic Teams QR code?", answer: "A static QR code encodes the meeting link permanently. A <a href='/dynamic-qr-code-generator'>dynamic QR code</a> lets you update the link anytime — useful for room displays where meetings change." },
|
||||||
|
{ question: "How big should I print the Teams QR code?", answer: "Minimum 4×4 cm for handouts. For a 55\" room display, 200×200px is sufficient. Always test scanning from the expected distance." },
|
||||||
|
{ question: "Does this work with Teams for personal accounts?", answer: "Yes. The QR generator works for Teams work, school, and personal account meeting links." },
|
||||||
|
],
|
||||||
|
relatedSlugs: ["dynamic-vs-static-qr-codes", "qr-code-small-business"],
|
||||||
|
sources: [
|
||||||
|
{ name: "Microsoft Teams – Create and join meetings", url: "https://support.microsoft.com/en-us/office/schedule-a-meeting-in-microsoft-teams-943507a9-8583-4c58-b5d2-8ec8265e04e5", accessDate: "April 2026" },
|
||||||
|
],
|
||||||
|
content: `<div class="blog-content">
|
||||||
|
<div class="post-metadata bg-blue-50 p-4 rounded-lg mb-8 border-l-4 border-blue-500">
|
||||||
|
<p class="text-sm text-gray-700">
|
||||||
|
<strong>Author:</strong> Timo Knuth, QR Code & Marketing Expert<br/>
|
||||||
|
Published: April 2, 2026
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-green-50 border border-green-200 rounded-xl p-6 mb-8">
|
||||||
|
<h2 class="text-lg font-bold text-green-900 mt-0 mb-3">Quick answer</h2>
|
||||||
|
<p class="text-green-800 mb-0">Copy your Teams meeting join link → paste into <a href="/tools/teams-qr-code" class="text-green-700 font-semibold underline">QR Master's Teams QR generator</a> → download → display. Takes under 60 seconds.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Why a QR Code for Microsoft Teams?</h2>
|
||||||
|
<p>Hybrid work has made meeting room friction a real problem. Someone arrives at a conference room, needs to join the call, and has to either find the invite email, type a 40-character URL, or wait for the organizer to send them the link again. A QR code displayed on the room's screen or printed on a table card eliminates all of that. One scan and Teams opens immediately.</p>
|
||||||
|
<p>The use cases are broader than just conference rooms:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Office room displays:</strong> A permanently mounted QR code in meeting rooms that links to the room's recurring standup or team channel.</li>
|
||||||
|
<li><strong>Printed event invitations:</strong> Instead of printing a URL, print a QR code. Attendees scan before the event to save the join link.</li>
|
||||||
|
<li><strong>Shared workspaces and coworking offices:</strong> Display QR codes linking to daily all-hands or onboarding calls that rotate monthly.</li>
|
||||||
|
<li><strong>Training materials:</strong> Include a QR code in printed handbooks that links to a live Q&A session.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Step-by-Step: Creating a Microsoft Teams QR Code</h2>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Get the Teams meeting link.</strong> Open Microsoft Teams → Calendar → click on your meeting → click "Copy join link". The URL starts with <code>https://teams.microsoft.com/l/meetup-join/...</code></li>
|
||||||
|
<li><strong>Open the Teams QR generator.</strong> Go to <a href="/tools/teams-qr-code" class="text-blue-600 underline">qrmaster.net/tools/teams-qr-code</a>. No account needed for a basic static code.</li>
|
||||||
|
<li><strong>Paste and customize.</strong> Paste the meeting URL. For branding: use Teams purple (<code>#6264A7</code>) for the QR modules. Add a frame label — "Scan to Join" or "Join Teams Meeting" works well.</li>
|
||||||
|
<li><strong>Choose your format.</strong>
|
||||||
|
<ul>
|
||||||
|
<li><strong>PNG</strong> — for digital displays (room screens, Slack, email signatures)</li>
|
||||||
|
<li><strong>SVG</strong> — for print (scales to any size without losing quality)</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li><strong>Test before deploying.</strong> Scan with an iPhone (native camera) and an Android (Google Lens). Test from the actual scanning distance — usually 0.5–1.5 meters for room displays.</li>
|
||||||
|
<li><strong>Display or print.</strong> For room screens, most Teams Rooms devices support custom backgrounds or a dedicated display app. For print, minimum 4×4 cm on paper.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Static vs. Dynamic: Which Should You Use?</h2>
|
||||||
|
<p>For a one-off meeting like a client call, a <strong>static QR code</strong> is fine — it encodes the link permanently and requires no account.</p>
|
||||||
|
<p>For meeting rooms or recurring situations, use a <strong><a href="/dynamic-qr-code-generator" class="text-blue-600 underline">dynamic QR code</a></strong>. Here's why: when a recurring meeting is updated or moved to a new link, a dynamic code lets you update the destination from your dashboard without changing or reprinting the QR code. The code on the room's screen stays the same — only the link behind it changes.</p>
|
||||||
|
<p>Dynamic codes also give you scan analytics — how many people joined via QR vs. link, what device they used, and what time of day has the most scans. Useful if you're running regular events or trainings.</p>
|
||||||
|
|
||||||
|
<h2>Sizing Guide for Teams QR Codes</h2>
|
||||||
|
<table class="w-full text-sm border-collapse my-6">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-slate-100">
|
||||||
|
<th class="text-left p-3 border border-slate-200 font-semibold">Use case</th>
|
||||||
|
<th class="text-left p-3 border border-slate-200 font-semibold">Minimum size</th>
|
||||||
|
<th class="text-left p-3 border border-slate-200 font-semibold">Recommended</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="p-3 border border-slate-200">55" room display</td>
|
||||||
|
<td class="p-3 border border-slate-200">150×150 px</td>
|
||||||
|
<td class="p-3 border border-slate-200">250×250 px</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="bg-slate-50">
|
||||||
|
<td class="p-3 border border-slate-200">Printed A4 handout</td>
|
||||||
|
<td class="p-3 border border-slate-200">4×4 cm</td>
|
||||||
|
<td class="p-3 border border-slate-200">6×6 cm</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="p-3 border border-slate-200">Table card / name tent</td>
|
||||||
|
<td class="p-3 border border-slate-200">3×3 cm</td>
|
||||||
|
<td class="p-3 border border-slate-200">4×4 cm</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="bg-slate-50">
|
||||||
|
<td class="p-3 border border-slate-200">Email signature</td>
|
||||||
|
<td class="p-3 border border-slate-200">80×80 px</td>
|
||||||
|
<td class="p-3 border border-slate-200">120×120 px</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p>Always maintain a quiet zone (white margin) of at least 4 modules around the code. Cutting into this margin causes scan failures.</p>
|
||||||
|
|
||||||
|
<h2>Common Issues and Fixes</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Teams opens but shows "Meeting not found":</strong> The meeting link has expired or was cancelled. If using a dynamic QR code, update the destination link in your dashboard.</li>
|
||||||
|
<li><strong>QR code scans but nothing happens on some devices:</strong> Some older Android devices need a QR scanner app. The Teams app itself includes a QR scanner under Settings → Scan QR code.</li>
|
||||||
|
<li><strong>Link too long to encode cleanly:</strong> Teams join URLs are long. Use a dynamic QR code — it encodes a short redirect URL instead, which produces a less dense, more reliably scannable code.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>Ready to create yours? The <a href="/tools/teams-qr-code" class="text-blue-600 underline font-semibold">Teams QR code generator is free and takes under 60 seconds →</a></p>
|
||||||
|
|
||||||
|
</div>`,
|
||||||
|
},
|
||||||
|
|
||||||
// ==================================================================================
|
// ==================================================================================
|
||||||
// EXISTING POSTS (Refreshed) - 8 Posts
|
// EXISTING POSTS (Refreshed) - 8 Posts
|
||||||
// ==================================================================================
|
// ==================================================================================
|
||||||
@@ -42,6 +175,9 @@ export const blogPosts: BlogPost[] = [
|
|||||||
{ question: "How can I track menu QR scans?", answer: "Use dynamic QR analytics (scans, locations, devices) and optionally add UTM parameters for campaign attribution." },
|
{ question: "How can I track menu QR scans?", answer: "Use dynamic QR analytics (scans, locations, devices) and optionally add UTM parameters for campaign attribution." },
|
||||||
],
|
],
|
||||||
relatedSlugs: ["dynamic-vs-static-qr-codes", "qr-code-print-size-guide", "qr-code-tracking-guide-2025", "qr-code-events"],
|
relatedSlugs: ["dynamic-vs-static-qr-codes", "qr-code-print-size-guide", "qr-code-tracking-guide-2025", "qr-code-events"],
|
||||||
|
sources: [
|
||||||
|
{ name: "National Restaurant Association – State of the Industry 2022", url: "https://restaurant.org/research-and-media/media/press-releases/2022/", accessDate: "April 2026" },
|
||||||
|
],
|
||||||
authorName: "Timo Knuth",
|
authorName: "Timo Knuth",
|
||||||
authorTitle: "QR Code & Marketing Expert",
|
authorTitle: "QR Code & Marketing Expert",
|
||||||
content: `<div class="blog-content">
|
content: `<div class="blog-content">
|
||||||
@@ -64,7 +200,7 @@ export const blogPosts: BlogPost[] = [
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Why QR Code Menus Became a Restaurant Standard</h2>
|
<h2>Why QR Code Menus Became a Restaurant Standard</h2>
|
||||||
<p>What started as a contactless safety measure during the pandemic has become a permanent fixture in the restaurant industry. According to industry data, over 60% of restaurants that adopted QR menus during 2020–2021 kept them afterward — not for safety reasons, but because of the business benefits.</p>
|
<p>What started as a contactless safety measure during the pandemic has become a permanent fixture in the restaurant industry. According to industry data, over 60% of restaurants that adopted QR menus during 2020–2021 kept them afterward — not for safety reasons, but because of the business benefits <a href="https://restaurant.org/research-and-media/media/press-releases/2022/" target="_blank" rel="noopener noreferrer">[NRA, 2022]</a>.</p>
|
||||||
<p>The advantages are straightforward: a printed menu costs $3–8 per copy to laminate and reprint whenever items change. A QR menu costs nothing to update. For a restaurant that changes its seasonal specials every few months, that adds up fast. Beyond cost savings, QR menus open up analytics that paper never could — you can see which menu sections guests spend the most time on, when peak scanning happens during the day, and which table locations drive the most engagement.</p>
|
<p>The advantages are straightforward: a printed menu costs $3–8 per copy to laminate and reprint whenever items change. A QR menu costs nothing to update. For a restaurant that changes its seasonal specials every few months, that adds up fast. Beyond cost savings, QR menus open up analytics that paper never could — you can see which menu sections guests spend the most time on, when peak scanning happens during the day, and which table locations drive the most engagement.</p>
|
||||||
|
|
||||||
<h2>Static vs. Dynamic QR Codes for Restaurant Menus</h2>
|
<h2>Static vs. Dynamic QR Codes for Restaurant Menus</h2>
|
||||||
|
|||||||
774
src/lib/email.ts
774
src/lib/email.ts
@@ -25,6 +25,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Resend } from 'resend';
|
import { Resend } from 'resend';
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
|
||||||
// Use a placeholder during build time, real key at runtime
|
// Use a placeholder during build time, real key at runtime
|
||||||
const resendKey = process.env.RESEND_API_KEY || 're_placeholder_for_build';
|
const resendKey = process.env.RESEND_API_KEY || 're_placeholder_for_build';
|
||||||
@@ -532,3 +533,776 @@ export async function sendAIFeatureLaunchEmail(email: string) {
|
|||||||
throw new Error('Failed to send AI feature launch email');
|
throw new Error('Failed to send AI feature launch email');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SMTP Transport (nodemailer) — used for retention / welcome emails
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createSmtpTransport() {
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST || 'smtp.qrmaster.net',
|
||||||
|
port: parseInt(process.env.SMTP_PORT || '465', 10),
|
||||||
|
secure: true, // port 465 = SSL
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER || 'timo@qrmaster.net',
|
||||||
|
pass: process.env.SMTP_PASS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://www.qrmaster.net';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared design tokens (email-safe inline styles)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const clr = {
|
||||||
|
bg: '#F5F2EC', // warm parchment wrapper
|
||||||
|
card: '#FFFFFF',
|
||||||
|
header: '#0B0D14', // near-black header
|
||||||
|
headerAccent: '#1A1D2E',
|
||||||
|
gold: '#C8A257', // warm gold accent
|
||||||
|
goldDim: '#A07E3A',
|
||||||
|
text: '#1A1A1A',
|
||||||
|
textSoft: '#5A5A5A',
|
||||||
|
textMuted: '#909090',
|
||||||
|
border: '#E8E3D8',
|
||||||
|
pillBg: '#F0EDE5',
|
||||||
|
ctaBg: '#0B0D14',
|
||||||
|
ctaText: '#FFFFFF',
|
||||||
|
};
|
||||||
|
|
||||||
|
const webFontHead = `
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Sans:wght@300;400;500;600&display=swap');
|
||||||
|
.dm-serif { font-family: 'DM Serif Display', Georgia, 'Times New Roman', serif !important; }
|
||||||
|
.dm-sans { font-family: 'DM Sans', -apple-system, 'Helvetica Neue', Arial, sans-serif !important; }
|
||||||
|
</style>`;
|
||||||
|
|
||||||
|
function emailShell(headExtra: string, bodyContent: string): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="color-scheme" content="light">
|
||||||
|
<meta name="supported-color-schemes" content="light">
|
||||||
|
${webFontHead}
|
||||||
|
${headExtra}
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background-color:${clr.bg};-webkit-text-size-adjust:100%;mso-line-height-rule:exactly;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
|
||||||
|
style="background-color:${clr.bg};padding:40px 16px 60px;">
|
||||||
|
<tr><td align="center">
|
||||||
|
|
||||||
|
<!-- Email card -->
|
||||||
|
<table role="presentation" width="560" cellpadding="0" cellspacing="0" border="0"
|
||||||
|
style="max-width:560px;width:100%;background-color:${clr.card};border-radius:16px;
|
||||||
|
overflow:hidden;box-shadow:0 8px 40px rgba(0,0,0,0.12);">
|
||||||
|
${bodyContent}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Footer text -->
|
||||||
|
<table role="presentation" width="560" cellpadding="0" cellspacing="0" border="0"
|
||||||
|
style="max-width:560px;width:100%;margin-top:28px;">
|
||||||
|
<tr>
|
||||||
|
<td style="text-align:center;padding:0 20px;">
|
||||||
|
<p style="margin:0 0 6px;font-family:'DM Sans',-apple-system,sans-serif;font-size:12px;color:${clr.textMuted};">
|
||||||
|
<a href="${appUrl}" style="color:${clr.gold};text-decoration:none;font-weight:500;">www.qrmaster.net</a>
|
||||||
|
·
|
||||||
|
<a href="mailto:support@qrmaster.net" style="color:${clr.textMuted};text-decoration:none;">support@qrmaster.net</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;font-family:'DM Sans',-apple-system,sans-serif;font-size:11px;color:#B0A898;">
|
||||||
|
© 2026 QR Master. You're receiving this because you created an account.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dot-grid SVG encoded as data URI for header backgrounds
|
||||||
|
// Safe URL-encoded SVG without problematic internal quotes
|
||||||
|
const dotGridPattern = `url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Ccircle%20cx%3D%2212%22%20cy%3D%2212%22%20r%3D%221.5%22%20fill%3D%22%23C8A257%22%20fill-opacity%3D%220.18%22%2F%3E%3C%2Fsvg%3E)`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welcome Email — sent immediately on signup (Day 0)
|
||||||
|
*/
|
||||||
|
export async function sendWelcomeEmail(email: string, name: string) {
|
||||||
|
const transport = createSmtpTransport();
|
||||||
|
const createUrl = `${appUrl}/create`;
|
||||||
|
const firstName = name.split(' ')[0];
|
||||||
|
|
||||||
|
const html = emailShell('', `
|
||||||
|
<!-- ── HEADER ── -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:${clr.card};background-image:${dotGridPattern};
|
||||||
|
background-size:24px 24px;padding:60px 48px 50px;text-align:center;">
|
||||||
|
|
||||||
|
<!-- Wordmark Badge -->
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center">
|
||||||
|
<tr>
|
||||||
|
<td style="border:1px solid rgba(200,162,87,0.4);border-radius:6px;
|
||||||
|
padding:6px 16px;background-color:rgba(200,162,87,0.05);">
|
||||||
|
<span style="font-family:'DM Sans',-apple-system,sans-serif;font-size:10px;
|
||||||
|
font-weight:700;letter-spacing:3px;color:${clr.gold};
|
||||||
|
text-transform:uppercase;">QR Master</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Headline -->
|
||||||
|
<h1 style="margin:36px 0 0;font-family:'DM Serif Display',Georgia,serif;
|
||||||
|
font-size:42px;font-weight:400;line-height:1.1;color:${clr.text};
|
||||||
|
letter-spacing:-0.5px;">
|
||||||
|
Welcome,<br>
|
||||||
|
<em style="color:${clr.gold};font-style:italic;">${firstName}.</em>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- Thin gold divider -->
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0"
|
||||||
|
align="center" style="margin-top:32px;">
|
||||||
|
<tr>
|
||||||
|
<td style="width:40px;height:1px;background-color:${clr.gold};opacity:0.6;"></td>
|
||||||
|
<td style="width:12px;"></td>
|
||||||
|
<td style="width:6px;height:6px;background-color:${clr.gold};border-radius:50%;
|
||||||
|
opacity:0.9;"></td>
|
||||||
|
<td style="width:12px;"></td>
|
||||||
|
<td style="width:40px;height:1px;background-color:${clr.gold};opacity:0.6;"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin:24px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:15px;color:${clr.textSoft};letter-spacing:0.3px;">
|
||||||
|
The physical world is now your canvas.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- ── HERO IMAGE ── -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 0; text-align: center; background-color: ${clr.card};">
|
||||||
|
<img src="${appUrl}/email-hero-light.png" width="560" style="display:block;width:100%;max-width:560px;height:auto;border-bottom:3px solid ${clr.gold};" alt="Beautiful QR Code Experience">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- ── BODY ── -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:56px 48px 0;">
|
||||||
|
<p style="margin:0 0 24px;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:16px;line-height:1.8;color:${clr.text};">
|
||||||
|
Let's be honest: most QR codes are static, look terrible, and break the moment you change a link. We built QR Master to fix that.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:16px;line-height:1.8;color:${clr.text};">
|
||||||
|
Your account is fully activated. You now have the power to create beautiful, dynamic QR codes that adapt to your brand, never expire, and track every single scan (device, location, and time).
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:16px;line-height:1.8;color:${clr.textSoft};">
|
||||||
|
To give you the perfect start, we've loaded your account with everything you need:
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- ── STATS STRIP ── -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:16px 48px 32px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<!-- Stat 1 -->
|
||||||
|
<td style="padding:12px 0;text-align:center;width:31%;">
|
||||||
|
<div style="font-family:'DM Serif Display',Georgia,serif;font-size:28px;
|
||||||
|
color:${clr.text};line-height:1;">3</div>
|
||||||
|
<div style="font-family:'DM Sans',-apple-system,sans-serif;font-size:10px;
|
||||||
|
font-weight:700;color:${clr.textMuted};letter-spacing:1px;margin-top:8px;
|
||||||
|
text-transform:uppercase;">
|
||||||
|
Free Codes
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="width:3.5%;"></td>
|
||||||
|
<!-- Stat 2 -->
|
||||||
|
<td style="padding:12px 0;text-align:center;width:31%;">
|
||||||
|
<div style="font-family:'DM Serif Display',Georgia,serif;font-size:28px;
|
||||||
|
color:${clr.text};line-height:1;">90<span style="font-size:18px;">s</span></div>
|
||||||
|
<div style="font-family:'DM Sans',-apple-system,sans-serif;font-size:10px;
|
||||||
|
font-weight:700;color:${clr.textMuted};letter-spacing:1px;margin-top:8px;
|
||||||
|
text-transform:uppercase;">
|
||||||
|
To Create
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="width:3.5%;"></td>
|
||||||
|
<!-- Stat 3 -->
|
||||||
|
<td style="padding:12px 0;text-align:center;width:31%;">
|
||||||
|
<div style="font-family:'DM Sans',-apple-system,sans-serif;font-size:28px;
|
||||||
|
color:${clr.text};line-height:1;">∞</div>
|
||||||
|
<div style="font-family:'DM Sans',-apple-system,sans-serif;font-size:10px;
|
||||||
|
font-weight:700;color:${clr.textMuted};letter-spacing:1px;margin-top:8px;
|
||||||
|
text-transform:uppercase;">
|
||||||
|
No Expiry
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- ── CTA ── -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 48px 56px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="${createUrl}"
|
||||||
|
style="display:inline-block;background-color:${clr.ctaBg};color:${clr.ctaText};
|
||||||
|
text-decoration:none;padding:18px 48px;border-radius:8px;
|
||||||
|
font-family:'DM Sans',-apple-system,sans-serif;font-size:16px;
|
||||||
|
font-weight:600;letter-spacing:0.5px;
|
||||||
|
border:1px solid rgba(200,162,87,0.5);
|
||||||
|
box-shadow:0 6px 20px rgba(11,13,20,0.15);">
|
||||||
|
Design your first QR code →
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-top:16px;">
|
||||||
|
<span style="font-family:'DM Sans',-apple-system,sans-serif;font-size:13px;
|
||||||
|
color:${clr.textMuted};">No credit card required. Zero commitment.</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- ── DIVIDER ── -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 48px;">
|
||||||
|
<div style="border-top:1px solid ${clr.border};"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- ── SIGN-OFF ── -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:40px 48px 56px;">
|
||||||
|
<p style="margin:0;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:15px;color:${clr.textSoft};line-height:1.7;">
|
||||||
|
I'm thrilled to have you on board. If you have any questions, feedback, or just want to share what you've created — hit reply. I'm reading every single email.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin-top:28px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding-right:16px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td style="width:56px; height:56px; background-color:#0B0D14; border-radius:50%; text-align:center; vertical-align:middle; border:2px solid ${clr.border}; box-shadow:0 4px 10px rgba(0,0,0,0.05);">
|
||||||
|
<img src="${appUrl}/favicon.svg" width="32" height="32" alt="Timo" style="display:inline-block; vertical-align:middle;">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<p style="margin:0;font-family:'DM Serif Display',Georgia,serif;
|
||||||
|
font-size:24px;font-style:italic;color:${clr.text};line-height:1.2;">
|
||||||
|
Timo
|
||||||
|
</p>
|
||||||
|
<p style="margin:4px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:11px;font-weight:700;color:${clr.textMuted};
|
||||||
|
letter-spacing:1.5px;text-transform:uppercase;">
|
||||||
|
Founder, QR Master
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await transport.sendMail({
|
||||||
|
from: 'Timo from QR Master <timo@qrmaster.net>',
|
||||||
|
replyTo: 'support@qrmaster.net',
|
||||||
|
to: email,
|
||||||
|
subject: 'Your QR Master account is ready',
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activation Nudge — sent on Day 3 if user has 0 QR codes
|
||||||
|
*/
|
||||||
|
export async function sendActivationNudgeEmail(email: string, name: string) {
|
||||||
|
const transport = createSmtpTransport();
|
||||||
|
const createUrl = `${appUrl}/create`;
|
||||||
|
const firstName = name.split(' ')[0];
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ n: '01', label: 'Paste your URL', sub: 'or choose WiFi, vCard, Teams, and more' },
|
||||||
|
{ n: '02', label: 'Customize the design', sub: 'colors, logo, frame label — optional' },
|
||||||
|
{ n: '03', label: 'Download & use', sub: 'PNG for screen, SVG for print' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const stepsHtml = steps.map(s => `
|
||||||
|
<tr>
|
||||||
|
<td style="padding:16px 0;border-bottom:1px solid ${clr.border};">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td style="width:36px;vertical-align:top;padding-top:2px;">
|
||||||
|
<span style="font-family:'DM Serif Display',Georgia,serif;font-size:13px;
|
||||||
|
color:${clr.gold};font-style:italic;">${s.n}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style="font-family:'DM Sans',-apple-system,sans-serif;font-size:15px;
|
||||||
|
font-weight:600;color:${clr.text};">${s.label}</div>
|
||||||
|
<div style="font-family:'DM Sans',-apple-system,sans-serif;font-size:13px;
|
||||||
|
color:${clr.textMuted};margin-top:2px;">${s.sub}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
|
||||||
|
const html = emailShell('', `
|
||||||
|
<!-- ── HEADER ── -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:${clr.header};background-image:${dotGridPattern};
|
||||||
|
background-size:24px 24px;padding:44px 48px 40px;">
|
||||||
|
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td style="border:1px solid rgba(200,162,87,0.35);border-radius:8px;padding:7px 18px;">
|
||||||
|
<span style="font-family:'DM Sans',-apple-system,sans-serif;font-size:11px;
|
||||||
|
font-weight:600;letter-spacing:3px;color:${clr.gold};
|
||||||
|
text-transform:uppercase;">QR Master</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h1 style="margin:28px 0 0;font-family:'DM Serif Display',Georgia,serif;
|
||||||
|
font-size:34px;font-weight:400;line-height:1.2;color:#FFFFFF;">
|
||||||
|
You haven't made one yet,<br>
|
||||||
|
<em style="color:${clr.gold};">${firstName}.</em>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p style="margin:16px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:14px;color:rgba(255,255,255,0.45);letter-spacing:0.2px;">
|
||||||
|
3 days since signup · 0 QR codes created
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- ── BODY ── -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:40px 48px 0;">
|
||||||
|
<p style="margin:0 0 8px;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:16px;line-height:1.75;color:${clr.text};">
|
||||||
|
Your 3 free dynamic QR codes are still there. Unused.<br>
|
||||||
|
Dynamic means: one code, update the link anytime, every scan tracked.
|
||||||
|
</p>
|
||||||
|
<p style="margin:16px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:15px;line-height:1.75;color:${clr.textSoft};">
|
||||||
|
Here's all it takes:
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- ── STEPS ── -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 48px 0;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||||
|
${stepsHtml}
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- ── CTA ── -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:36px 48px 44px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="${createUrl}"
|
||||||
|
style="display:inline-block;background-color:${clr.ctaBg};color:${clr.ctaText};
|
||||||
|
text-decoration:none;padding:18px 52px;border-radius:10px;
|
||||||
|
font-family:'DM Sans',-apple-system,sans-serif;font-size:15px;
|
||||||
|
font-weight:600;letter-spacing:0.3px;border:1px solid rgba(200,162,87,0.3);">
|
||||||
|
Make my first QR code →
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-top:12px;">
|
||||||
|
<span style="font-family:'DM Sans',-apple-system,sans-serif;font-size:12px;
|
||||||
|
color:${clr.textMuted};">Free forever · no card needed</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- ── DIVIDER + SIGN-OFF ── -->
|
||||||
|
<tr><td style="padding:0 48px;"><div style="border-top:1px solid ${clr.border};"></div></td></tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:28px 48px 40px;">
|
||||||
|
<p style="margin:0;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:14px;color:${clr.textSoft};line-height:1.7;">
|
||||||
|
If something stopped you from getting started, just reply. I'll help directly.
|
||||||
|
</p>
|
||||||
|
<p style="margin:16px 0 0;font-family:'DM Serif Display',Georgia,serif;
|
||||||
|
font-size:17px;color:${clr.text};">Timo</p>
|
||||||
|
<p style="margin:2px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:11px;color:${clr.textMuted};letter-spacing:0.5px;">FOUNDER, QR MASTER</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await transport.sendMail({
|
||||||
|
from: 'Timo from QR Master <timo@qrmaster.net>',
|
||||||
|
replyTo: 'support@qrmaster.net',
|
||||||
|
to: email,
|
||||||
|
subject: "You haven't made one yet",
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upgrade Nudge — sent on Day 7 if user has ≥1 QR code and is still on FREE plan
|
||||||
|
*/
|
||||||
|
export async function sendUpgradeNudgeEmail(email: string, name: string, qrCount: number) {
|
||||||
|
const transport = createSmtpTransport();
|
||||||
|
const pricingUrl = `${appUrl}/pricing`;
|
||||||
|
const firstName = name.split(' ')[0];
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
label: 'Dynamic QR codes',
|
||||||
|
free: '3',
|
||||||
|
pro: '50',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Logo in QR code',
|
||||||
|
free: '—',
|
||||||
|
pro: 'Your logo, centered',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Brand colors',
|
||||||
|
free: '—',
|
||||||
|
pro: 'Any color',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'CSV export',
|
||||||
|
free: '✓',
|
||||||
|
pro: '✓',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rowsHtml = features.map((f, i) => `
|
||||||
|
<tr style="background-color:${i % 2 === 0 ? '#FAFAF8' : '#FFFFFF'};">
|
||||||
|
<td style="padding:12px 16px;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:13px;color:${clr.textSoft};border-bottom:1px solid ${clr.border};">
|
||||||
|
${f.label}
|
||||||
|
</td>
|
||||||
|
<td style="padding:12px 16px;text-align:center;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:13px;color:${clr.textMuted};border-bottom:1px solid ${clr.border};">
|
||||||
|
${f.free}
|
||||||
|
</td>
|
||||||
|
<td style="padding:12px 16px;text-align:center;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:13px;font-weight:500;color:${clr.text};border-bottom:1px solid ${clr.border};">
|
||||||
|
${f.pro}
|
||||||
|
</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
|
||||||
|
const html = emailShell('', `
|
||||||
|
<!-- ── HEADER ── -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:${clr.header};background-image:${dotGridPattern};
|
||||||
|
background-size:24px 24px;padding:44px 48px 40px;">
|
||||||
|
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td style="border:1px solid rgba(200,162,87,0.35);border-radius:8px;padding:7px 18px;">
|
||||||
|
<span style="font-family:'DM Sans',-apple-system,sans-serif;font-size:11px;
|
||||||
|
font-weight:600;letter-spacing:3px;color:${clr.gold};
|
||||||
|
text-transform:uppercase;">QR Master</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h1 style="margin:28px 0 0;font-family:'DM Serif Display',Georgia,serif;
|
||||||
|
font-size:34px;font-weight:400;line-height:1.2;color:#FFFFFF;">
|
||||||
|
${qrCount} of 3 free codes used,<br>
|
||||||
|
<em style="color:${clr.gold};">${firstName}.</em>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p style="margin:16px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:14px;color:rgba(255,255,255,0.45);">
|
||||||
|
Free plan · Day 7
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- ── BODY ── -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:40px 48px 0;">
|
||||||
|
<p style="margin:0;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:16px;line-height:1.75;color:${clr.text};">
|
||||||
|
The free plan gives you 3 dynamic QR codes. That's enough to start — not enough to scale. When you hit the limit, every new campaign means replacing an old one.
|
||||||
|
</p>
|
||||||
|
<p style="margin:16px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:16px;line-height:1.75;color:${clr.textSoft};">
|
||||||
|
Pro removes the ceiling — and adds custom branding your free codes never have:
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- ── COMPARISON TABLE ── -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:24px 48px 0;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"
|
||||||
|
style="border-radius:10px;overflow:hidden;border:1px solid ${clr.border};">
|
||||||
|
<!-- Table header -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 16px;background-color:${clr.pillBg};
|
||||||
|
font-family:'DM Sans',-apple-system,sans-serif;font-size:11px;
|
||||||
|
font-weight:600;letter-spacing:1px;color:${clr.textMuted};
|
||||||
|
text-transform:uppercase;border-bottom:1px solid ${clr.border};width:45%;">
|
||||||
|
Feature
|
||||||
|
</td>
|
||||||
|
<td style="padding:12px 16px;background-color:${clr.pillBg};text-align:center;
|
||||||
|
font-family:'DM Sans',-apple-system,sans-serif;font-size:11px;
|
||||||
|
font-weight:600;letter-spacing:1px;color:${clr.textMuted};
|
||||||
|
text-transform:uppercase;border-bottom:1px solid ${clr.border};width:20%;">
|
||||||
|
Free
|
||||||
|
</td>
|
||||||
|
<td style="padding:12px 16px;background-color:${clr.header};text-align:center;
|
||||||
|
font-family:'DM Sans',-apple-system,sans-serif;font-size:11px;
|
||||||
|
font-weight:600;letter-spacing:1px;color:${clr.gold};
|
||||||
|
text-transform:uppercase;border-bottom:1px solid ${clr.border};width:35%;">
|
||||||
|
Pro
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
${rowsHtml}
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- ── CTA ── -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:32px 48px 44px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="${pricingUrl}"
|
||||||
|
style="display:inline-block;background-color:${clr.ctaBg};color:${clr.ctaText};
|
||||||
|
text-decoration:none;padding:18px 52px;border-radius:10px;
|
||||||
|
font-family:'DM Sans',-apple-system,sans-serif;font-size:15px;
|
||||||
|
font-weight:600;letter-spacing:0.3px;border:1px solid rgba(200,162,87,0.3);">
|
||||||
|
See Pro plan →
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-top:12px;">
|
||||||
|
<span style="font-family:'DM Sans',-apple-system,sans-serif;font-size:12px;
|
||||||
|
color:${clr.textMuted};">Your free plan stays active — no pressure</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- ── DIVIDER + SIGN-OFF ── -->
|
||||||
|
<tr><td style="padding:0 48px;"><div style="border-top:1px solid ${clr.border};"></div></td></tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:28px 48px 40px;">
|
||||||
|
<p style="margin:0;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:14px;color:${clr.textSoft};line-height:1.7;">
|
||||||
|
If you have questions about what Pro unlocks, just reply here.
|
||||||
|
</p>
|
||||||
|
<p style="margin:16px 0 0;font-family:'DM Serif Display',Georgia,serif;
|
||||||
|
font-size:17px;color:${clr.text};">Timo</p>
|
||||||
|
<p style="margin:2px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:11px;color:${clr.textMuted};letter-spacing:0.5px;">FOUNDER, QR MASTER</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await transport.sendMail({
|
||||||
|
from: 'Timo from QR Master <timo@qrmaster.net>',
|
||||||
|
replyTo: 'support@qrmaster.net',
|
||||||
|
to: email,
|
||||||
|
subject: `You're ${qrCount >= 3 ? 'at' : `${3 - qrCount} away from`} the free limit`,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 30-Day Nudge — sent on Day 30 if user has ≥1 QR code and is still on FREE plan
|
||||||
|
*/
|
||||||
|
export async function sendThirtyDayNudgeEmail(email: string, name: string, qrCount: number) {
|
||||||
|
const transport = createSmtpTransport();
|
||||||
|
const pricingUrl = `${appUrl}/pricing`;
|
||||||
|
const firstName = name.split(' ')[0];
|
||||||
|
|
||||||
|
const html = emailShell('', `
|
||||||
|
<!-- ── HEADER ── -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:${clr.header};background-image:${dotGridPattern};
|
||||||
|
background-size:24px 24px;padding:44px 48px 40px;">
|
||||||
|
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td style="border:1px solid rgba(200,162,87,0.35);border-radius:8px;padding:7px 18px;">
|
||||||
|
<span style="font-family:'DM Sans',-apple-system,sans-serif;font-size:11px;
|
||||||
|
font-weight:600;letter-spacing:3px;color:${clr.gold};
|
||||||
|
text-transform:uppercase;">QR Master</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h1 style="margin:28px 0 0;font-family:'DM Serif Display',Georgia,serif;
|
||||||
|
font-size:34px;font-weight:400;line-height:1.2;color:#FFFFFF;">
|
||||||
|
A month in,<br>
|
||||||
|
<em style="color:${clr.gold};">${firstName}.</em>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p style="margin:16px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:14px;color:rgba(255,255,255,0.45);">
|
||||||
|
Free plan · Day 30
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- ── BODY ── -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:40px 48px 0;">
|
||||||
|
<p style="margin:0;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:16px;line-height:1.75;color:${clr.text};">
|
||||||
|
You've created ${qrCount} QR code${qrCount !== 1 ? 's' : ''} in your first month. That tells me you're actually using this — not just signing up to forget it.
|
||||||
|
</p>
|
||||||
|
<p style="margin:20px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:16px;line-height:1.75;color:${clr.textSoft};">
|
||||||
|
The one thing I hear most from Pro users who switched after a few weeks: <em style="color:${clr.text};">they wish they'd added their brand sooner.</em> Every code they printed on free didn't have their logo. Every flyer had a generic black pattern instead of their colors.
|
||||||
|
</p>
|
||||||
|
<p style="margin:20px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:16px;line-height:1.75;color:${clr.textSoft};">
|
||||||
|
Pro fixes that. Two things it unlocks that free never will:
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- ── FEATURES ── -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:28px 48px 0;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||||
|
<!-- Logo row -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:24px;background-color:${clr.pillBg};border-radius:12px;
|
||||||
|
border:1px solid ${clr.border};">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td style="width:36px;vertical-align:top;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td style="width:36px;height:36px;background-color:${clr.header};border-radius:10px;
|
||||||
|
text-align:center;vertical-align:middle;font-size:18px;color:${clr.gold};">
|
||||||
|
◆
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
<td style="padding-left:14px;vertical-align:top;padding-top:2px;">
|
||||||
|
<p style="margin:0;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:15px;font-weight:600;color:${clr.text};letter-spacing:-0.2px;">Your logo, inside every QR code</p>
|
||||||
|
<p style="margin:6px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:14px;color:${clr.textSoft};line-height:1.6;">
|
||||||
|
Upload once. Every code you make automatically carries your brand mark — menus, flyers, packaging, wherever.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Spacer -->
|
||||||
|
<tr><td style="height:16px;"></td></tr>
|
||||||
|
<!-- Colors row -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:24px;background-color:${clr.pillBg};border-radius:12px;
|
||||||
|
border:1px solid ${clr.border};">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td style="width:36px;vertical-align:top;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td style="width:36px;height:36px;background-color:${clr.header};border-radius:10px;
|
||||||
|
text-align:center;vertical-align:middle;font-size:18px;color:${clr.gold};">
|
||||||
|
●
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
<td style="padding-left:14px;vertical-align:top;padding-top:2px;">
|
||||||
|
<p style="margin:0;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:15px;font-weight:600;color:${clr.text};letter-spacing:-0.2px;">Brand colors — not just black</p>
|
||||||
|
<p style="margin:6px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:14px;color:${clr.textSoft};line-height:1.6;">
|
||||||
|
Match your QR codes to your brand palette. Looks intentional. Scans the same.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- ── CTA ── -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:32px 48px 44px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="${pricingUrl}"
|
||||||
|
style="display:inline-block;background-color:${clr.ctaBg};color:${clr.ctaText};
|
||||||
|
text-decoration:none;padding:18px 52px;border-radius:10px;
|
||||||
|
font-family:'DM Sans',-apple-system,sans-serif;font-size:15px;
|
||||||
|
font-weight:600;letter-spacing:0.3px;border:1px solid rgba(200,162,87,0.3);">
|
||||||
|
Add my brand to QR codes →
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-top:12px;">
|
||||||
|
<span style="font-family:'DM Sans',-apple-system,sans-serif;font-size:12px;
|
||||||
|
color:${clr.textMuted};">Your existing codes keep working — nothing breaks</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- ── DIVIDER + SIGN-OFF ── -->
|
||||||
|
<tr><td style="padding:0 48px;"><div style="border-top:1px solid ${clr.border};"></div></td></tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:28px 48px 40px;">
|
||||||
|
<p style="margin:0;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:14px;color:${clr.textSoft};line-height:1.7;">
|
||||||
|
If you ever want to talk through whether Pro makes sense for what you're building, just reply. Happy to help figure it out.
|
||||||
|
</p>
|
||||||
|
<p style="margin:16px 0 0;font-family:'DM Serif Display',Georgia,serif;
|
||||||
|
font-size:17px;color:${clr.text};">Timo</p>
|
||||||
|
<p style="margin:2px 0 0;font-family:'DM Sans',-apple-system,sans-serif;
|
||||||
|
font-size:11px;color:${clr.textMuted};letter-spacing:0.5px;">FOUNDER, QR MASTER</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await transport.sendMail({
|
||||||
|
from: 'Timo from QR Master <timo@qrmaster.net>',
|
||||||
|
replyTo: 'support@qrmaster.net',
|
||||||
|
to: email,
|
||||||
|
subject: `${firstName}, a month of QR codes — one upgrade worth making`,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
119
tmp_email_size_test.js
Normal file
119
tmp_email_size_test.js
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
// Quick size test for email templates - run with: node tmp_email_size_test.js
|
||||||
|
const src = require('fs').readFileSync('src/lib/email.ts', 'utf8');
|
||||||
|
|
||||||
|
// Extract the constants we need
|
||||||
|
const clr = {
|
||||||
|
bg: '#F5F2EC', card: '#FFFFFF', header: '#0B0D14', headerAccent: '#1A1D2E',
|
||||||
|
gold: '#C8A257', goldDim: '#A07E3A', text: '#1A1A1A', textSoft: '#5A5A5A',
|
||||||
|
textMuted: '#909090', border: '#E8E3D8', pillBg: '#F0EDE5',
|
||||||
|
ctaBg: '#0B0D14', ctaText: '#FFFFFF',
|
||||||
|
};
|
||||||
|
|
||||||
|
const dotGridPattern = `url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Ccircle%20cx%3D%2212%22%20cy%3D%2212%22%20r%3D%221.5%22%20fill%3D%22%23C8A257%22%20fill-opacity%3D%220.18%22%2F%3E%3C%2Fsvg%3E)`;
|
||||||
|
|
||||||
|
const webFontHead = `
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Sans:wght@300;400;500;600&display=swap');
|
||||||
|
.dm-serif { font-family: 'DM Serif Display', Georgia, 'Times New Roman', serif !important; }
|
||||||
|
.dm-sans { font-family: 'DM Sans', -apple-system, 'Helvetica Neue', Arial, sans-serif !important; }
|
||||||
|
</style>`;
|
||||||
|
|
||||||
|
const appUrl = 'https://www.qrmaster.net';
|
||||||
|
|
||||||
|
function emailShell(headExtra, bodyContent) {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="color-scheme" content="light">
|
||||||
|
<meta name="supported-color-schemes" content="light">
|
||||||
|
${webFontHead}
|
||||||
|
${headExtra}
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background-color:${clr.bg};-webkit-text-size-adjust:100%;mso-line-height-rule:exactly;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
|
||||||
|
style="background-color:${clr.bg};padding:40px 16px 60px;">
|
||||||
|
<tr><td align="center">
|
||||||
|
|
||||||
|
<!-- Email card -->
|
||||||
|
<table role="presentation" width="560" cellpadding="0" cellspacing="0" border="0"
|
||||||
|
style="max-width:560px;width:100%;background-color:${clr.card};border-radius:16px;
|
||||||
|
overflow:hidden;box-shadow:0 8px 40px rgba(0,0,0,0.12);">
|
||||||
|
${bodyContent}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Footer text -->
|
||||||
|
<table role="presentation" width="560" cellpadding="0" cellspacing="0" border="0"
|
||||||
|
style="max-width:560px;width:100%;margin-top:28px;">
|
||||||
|
<tr>
|
||||||
|
<td style="text-align:center;padding:0 20px;">
|
||||||
|
<p style="margin:0 0 6px;font-family:'DM Sans',-apple-system,sans-serif;font-size:12px;color:${clr.textMuted};">
|
||||||
|
<a href="${appUrl}" style="color:${clr.gold};text-decoration:none;font-weight:500;">www.qrmaster.net</a>
|
||||||
|
·
|
||||||
|
<a href="mailto:support@qrmaster.net" style="color:${clr.textMuted};text-decoration:none;">support@qrmaster.net</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;font-family:'DM Sans',-apple-system,sans-serif;font-size:11px;color:#B0A898;">
|
||||||
|
© 2026 QR Master. You're receiving this because you created an account.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build welcome email
|
||||||
|
const firstName = 'Timo';
|
||||||
|
const createUrl = `${appUrl}/create`;
|
||||||
|
|
||||||
|
const welcomeHtml = emailShell('', `
|
||||||
|
<!-- ── HEADER ── -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:${clr.card};background-image:${dotGridPattern};
|
||||||
|
background-size:24px 24px;padding:60px 48px 50px;text-align:center;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center">
|
||||||
|
<tr>
|
||||||
|
<td style="border:1px solid rgba(200,162,87,0.4);border-radius:6px;padding:6px 16px;background-color:rgba(200,162,87,0.05);">
|
||||||
|
<span style="font-family:'DM Sans',-apple-system,sans-serif;font-size:10px;font-weight:700;letter-spacing:3px;color:${clr.gold};text-transform:uppercase;">QR Master</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<h1 style="margin:36px 0 0;font-family:'DM Serif Display',Georgia,serif;font-size:42px;font-weight:400;line-height:1.1;color:${clr.text};letter-spacing:-0.5px;">
|
||||||
|
Welcome,<br>
|
||||||
|
<em style="color:${clr.gold};font-style:italic;">${firstName}.</em>
|
||||||
|
</h1>
|
||||||
|
<p style="margin:24px 0 0;font-family:'DM Sans',-apple-system,sans-serif;font-size:15px;color:${clr.textSoft};letter-spacing:0.3px;">
|
||||||
|
The physical world is now your canvas.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 0; text-align: center; background-color: ${clr.card};">
|
||||||
|
<img src="${appUrl}/email-hero-light.png" width="560" style="display:block;width:100%;max-width:560px;height:auto;border-bottom:3px solid ${clr.gold};" alt="Beautiful QR Code Experience">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:56px 48px 0;">
|
||||||
|
<p style="margin:0 0 24px;font-family:'DM Sans',-apple-system,sans-serif;font-size:16px;line-height:1.8;color:${clr.text};">
|
||||||
|
Let's be honest: most QR codes are static, look terrible, and break the moment you change a link. We built QR Master to fix that.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;font-family:'DM Sans',-apple-system,sans-serif;font-size:16px;line-height:1.8;color:${clr.text};">
|
||||||
|
Your account is fully activated. You now have the power to create beautiful, dynamic QR codes that adapt to your brand, never expire, and track every single scan (device, location, and time).
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;font-family:'DM Sans',-apple-system,sans-serif;font-size:16px;line-height:1.8;color:${clr.textSoft};">
|
||||||
|
To give you the perfect start, we've loaded your account with everything you need:
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const sizeKB = Buffer.byteLength(welcomeHtml, 'utf8') / 1024;
|
||||||
|
console.log('Welcome email rendered size:', sizeKB.toFixed(1), 'KB');
|
||||||
|
console.log('Gmail clips at: 102 KB');
|
||||||
|
console.log('Status:', sizeKB < 102 ? 'OK - under limit' : 'CLIPPED - over 102KB limit!');
|
||||||
|
console.log('');
|
||||||
|
console.log('Shell overhead approx:', Buffer.byteLength(emailShell('', ''), 'utf8') / 1024, 'KB');
|
||||||
@@ -7,5 +7,11 @@
|
|||||||
"env": {
|
"env": {
|
||||||
"NODE_OPTIONS": "--max-old-space-size=4096"
|
"NODE_OPTIONS": "--max-old-space-size=4096"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"crons": [
|
||||||
|
{
|
||||||
|
"path": "/api/cron/retention-emails",
|
||||||
|
"schedule": "0 10 * * *"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user