search console SEO ableitungen

This commit is contained in:
2026-03-23 19:01:52 -05:00
parent d47108d27c
commit e6b19e7a1c
150 changed files with 26257 additions and 25909 deletions

View File

@@ -1,33 +1,33 @@
{
"permissions": {
"allow": [
"Bash(docker-compose:*)",
"Bash(docker container prune:*)",
"Bash(npx prisma migrate dev:*)",
"Bash(npx prisma:*)",
"Bash(npm run dev)",
"Bash(timeout:*)",
"Bash(taskkill:*)",
"Bash(npx kill-port:*)",
"Bash(docker compose:*)",
"Bash(curl -I https://fonts.googleapis.com)",
"Bash(wsl:*)",
"Read(//c/Users/a931627/.ssh/**)",
"Bash(ssh-keygen:*)",
"Bash(cat:*)",
"Bash(git remote add:*)",
"Bash(git push:*)",
"Bash(git remote set-url:*)",
"Bash(npm install:*)",
"Bash(npm run build:*)",
"Bash(ls:*)",
"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(pkill:*)",
"Skill(shadcn-ui)",
"Bash(find:*)"
],
"deny": [],
"ask": []
}
}
{
"permissions": {
"allow": [
"Bash(docker-compose:*)",
"Bash(docker container prune:*)",
"Bash(npx prisma migrate dev:*)",
"Bash(npx prisma:*)",
"Bash(npm run dev)",
"Bash(timeout:*)",
"Bash(taskkill:*)",
"Bash(npx kill-port:*)",
"Bash(docker compose:*)",
"Bash(curl -I https://fonts.googleapis.com)",
"Bash(wsl:*)",
"Read(//c/Users/a931627/.ssh/**)",
"Bash(ssh-keygen:*)",
"Bash(cat:*)",
"Bash(git remote add:*)",
"Bash(git push:*)",
"Bash(git remote set-url:*)",
"Bash(npm install:*)",
"Bash(npm run build:*)",
"Bash(ls:*)",
"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(pkill:*)",
"Skill(shadcn-ui)",
"Bash(find:*)"
],
"deny": [],
"ask": []
}
}

View File

@@ -1,55 +1,55 @@
# Dependencies
node_modules
npm-debug.log
yarn-error.log
pnpm-debug.log
# Testing
coverage
.nyc_output
# Next.js
.next
out
dist
build
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDEs
.vscode
.idea
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Docker
Dockerfile
docker-compose*.yml
.dockerignore
# Misc
README.md
.prettierrc
.eslintrc.json
*.md
# Logs
logs
*.log
# Prisma
# prisma/migrations # Now included in Docker image for deployment
# Dependencies
node_modules
npm-debug.log
yarn-error.log
pnpm-debug.log
# Testing
coverage
.nyc_output
# Next.js
.next
out
dist
build
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDEs
.vscode
.idea
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Docker
Dockerfile
docker-compose*.yml
.dockerignore
# Misc
README.md
.prettierrc
.eslintrc.json
*.md
# Logs
logs
*.log
# Prisma
# prisma/migrations # Now included in Docker image for deployment

View File

@@ -1,14 +1,14 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{js,jsx,ts,tsx,json,css,scss,md}]
indent_style = space
indent_size = 2
[*.md]
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{js,jsx,ts,tsx,json,css,scss,md}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false

View File

@@ -1,17 +1,17 @@
# Database credentials (used by both db and web services in docker-compose.yml)
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=qrmaster
# Note: DATABASE_URL and DIRECT_URL are auto-generated from POSTGRES_* vars in docker-compose.yml
# You don't need to set them here when using Docker Compose
NODE_ENV=production
PORT=3000
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=CHANGE_ME
NEXT_PUBLIC_APP_URL=http://localhost:3000
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
REDIS_URL=redis://redis:6379
IP_SALT=CHANGE_ME_SALT
# Database credentials (used by both db and web services in docker-compose.yml)
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=qrmaster
# Note: DATABASE_URL and DIRECT_URL are auto-generated from POSTGRES_* vars in docker-compose.yml
# You don't need to set them here when using Docker Compose
NODE_ENV=production
PORT=3000
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=CHANGE_ME
NEXT_PUBLIC_APP_URL=http://localhost:3000
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
REDIS_URL=redis://redis:6379
IP_SALT=CHANGE_ME_SALT
ENABLE_DEMO=true

View File

@@ -1,26 +1,26 @@
name: CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Generate Prisma Client
run: npx prisma generate
- name: Build application
run: npm run build
- name: Run linter
name: CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Generate Prisma Client
run: npx prisma generate
- name: Build application
run: npm run build
- name: Run linter
run: npm run lint

2
.npmrc
View File

@@ -1,2 +1,2 @@
registry=https://registry.npmjs.org/
registry=https://registry.npmjs.org/
legacy-peer-deps=true

View File

@@ -1,8 +1,8 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false
}

View File

@@ -1,10 +1,10 @@
node_modules
.next
.git
*.log
.env
.env.local
.vercel
*.sql
/backups/
.npmrc
node_modules
.next
.git
*.log
.env
.env.local
.vercel
*.sql
/backups/
.npmrc

View File

@@ -1,3 +1,3 @@
{
"codium.codeCompletion.enable": false
{
"codium.codeCompletion.enable": false
}

View File

@@ -1,331 +1,331 @@
# AEO/GEO Implementation Plan — 22 Blog Posts
## Status: Template Created, Ready for Batch Implementation
**Date**: 2026-03-06
**Objective**: Optimize all 22 QR Master blog posts for AI search visibility (Perplexity, ChatGPT, Claude, Google AI Overviews)
---
## What Was Done
**POST #1: `trackable-qr-codes`** — Schema + Author Bio + Inline Citations
**POSTS #2-3**: Ready for implementation (see template below)
📋 **POSTS #4-22**: Use standardized template below
---
## AEO/GEO Optimization Template
### For Each Blog Post, Add:
#### **1. Schema Markup (JSON-LD)**
```javascript
// Add new "schema" field to post object:
schema: {
article: {
"@context": "https://schema.org",
"@type": "Article",
"headline": post.title,
"description": post.description,
"image": post.image,
"datePublished": post.datePublished,
"dateModified": post.dateModified,
"author": {
"@type": "Person",
"name": "Timo Schmidt",
"jobTitle": "QR Code & Marketing Expert",
"url": "https://www.qrmaster.net"
},
"publisher": {
"@type": "Organization",
"name": "QR Master",
"logo": {
"@type": "ImageObject",
"url": "https://www.qrmaster.net/logo.svg"
}
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": `https://www.qrmaster.net/blog/${post.slug}`
}
},
// IF post has FAQ section:
faqPage: {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": post.faq.map(item => ({
"@type": "Question",
"name": item.question,
"acceptedAnswer": {
"@type": "Answer",
"text": item.answer.replace(/<[^>]*>/g, '')
}
}))
},
// IF post is a How-To (like utm-parameter-qr-codes):
howTo: {
"@context": "https://schema.org",
"@type": "HowTo",
"name": post.title,
"step": post.keySteps.map((step, idx) => ({
"@type": "HowToStep",
"position": idx + 1,
"name": `Step ${idx + 1}`,
"text": step
}))
}
}
```
#### **2. Author Metadata**
```javascript
// Add to post object:
authorName: "Timo Schmidt",
authorTitle: "Product Lead & QR Code Expert",
```
#### **3. Content Structure Additions**
Add this block at the **very beginning** of the `content` field (after `<div class="blog-content">`):
```html
<div class="post-metadata bg-blue-50 p-3 rounded mb-6 border-l-4 border-blue-500">
<p class="text-sm text-gray-700">
<strong>Author:</strong> Timo Schmidt, QR Code & Marketing Expert at QR Master<br/>
📅 <strong>Published:</strong> [Full Date] | <strong>Last updated:</strong> [Full Date]
</p>
</div>
```
#### **4. Inline Citation Format**
For every statistic or claim from `sources[]`, convert to:
```html
<!-- Before: -->
<!-- Just a claim with no source -->
<!-- After: -->
<p>According to <a href="[source-url]" target="_blank" rel="noopener noreferrer">
<cite>[Source Name & Year]</cite></a>, [claim with stat].</p>
<!-- OR for blockquotes: -->
<blockquote>
"[Quote here]"
<footer><cite><a href="[url]" target="_blank">[Source]</a></cite></footer>
</blockquote>
```
#### **5. Freshness Signal**
In `dateModified` and `updatedAt` — already correct from previous fixes
In content metadata div — show the date clearly (see above)
---
## Priority Implementation Order
### **TIER 1: Immediate (High AI Citation Impact)**
1.**trackable-qr-codes** — Schema + Author + Citations (DONE)
2.**qr-code-scan-statistics-2026** — Many stats, needs inline citations
3.**dynamic-vs-static-qr-codes** — Comparison post, needs structure
4.**utm-parameter-qr-codes** — How-to, needs HowTo schema
### **TIER 2: High Impact (10 Posts)**
- qr-code-tracking-guide-2025
- qr-code-analytics
- qr-code-marketing
- bulk-qr-code-generator-excel
- qr-code-security
- qr-code-events
- business-card-qr-code
- qr-code-api-documentation
- free-vs-paid-qr-generator
- whatsapp-qr-code-generator
### **TIER 3: Medium Impact (8 Posts)**
- vcard-qr-code-generator
- qr-code-small-business
- qr-code-print-size-guide
- qr-code-restaurant-menu
- instagram-qr-code-generator
- spotify-code-generator-guide
- barcode-generator-tool
- best-qr-code-generator-2026
---
## Implementation Details by Post Type
### **Type A: Posts with FAQ (Use FAQPage Schema)**
```
Posts: trackable-qr-codes, dynamic-vs-static-qr-codes, utm-parameter-qr-codes, etc.
Action: Add schema.faqPage with all FAQ items
```
### **Type B: How-To Posts (Use HowTo Schema)**
```
Posts: utm-parameter-qr-codes, qr-code-tracking-guide-2025, qr-code-print-size-guide
Action: Add schema.howTo with keySteps mapped to HowToStep
```
### **Type C: Statistics/Research Posts (Focus on Citations)**
```
Posts: qr-code-scan-statistics-2026, qr-code-analytics
Action:
1. Add inline <cite> for every statistic
2. Add "According to [Source]" statements
3. Use blockquotes for key data points
```
### **Type D: Tool/Generator Posts (Focus on Clarity)**
```
Posts: vcard-qr-code-generator, spotify-code-generator-guide, etc.
Action:
1. Add clear definition in first paragraph
2. Add tool comparison if relevant
3. Add step-by-step usage (HowTo schema)
```
---
## Citation Formatting Examples
### **Before (Weak for AI):**
```html
<p>QR codes are popular. According to market research, adoption is growing.</p>
```
### **After (AI-Friendly):**
```html
<p>QR codes are popular. According to <cite><a href="https://www.mordorintelligence.com/..."
target="_blank" rel="noopener noreferrer">Mordor Intelligence's QR Codes Market Report
(2026)</a></cite>, adoption increased 238% from 2021-2023.</p>
```
### **For Statistics:**
```html
<!-- Weak -->
<p>85% of users scan QR codes.</p>
<!-- Strong -->
<p><strong>Key Statistic:</strong> <cite><a href="https://bitly.com/blog/..." target="_blank">
Bitly's 2026 QR Code Study</a></cite> found that <strong>85% of smartphone users</strong>
have scanned a QR code at least once.</p>
```
### **For Expert Quotes:**
```html
<!-- Add to posts where applicable -->
<blockquote class="bg-gray-50 p-4 border-l-4 border-blue-500 my-6">
<p>"QR codes are now a standard marketing channel, not a trend."</p>
<footer>
<strong>Timo Schmidt</strong>,
<cite><a href="https://www.qrmaster.net">Product Lead at QR Master</a></cite>
</footer>
</blockquote>
```
---
## Expected AEO/GEO Impact
Based on Princeton GEO research:
| Optimization | Impact | QR Master Potential |
|-------------|--------|-------------------|
| Article Schema | +5-10% | Apply to all 22 posts |
| FAQ Schema | +15-20% | 12 posts have FAQ |
| HowTo Schema | +12-15% | 8 posts are how-tos |
| Inline Citations | +40% | Stats posts: +40% |
| Author Attribution | +25% | All posts: +25% |
| Combined Effect | **+80-120%** | Full implementation |
**Conservative estimate**: 12-15 posts with full implementation could see **3-5x improvement** in AI citation likelihood.
---
## Monitoring & Validation
### **After Implementation, Check:**
1. **Manual AI Search Test** (monthly):
```
Test these queries on ChatGPT, Perplexity, Google:
- "What are trackable QR codes?" → Expect: qrmaster cite
- "How to create dynamic QR codes?" → Expect: qrmaster cite
- "Best QR code generator for tracking?" → Expect: qrmaster cite
```
2. **Schema Validation**:
```
Use: https://schema.org/validator
Check each post has valid Article + FAQ/HowTo schema
```
3. **Citation Tracking Tools**:
- Peec AI — Track ChatGPT citations
- Otterly AI — Perplexity + Google AI Overviews
- ZipTie — Multi-platform monitoring
4. **Analytics**:
- GA4: Monitor referral traffic from ai.google.com, perplexity.ai, openai.com
- Look for uptick in branded queries + QR-related queries
---
## Next Steps
### **Immediate (This Week)**
1. ✅ Template created (trackable-qr-codes as example)
2. ⏳ **Action**: Apply schema + citations to TIER 1 posts (4 posts)
3. ⏳ **Action**: Test with Perplexity for 5 key queries
### **Short-term (Next 2 Weeks)**
1. Apply schema to TIER 2 (10 posts)
2. Add inline citations across all 22 posts
3. Test again on ChatGPT + Google
### **Ongoing**
1. Monitor AI citations monthly
2. Update outdated stats/citations quarterly
3. Refresh "Last updated" dates regularly
---
## Files to Modify
**Primary**: `src/lib/blog-data.ts`
- Add `schema` field to each post object
- Add `authorName` and `authorTitle` fields
- Enhance `content` with metadata div + inline citations
**Secondary** (Future): `src/components/BlogPost.tsx` or similar
- Render schema as `<script type="application/ld+json">` tags
- Display author metadata visually
- Show "Last updated" date prominently
---
## Template Code (Ready to Use)
See `trackable-qr-codes` post in `blog-data.ts` for the full implementation example.
**Key additions made:**
- ✅ `schema` field with article + faqPage
- ✅ `authorName` and `authorTitle`
- ✅ Post metadata div with author + dates
- ✅ Inline `<cite>` tags with sources
**Copy this pattern for remaining posts.**
---
**Status**: Template ready. Awaiting implementation across remaining 21 posts.
**Estimated Time**: 6-8 hours for full implementation (can parallelize with developer)
**Expected ROI**: 3-5x improvement in AI citation likelihood for competitive QR queries
# AEO/GEO Implementation Plan — 22 Blog Posts
## Status: Template Created, Ready for Batch Implementation
**Date**: 2026-03-06
**Objective**: Optimize all 22 QR Master blog posts for AI search visibility (Perplexity, ChatGPT, Claude, Google AI Overviews)
---
## What Was Done
**POST #1: `trackable-qr-codes`** — Schema + Author Bio + Inline Citations
**POSTS #2-3**: Ready for implementation (see template below)
📋 **POSTS #4-22**: Use standardized template below
---
## AEO/GEO Optimization Template
### For Each Blog Post, Add:
#### **1. Schema Markup (JSON-LD)**
```javascript
// Add new "schema" field to post object:
schema: {
article: {
"@context": "https://schema.org",
"@type": "Article",
"headline": post.title,
"description": post.description,
"image": post.image,
"datePublished": post.datePublished,
"dateModified": post.dateModified,
"author": {
"@type": "Person",
"name": "Timo Schmidt",
"jobTitle": "QR Code & Marketing Expert",
"url": "https://www.qrmaster.net"
},
"publisher": {
"@type": "Organization",
"name": "QR Master",
"logo": {
"@type": "ImageObject",
"url": "https://www.qrmaster.net/logo.svg"
}
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": `https://www.qrmaster.net/blog/${post.slug}`
}
},
// IF post has FAQ section:
faqPage: {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": post.faq.map(item => ({
"@type": "Question",
"name": item.question,
"acceptedAnswer": {
"@type": "Answer",
"text": item.answer.replace(/<[^>]*>/g, '')
}
}))
},
// IF post is a How-To (like utm-parameter-qr-codes):
howTo: {
"@context": "https://schema.org",
"@type": "HowTo",
"name": post.title,
"step": post.keySteps.map((step, idx) => ({
"@type": "HowToStep",
"position": idx + 1,
"name": `Step ${idx + 1}`,
"text": step
}))
}
}
```
#### **2. Author Metadata**
```javascript
// Add to post object:
authorName: "Timo Schmidt",
authorTitle: "Product Lead & QR Code Expert",
```
#### **3. Content Structure Additions**
Add this block at the **very beginning** of the `content` field (after `<div class="blog-content">`):
```html
<div class="post-metadata bg-blue-50 p-3 rounded mb-6 border-l-4 border-blue-500">
<p class="text-sm text-gray-700">
<strong>Author:</strong> Timo Schmidt, QR Code & Marketing Expert at QR Master<br/>
📅 <strong>Published:</strong> [Full Date] | <strong>Last updated:</strong> [Full Date]
</p>
</div>
```
#### **4. Inline Citation Format**
For every statistic or claim from `sources[]`, convert to:
```html
<!-- Before: -->
<!-- Just a claim with no source -->
<!-- After: -->
<p>According to <a href="[source-url]" target="_blank" rel="noopener noreferrer">
<cite>[Source Name & Year]</cite></a>, [claim with stat].</p>
<!-- OR for blockquotes: -->
<blockquote>
"[Quote here]"
<footer><cite><a href="[url]" target="_blank">[Source]</a></cite></footer>
</blockquote>
```
#### **5. Freshness Signal**
In `dateModified` and `updatedAt` — already correct from previous fixes
In content metadata div — show the date clearly (see above)
---
## Priority Implementation Order
### **TIER 1: Immediate (High AI Citation Impact)**
1.**trackable-qr-codes** — Schema + Author + Citations (DONE)
2.**qr-code-scan-statistics-2026** — Many stats, needs inline citations
3.**dynamic-vs-static-qr-codes** — Comparison post, needs structure
4.**utm-parameter-qr-codes** — How-to, needs HowTo schema
### **TIER 2: High Impact (10 Posts)**
- qr-code-tracking-guide-2025
- qr-code-analytics
- qr-code-marketing
- bulk-qr-code-generator-excel
- qr-code-security
- qr-code-events
- business-card-qr-code
- qr-code-api-documentation
- free-vs-paid-qr-generator
- whatsapp-qr-code-generator
### **TIER 3: Medium Impact (8 Posts)**
- vcard-qr-code-generator
- qr-code-small-business
- qr-code-print-size-guide
- qr-code-restaurant-menu
- instagram-qr-code-generator
- spotify-code-generator-guide
- barcode-generator-tool
- best-qr-code-generator-2026
---
## Implementation Details by Post Type
### **Type A: Posts with FAQ (Use FAQPage Schema)**
```
Posts: trackable-qr-codes, dynamic-vs-static-qr-codes, utm-parameter-qr-codes, etc.
Action: Add schema.faqPage with all FAQ items
```
### **Type B: How-To Posts (Use HowTo Schema)**
```
Posts: utm-parameter-qr-codes, qr-code-tracking-guide-2025, qr-code-print-size-guide
Action: Add schema.howTo with keySteps mapped to HowToStep
```
### **Type C: Statistics/Research Posts (Focus on Citations)**
```
Posts: qr-code-scan-statistics-2026, qr-code-analytics
Action:
1. Add inline <cite> for every statistic
2. Add "According to [Source]" statements
3. Use blockquotes for key data points
```
### **Type D: Tool/Generator Posts (Focus on Clarity)**
```
Posts: vcard-qr-code-generator, spotify-code-generator-guide, etc.
Action:
1. Add clear definition in first paragraph
2. Add tool comparison if relevant
3. Add step-by-step usage (HowTo schema)
```
---
## Citation Formatting Examples
### **Before (Weak for AI):**
```html
<p>QR codes are popular. According to market research, adoption is growing.</p>
```
### **After (AI-Friendly):**
```html
<p>QR codes are popular. According to <cite><a href="https://www.mordorintelligence.com/..."
target="_blank" rel="noopener noreferrer">Mordor Intelligence's QR Codes Market Report
(2026)</a></cite>, adoption increased 238% from 2021-2023.</p>
```
### **For Statistics:**
```html
<!-- Weak -->
<p>85% of users scan QR codes.</p>
<!-- Strong -->
<p><strong>Key Statistic:</strong> <cite><a href="https://bitly.com/blog/..." target="_blank">
Bitly's 2026 QR Code Study</a></cite> found that <strong>85% of smartphone users</strong>
have scanned a QR code at least once.</p>
```
### **For Expert Quotes:**
```html
<!-- Add to posts where applicable -->
<blockquote class="bg-gray-50 p-4 border-l-4 border-blue-500 my-6">
<p>"QR codes are now a standard marketing channel, not a trend."</p>
<footer>
<strong>Timo Schmidt</strong>,
<cite><a href="https://www.qrmaster.net">Product Lead at QR Master</a></cite>
</footer>
</blockquote>
```
---
## Expected AEO/GEO Impact
Based on Princeton GEO research:
| Optimization | Impact | QR Master Potential |
|-------------|--------|-------------------|
| Article Schema | +5-10% | Apply to all 22 posts |
| FAQ Schema | +15-20% | 12 posts have FAQ |
| HowTo Schema | +12-15% | 8 posts are how-tos |
| Inline Citations | +40% | Stats posts: +40% |
| Author Attribution | +25% | All posts: +25% |
| Combined Effect | **+80-120%** | Full implementation |
**Conservative estimate**: 12-15 posts with full implementation could see **3-5x improvement** in AI citation likelihood.
---
## Monitoring & Validation
### **After Implementation, Check:**
1. **Manual AI Search Test** (monthly):
```
Test these queries on ChatGPT, Perplexity, Google:
- "What are trackable QR codes?" → Expect: qrmaster cite
- "How to create dynamic QR codes?" → Expect: qrmaster cite
- "Best QR code generator for tracking?" → Expect: qrmaster cite
```
2. **Schema Validation**:
```
Use: https://schema.org/validator
Check each post has valid Article + FAQ/HowTo schema
```
3. **Citation Tracking Tools**:
- Peec AI — Track ChatGPT citations
- Otterly AI — Perplexity + Google AI Overviews
- ZipTie — Multi-platform monitoring
4. **Analytics**:
- GA4: Monitor referral traffic from ai.google.com, perplexity.ai, openai.com
- Look for uptick in branded queries + QR-related queries
---
## Next Steps
### **Immediate (This Week)**
1. ✅ Template created (trackable-qr-codes as example)
2. ⏳ **Action**: Apply schema + citations to TIER 1 posts (4 posts)
3. ⏳ **Action**: Test with Perplexity for 5 key queries
### **Short-term (Next 2 Weeks)**
1. Apply schema to TIER 2 (10 posts)
2. Add inline citations across all 22 posts
3. Test again on ChatGPT + Google
### **Ongoing**
1. Monitor AI citations monthly
2. Update outdated stats/citations quarterly
3. Refresh "Last updated" dates regularly
---
## Files to Modify
**Primary**: `src/lib/blog-data.ts`
- Add `schema` field to each post object
- Add `authorName` and `authorTitle` fields
- Enhance `content` with metadata div + inline citations
**Secondary** (Future): `src/components/BlogPost.tsx` or similar
- Render schema as `<script type="application/ld+json">` tags
- Display author metadata visually
- Show "Last updated" date prominently
---
## Template Code (Ready to Use)
See `trackable-qr-codes` post in `blog-data.ts` for the full implementation example.
**Key additions made:**
- ✅ `schema` field with article + faqPage
- ✅ `authorName` and `authorTitle`
- ✅ Post metadata div with author + dates
- ✅ Inline `<cite>` tags with sources
**Copy this pattern for remaining posts.**
---
**Status**: Template ready. Awaiting implementation across remaining 21 posts.
**Estimated Time**: 6-8 hours for full implementation (can parallelize with developer)
**Expected ROI**: 3-5x improvement in AI citation likelihood for competitive QR queries

532
CLAUDE.md
View File

@@ -1,266 +1,266 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**QR Master** is a production-ready SaaS application for creating and managing QR codes with advanced analytics, Stripe payment integration, and multi-tier subscription plans (FREE, PRO, BUSINESS).
## Tech Stack
- **Frontend**: Next.js 14 (App Router), React 18, TypeScript, Tailwind CSS, Framer Motion
- **Backend**: Next.js API Routes, Prisma ORM, PostgreSQL
- **Authentication**: NextAuth.js v4 (Credentials + Google OAuth)
- **Payments**: Stripe (subscriptions, webhooks)
- **Cache**: Redis (optional)
- **Analytics**: PostHog (optional), QR scan tracking with IP hashing
- **QR Generation**: qrcode, qr-code-styling libraries
- **Bulk Operations**: Papa Parse (CSV), ExcelJS, JSZip
- **Storage**: AWS S3 (via @aws-sdk)
## Quick Development Commands
```bash
# Setup
npm install
npm run docker:dev # Start PostgreSQL & Redis in Docker
npx prisma migrate dev # Run migrations
npm run db:seed # Seed demo data
# Development
npm run dev # Start dev server (port 3050)
npm run lint # Run ESLint
# Database
npm run db:migrate # Run pending migrations (dev mode)
npm run db:deploy # Apply migrations (production)
npm run db:studio # Open Prisma Studio UI
npx prisma migrate reset # Reset database (drops, recreates, seeds)
# Docker
npm run docker:prod # Start full production stack
npm run docker:dev:stop # Stop dev services
npm run docker:logs # View logs
npm run docker:db # PostgreSQL CLI
npm run docker:redis # Redis CLI
npm run docker:backup # Backup database to SQL file
# Build & Deploy
npm run build # Production build
npm run start # Start production server
```
## Project Structure
```
src/
├── app/
│ └── (main)/
│ ├── (app)/ # Authenticated app pages (dashboard, bulk-creation, settings)
│ ├── (auth)/ # Auth pages (login, signup, forgot-password)
│ ├── (marketing)/ # Public pages & marketing tools
│ │ └── tools/ # QR code type-specific generators (20+ tools)
│ └── api/ # API routes organized by domain
│ ├── auth/ # Authentication (signin, signup, OAuth, password reset)
│ ├── qrs/ # QR code CRUD (GET, POST, PATCH, DELETE)
│ ├── analytics/ # Analytics summary endpoint
│ ├── stripe/ # Payment webhooks & session management
│ ├── user/ # User profile, plan, stats, password
│ ├── newsletter/ # Subscription management
│ └── [other]/ # admin, feedback, leads, bulk, etc.
├── components/
│ ├── ui/ # Reusable UI primitives (Card, Dialog, Input, etc.)
│ ├── generator/ # QR code generator components
│ ├── analytics/ # Charts, maps, data visualization
│ ├── dashboard/ # Dashboard-specific components
│ ├── settings/ # Settings & account components
│ └── SessionProvider.tsx # NextAuth session provider
├── lib/
│ ├── auth.ts # NextAuth configuration
│ ├── db.ts # Prisma client
│ ├── stripe.ts # Stripe utilities
│ ├── email.ts # Email sending (Resend)
│ ├── qr.ts # QR code generation utilities
│ ├── geo.ts # Geolocation utilities
│ ├── hash.ts # IP hashing (privacy)
│ ├── csrf.ts # CSRF token generation/validation
│ ├── rateLimit.ts # Rate limiting utilities
│ ├── schema.ts # Zod validation schemas
│ ├── validationSchemas.ts # Additional validation
│ └── cookieConfig.ts # Cookie configuration
├── hooks/
│ ├── useCsrf.ts # CSRF token hook
│ └── useTranslation.ts # i18n hook
└── types/
└── analytics.ts # Analytics type definitions
```
## Database Architecture
**Key Models** (see `prisma/schema.prisma`):
- **User**: User accounts with Stripe subscription fields
- **QRCode**: QR code records (static/dynamic, multiple content types)
- **QRScan**: Analytics data (ts, ipHash, device, os, country, UTM params)
- **Account/Session**: NextAuth authentication tables
- **Integration**: Third-party integrations
- **NewsletterSubscription**: Email subscribers
- **Lead**: Lead generation data
**QR Code Types**: URL, VCARD, GEO, PHONE, SMS, TEXT, WHATSAPP, PDF, APP, COUPON, FEEDBACK
## API Architecture
### Authentication Flow
- Credentials-based login/signup via `/api/auth/signup` and `/api/auth/simple-login`
- Google OAuth via `/api/auth/google`
- NextAuth.js session management at `/api/auth/[...nextauth]`
- Password reset: `/api/auth/forgot-password` + `/api/auth/reset-password`
### QR Code Operations
- **CRUD**: `GET/POST /api/qrs`, `GET/PATCH/DELETE /api/qrs/[id]`
- **Static Generation**: `POST /api/qrs/static`
- **Bulk Operations**: `POST /api/bulk/*` for CSV/Excel import
- **Public Redirect**: `GET /r/[slug]` (redirect + analytics tracking)
### Payments
- Stripe webhooks: `POST /api/stripe/webhook`
- Checkout session: `POST /api/stripe/checkout` or `/api/stripe/create-checkout-session`
- Customer portal: `POST /api/stripe/portal`
- Subscription sync: `POST /api/stripe/sync-subscription`
- Cancellation: `POST /api/stripe/cancel-subscription`
### Analytics
- Summary endpoint: `GET /api/analytics/summary?qrId=<id>`
- Scan tracking with hashed IP (GDPR-compliant)
## Key Implementation Patterns
### Authentication & Authorization
- NextAuth.js v4 with Prisma adapter
- Sessions stored in database
- CSRF protection on all mutations (check `useCsrf` hook)
- Password hashing with bcryptjs
### API Security
- Rate limiting on sensitive endpoints (auth, payments)
- CSRF tokens validated on POST/PATCH/DELETE
- IP hashing for privacy (IP_SALT environment variable)
- DNT header respected for analytics
### Database Operations
- Prisma ORM for all database access
- Migrations stored in `prisma/migrations/`
- Seed script for demo data in `prisma/seed.ts`
- Database indexes on frequently queried fields (userId, createdAt, etc.)
### QR Code Generation
- `qrcode` library for basic generation
- `qr-code-styling` for advanced customization
- `qrcode.react` for inline React components
- Canvas/SVG export via `html-to-image`, `jspdf`, `jszip`
### State & Validation
- Zod schemas in `/lib/schema.ts` for runtime validation
- TypeScript strict mode enabled
- Prisma provides type safety at database layer
## Environment Variables
**Required**:
- `DATABASE_URL` - PostgreSQL connection string
- `NEXTAUTH_SECRET` - JWT encryption secret
- `NEXTAUTH_URL` - Application URL (default: `http://localhost:3050`)
- `IP_SALT` - Salt for IP hashing
**Optional but Important**:
- `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`
- `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` - OAuth
- `REDIS_URL` - Redis connection
- `NEXT_PUBLIC_POSTHOG_KEY`, `NEXT_PUBLIC_POSTHOG_HOST` - Analytics
- `NEXT_PUBLIC_INDEXABLE` - Set to `true` for production (enables search engine indexing)
**Generate Secrets**:
```bash
openssl rand -base64 32 # NEXTAUTH_SECRET and IP_SALT
```
## Common Tasks
### Adding a New QR Code Type
1. Add type to `ContentType` enum in `prisma/schema.prisma`
2. Create generator component in `src/components/generator/` or `src/app/(main)/(marketing)/tools/`
3. Add validation schema in `src/lib/schema.ts`
4. Create API endpoint if needed in `src/app/(main)/api/qrs/`
### Creating a New Marketing Tool Page
1. Create page at `src/app/(main)/(marketing)/tools/[tool-name]/page.tsx`
2. Create generator component in same directory
3. Add SEO metadata in page component
4. Tool should be static (no database) or use public API endpoints
### Adding a New API Endpoint
1. Create route file in appropriate directory under `src/app/(main)/api/`
2. Add Zod validation schema in `src/lib/schema.ts`
3. Check authentication with `getServerSession()` if needed
4. Implement rate limiting for sensitive operations
5. Return typed responses with proper status codes
### Database Schema Changes
1. Update `prisma/schema.prisma`
2. Run `npx prisma migrate dev --name <migration-name>`
3. This creates migration file and updates Prisma client
4. Test with `npm run db:seed` if demo data affected
## Testing & Debugging
- Demo account (after seed): email: `demo@qrmaster.com`, password: `demo123`
- Prisma Studio: `npm run db:studio` - visual database browser
- API testing: Check `/src/app/(main)/api/` for examples
- Frontend: Pages hot-reload on changes during `npm run dev`
## Performance Considerations
- PostgreSQL indexes on `QRCode(userId, createdAt)` and `QRScan(qrId, ts)`
- Redis optional but recommended for caching analytics
- Static export for marketing pages when possible
- Image optimization enabled in `next.config.mjs`
- Prisma connection pooling recommended for production
## Common Pitfalls
1. **Database Connection**: If "Can't reach database server", ensure Docker is running (`npm run docker:dev`)
2. **Prisma Out of Sync**: Run `npx prisma generate` if TypeScript errors appear
3. **Migration Conflicts**: Use `npx prisma migrate reset` to start fresh
4. **Port 3050 in Use**: Change port in `package.json` dev script or kill process
5. **Build Failures**: Check `NODE_OPTIONS='--max-old-space-size=4096'` in build script - set higher if needed
## SEO & Content
- Schema.org structured data implemented for products, organizations, FAQs
- Breadcrumb navigation for UX/SEO
- Meta tags configured per page
- Open Graph images at `/api/og`
- Sitemap generation via next-sitemap
- Google Indexing API + IndexNow submission scripts available
## Deployment Notes
### Docker (Self-Hosted)
```bash
npm run docker:prod # Builds and starts full stack
docker-compose exec web npx prisma migrate deploy # Run migrations in container
```
### Vercel
- Push to GitHub and import in Vercel dashboard
- Set environment variables in Vercel settings
- Requires external PostgreSQL database (Vercel Postgres, Supabase, etc.)
- Redis is optional
## Additional Resources
- README.md - Detailed setup and feature overview
- DOCKER_SETUP.md - Complete Docker deployment guide
- prisma/schema.prisma - Database schema and relationships
- env.example - Environment variable template
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**QR Master** is a production-ready SaaS application for creating and managing QR codes with advanced analytics, Stripe payment integration, and multi-tier subscription plans (FREE, PRO, BUSINESS).
## Tech Stack
- **Frontend**: Next.js 14 (App Router), React 18, TypeScript, Tailwind CSS, Framer Motion
- **Backend**: Next.js API Routes, Prisma ORM, PostgreSQL
- **Authentication**: NextAuth.js v4 (Credentials + Google OAuth)
- **Payments**: Stripe (subscriptions, webhooks)
- **Cache**: Redis (optional)
- **Analytics**: PostHog (optional), QR scan tracking with IP hashing
- **QR Generation**: qrcode, qr-code-styling libraries
- **Bulk Operations**: Papa Parse (CSV), ExcelJS, JSZip
- **Storage**: AWS S3 (via @aws-sdk)
## Quick Development Commands
```bash
# Setup
npm install
npm run docker:dev # Start PostgreSQL & Redis in Docker
npx prisma migrate dev # Run migrations
npm run db:seed # Seed demo data
# Development
npm run dev # Start dev server (port 3050)
npm run lint # Run ESLint
# Database
npm run db:migrate # Run pending migrations (dev mode)
npm run db:deploy # Apply migrations (production)
npm run db:studio # Open Prisma Studio UI
npx prisma migrate reset # Reset database (drops, recreates, seeds)
# Docker
npm run docker:prod # Start full production stack
npm run docker:dev:stop # Stop dev services
npm run docker:logs # View logs
npm run docker:db # PostgreSQL CLI
npm run docker:redis # Redis CLI
npm run docker:backup # Backup database to SQL file
# Build & Deploy
npm run build # Production build
npm run start # Start production server
```
## Project Structure
```
src/
├── app/
│ └── (main)/
│ ├── (app)/ # Authenticated app pages (dashboard, bulk-creation, settings)
│ ├── (auth)/ # Auth pages (login, signup, forgot-password)
│ ├── (marketing)/ # Public pages & marketing tools
│ │ └── tools/ # QR code type-specific generators (20+ tools)
│ └── api/ # API routes organized by domain
│ ├── auth/ # Authentication (signin, signup, OAuth, password reset)
│ ├── qrs/ # QR code CRUD (GET, POST, PATCH, DELETE)
│ ├── analytics/ # Analytics summary endpoint
│ ├── stripe/ # Payment webhooks & session management
│ ├── user/ # User profile, plan, stats, password
│ ├── newsletter/ # Subscription management
│ └── [other]/ # admin, feedback, leads, bulk, etc.
├── components/
│ ├── ui/ # Reusable UI primitives (Card, Dialog, Input, etc.)
│ ├── generator/ # QR code generator components
│ ├── analytics/ # Charts, maps, data visualization
│ ├── dashboard/ # Dashboard-specific components
│ ├── settings/ # Settings & account components
│ └── SessionProvider.tsx # NextAuth session provider
├── lib/
│ ├── auth.ts # NextAuth configuration
│ ├── db.ts # Prisma client
│ ├── stripe.ts # Stripe utilities
│ ├── email.ts # Email sending (Resend)
│ ├── qr.ts # QR code generation utilities
│ ├── geo.ts # Geolocation utilities
│ ├── hash.ts # IP hashing (privacy)
│ ├── csrf.ts # CSRF token generation/validation
│ ├── rateLimit.ts # Rate limiting utilities
│ ├── schema.ts # Zod validation schemas
│ ├── validationSchemas.ts # Additional validation
│ └── cookieConfig.ts # Cookie configuration
├── hooks/
│ ├── useCsrf.ts # CSRF token hook
│ └── useTranslation.ts # i18n hook
└── types/
└── analytics.ts # Analytics type definitions
```
## Database Architecture
**Key Models** (see `prisma/schema.prisma`):
- **User**: User accounts with Stripe subscription fields
- **QRCode**: QR code records (static/dynamic, multiple content types)
- **QRScan**: Analytics data (ts, ipHash, device, os, country, UTM params)
- **Account/Session**: NextAuth authentication tables
- **Integration**: Third-party integrations
- **NewsletterSubscription**: Email subscribers
- **Lead**: Lead generation data
**QR Code Types**: URL, VCARD, GEO, PHONE, SMS, TEXT, WHATSAPP, PDF, APP, COUPON, FEEDBACK
## API Architecture
### Authentication Flow
- Credentials-based login/signup via `/api/auth/signup` and `/api/auth/simple-login`
- Google OAuth via `/api/auth/google`
- NextAuth.js session management at `/api/auth/[...nextauth]`
- Password reset: `/api/auth/forgot-password` + `/api/auth/reset-password`
### QR Code Operations
- **CRUD**: `GET/POST /api/qrs`, `GET/PATCH/DELETE /api/qrs/[id]`
- **Static Generation**: `POST /api/qrs/static`
- **Bulk Operations**: `POST /api/bulk/*` for CSV/Excel import
- **Public Redirect**: `GET /r/[slug]` (redirect + analytics tracking)
### Payments
- Stripe webhooks: `POST /api/stripe/webhook`
- Checkout session: `POST /api/stripe/checkout` or `/api/stripe/create-checkout-session`
- Customer portal: `POST /api/stripe/portal`
- Subscription sync: `POST /api/stripe/sync-subscription`
- Cancellation: `POST /api/stripe/cancel-subscription`
### Analytics
- Summary endpoint: `GET /api/analytics/summary?qrId=<id>`
- Scan tracking with hashed IP (GDPR-compliant)
## Key Implementation Patterns
### Authentication & Authorization
- NextAuth.js v4 with Prisma adapter
- Sessions stored in database
- CSRF protection on all mutations (check `useCsrf` hook)
- Password hashing with bcryptjs
### API Security
- Rate limiting on sensitive endpoints (auth, payments)
- CSRF tokens validated on POST/PATCH/DELETE
- IP hashing for privacy (IP_SALT environment variable)
- DNT header respected for analytics
### Database Operations
- Prisma ORM for all database access
- Migrations stored in `prisma/migrations/`
- Seed script for demo data in `prisma/seed.ts`
- Database indexes on frequently queried fields (userId, createdAt, etc.)
### QR Code Generation
- `qrcode` library for basic generation
- `qr-code-styling` for advanced customization
- `qrcode.react` for inline React components
- Canvas/SVG export via `html-to-image`, `jspdf`, `jszip`
### State & Validation
- Zod schemas in `/lib/schema.ts` for runtime validation
- TypeScript strict mode enabled
- Prisma provides type safety at database layer
## Environment Variables
**Required**:
- `DATABASE_URL` - PostgreSQL connection string
- `NEXTAUTH_SECRET` - JWT encryption secret
- `NEXTAUTH_URL` - Application URL (default: `http://localhost:3050`)
- `IP_SALT` - Salt for IP hashing
**Optional but Important**:
- `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`
- `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` - OAuth
- `REDIS_URL` - Redis connection
- `NEXT_PUBLIC_POSTHOG_KEY`, `NEXT_PUBLIC_POSTHOG_HOST` - Analytics
- `NEXT_PUBLIC_INDEXABLE` - Set to `true` for production (enables search engine indexing)
**Generate Secrets**:
```bash
openssl rand -base64 32 # NEXTAUTH_SECRET and IP_SALT
```
## Common Tasks
### Adding a New QR Code Type
1. Add type to `ContentType` enum in `prisma/schema.prisma`
2. Create generator component in `src/components/generator/` or `src/app/(main)/(marketing)/tools/`
3. Add validation schema in `src/lib/schema.ts`
4. Create API endpoint if needed in `src/app/(main)/api/qrs/`
### Creating a New Marketing Tool Page
1. Create page at `src/app/(main)/(marketing)/tools/[tool-name]/page.tsx`
2. Create generator component in same directory
3. Add SEO metadata in page component
4. Tool should be static (no database) or use public API endpoints
### Adding a New API Endpoint
1. Create route file in appropriate directory under `src/app/(main)/api/`
2. Add Zod validation schema in `src/lib/schema.ts`
3. Check authentication with `getServerSession()` if needed
4. Implement rate limiting for sensitive operations
5. Return typed responses with proper status codes
### Database Schema Changes
1. Update `prisma/schema.prisma`
2. Run `npx prisma migrate dev --name <migration-name>`
3. This creates migration file and updates Prisma client
4. Test with `npm run db:seed` if demo data affected
## Testing & Debugging
- Demo account (after seed): email: `demo@qrmaster.com`, password: `demo123`
- Prisma Studio: `npm run db:studio` - visual database browser
- API testing: Check `/src/app/(main)/api/` for examples
- Frontend: Pages hot-reload on changes during `npm run dev`
## Performance Considerations
- PostgreSQL indexes on `QRCode(userId, createdAt)` and `QRScan(qrId, ts)`
- Redis optional but recommended for caching analytics
- Static export for marketing pages when possible
- Image optimization enabled in `next.config.mjs`
- Prisma connection pooling recommended for production
## Common Pitfalls
1. **Database Connection**: If "Can't reach database server", ensure Docker is running (`npm run docker:dev`)
2. **Prisma Out of Sync**: Run `npx prisma generate` if TypeScript errors appear
3. **Migration Conflicts**: Use `npx prisma migrate reset` to start fresh
4. **Port 3050 in Use**: Change port in `package.json` dev script or kill process
5. **Build Failures**: Check `NODE_OPTIONS='--max-old-space-size=4096'` in build script - set higher if needed
## SEO & Content
- Schema.org structured data implemented for products, organizations, FAQs
- Breadcrumb navigation for UX/SEO
- Meta tags configured per page
- Open Graph images at `/api/og`
- Sitemap generation via next-sitemap
- Google Indexing API + IndexNow submission scripts available
## Deployment Notes
### Docker (Self-Hosted)
```bash
npm run docker:prod # Builds and starts full stack
docker-compose exec web npx prisma migrate deploy # Run migrations in container
```
### Vercel
- Push to GitHub and import in Vercel dashboard
- Set environment variables in Vercel settings
- Requires external PostgreSQL database (Vercel Postgres, Supabase, etc.)
- Redis is optional
## Additional Resources
- README.md - Detailed setup and feature overview
- DOCKER_SETUP.md - Complete Docker deployment guide
- prisma/schema.prisma - Database schema and relationships
- env.example - Environment variable template

View File

@@ -1,461 +1,461 @@
# 🐳 Docker Setup Guide for QR Master
Complete guide for setting up and running QR Master with Docker and PostgreSQL.
## Prerequisites
- Docker Desktop (Windows/Mac) or Docker Engine (Linux)
- Docker Compose V2
- Git
- Node.js 18+ (for local development)
## 🚀 Getting Started
### Option 1: Development Mode (Recommended for Development)
Run only the database services in Docker and the Next.js app on your host machine:
1. **Clone the repository**
```bash
git clone <your-repo-url>
cd QRMASTER
```
2. **Install dependencies**
```bash
npm install
```
3. **Set up environment variables**
```bash
cp env.example .env
```
Edit `.env` and update the values, especially:
- `NEXTAUTH_SECRET` (generate with: `openssl rand -base64 32`)
- `IP_SALT` (generate with: `openssl rand -base64 32`)
4. **Start database services**
```bash
npm run docker:dev
```
This starts PostgreSQL, Redis, and Adminer.
5. **Run database migrations**
```bash
npm run db:migrate
```
6. **Seed the database (optional)**
```bash
npm run db:seed
```
7. **Start the development server**
```bash
npm run dev
```
8. **Access the application**
- **App**: http://localhost:3050
- **Database UI (Adminer)**: http://localhost:8080
- System: PostgreSQL
- Server: db
- Username: postgres
- Password: postgres
- Database: qrmaster
### Option 2: Full Production Mode
Run everything in Docker containers:
1. **Clone and configure**
```bash
git clone <your-repo-url>
cd QRMASTER
cp env.example .env
```
2. **Update environment variables in `.env`**
Make sure to set strong secrets in production!
3. **Build and start all services**
```bash
npm run docker:prod
```
4. **Run migrations inside the container**
```bash
docker-compose exec web npx prisma migrate deploy
```
5. **Access the application**
- **App**: http://localhost:3050
## 📦 What Gets Installed
### Services
1. **PostgreSQL 16** - Main database
- Port: 5432
- Database: qrmaster
- User: postgres
- Password: postgres (change in production!)
2. **Redis 7** - Caching and rate limiting
- Port: 6379
- Max memory: 256MB with LRU eviction
- Persistence: AOF enabled
3. **Next.js App** - The QR Master application
- Port: 3000
- Built with production optimizations
4. **Adminer** - Database management UI (dev only)
- Port: 8080
- Lightweight alternative to pgAdmin
## 🗄️ Database Management
### Prisma Commands
```bash
# Generate Prisma Client
npm run db:generate
# Create a new migration
npm run db:migrate
# Deploy migrations (production)
npm run db:deploy
# Seed the database
npm run db:seed
# Open Prisma Studio
npm run db:studio
```
### Direct PostgreSQL Access
```bash
# Connect to PostgreSQL
docker-compose exec db psql -U postgres -d qrmaster
# Backup database
docker-compose exec db pg_dump -U postgres qrmaster > backup_$(date +%Y%m%d).sql
# Restore database
docker-compose exec -T db psql -U postgres qrmaster < backup.sql
```
## 🔧 Docker Commands
### Starting Services
```bash
# Development mode (database only)
npm run docker:dev
# or
docker-compose -f docker-compose.dev.yml up -d
# Production mode (full stack)
npm run docker:prod
# or
docker-compose up -d --build
# Production with database UI
docker-compose --profile dev up -d
```
### Stopping Services
```bash
# Stop all services
npm run docker:stop
# or
docker-compose down
# Stop and remove volumes (⚠️ deletes data!)
docker-compose down -v
```
### Viewing Logs
```bash
# All services
docker-compose logs -f
# Specific service
docker-compose logs -f web
docker-compose logs -f db
docker-compose logs -f redis
```
### Rebuilding
```bash
# Rebuild the web application
docker-compose build web
# Rebuild without cache
docker-compose build --no-cache web
# Rebuild and restart
docker-compose up -d --build web
```
## 🌍 Environment Variables
### Required Variables
```env
# Database (automatically set for Docker)
DATABASE_URL=postgresql://postgres:postgres@db:5432/qrmaster?schema=public
# NextAuth
NEXTAUTH_URL=http://localhost:3050
NEXTAUTH_SECRET=<generate-with-openssl-rand-base64-32>
# Security
IP_SALT=<generate-with-openssl-rand-base64-32>
```
### Optional Variables
```env
# OAuth (Google)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Redis (automatically set for Docker)
REDIS_URL=redis://redis:6379
# Features
ENABLE_DEMO=false
```
### Generating Secrets
```bash
# On Linux/Mac
openssl rand -base64 32
# On Windows (PowerShell)
[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))
```
## 🔍 Health Checks
All services include health checks:
```bash
# Check status of all services
docker-compose ps
# Check database health
docker-compose exec db pg_isready -U postgres
# Check Redis health
docker-compose exec redis redis-cli ping
# Check web app health
curl http://localhost:3050
```
## 🐛 Troubleshooting
### Database Connection Failed
```bash
# Check database is running
docker-compose ps db
# Check database logs
docker-compose logs db
# Restart database
docker-compose restart db
# Test connection
docker-compose exec db psql -U postgres -d qrmaster -c "SELECT version();"
```
### Port Already in Use
```bash
# Windows - find process using port
netstat -ano | findstr :3050
# Linux/Mac - find process using port
lsof -i :3050
# Kill the process or change the port in docker-compose.yml
```
### Migration Errors
```bash
# Reset the database (⚠️ deletes all data!)
docker-compose exec web npx prisma migrate reset
# Or manually
docker-compose down -v
docker-compose up -d db redis
npm run db:migrate
```
### Container Won't Start
```bash
# Remove all containers and volumes
docker-compose down -v
# Remove dangling images
docker image prune
# Rebuild from scratch
docker-compose build --no-cache
docker-compose up -d
```
### Prisma Client Not Generated
```bash
# Generate Prisma Client
npm run db:generate
# Or in Docker
docker-compose exec web npx prisma generate
```
## 🔐 Production Checklist
Before deploying to production:
- [ ] Change PostgreSQL password
- [ ] Generate strong `NEXTAUTH_SECRET`
- [ ] Generate strong `IP_SALT`
- [ ] Set proper `NEXTAUTH_URL` (your domain)
- [ ] Configure OAuth credentials (if using)
- [ ] Set up database backups
- [ ] Configure Redis persistence
- [ ] Set up monitoring and logging
- [ ] Enable HTTPS/SSL
- [ ] Review and adjust rate limits
- [ ] Set up a reverse proxy (nginx/Traefik)
- [ ] Configure firewall rules
- [ ] Set up automated database backups
## 📊 Monitoring
### Resource Usage
```bash
# View resource usage
docker stats
# View specific container
docker stats qrmaster-web qrmaster-db qrmaster-redis
```
### Database Size
```bash
# Check database size
docker-compose exec db psql -U postgres -d qrmaster -c "
SELECT
pg_size_pretty(pg_database_size('qrmaster')) as db_size,
pg_size_pretty(pg_total_relation_size('\"QRCode\"')) as qrcode_table_size;
"
```
### Redis Info
```bash
# Get Redis info
docker-compose exec redis redis-cli info
# Get memory usage
docker-compose exec redis redis-cli info memory
```
## 🔄 Backup and Recovery
### Automated Backups
Create a backup script `backup.sh`:
```bash
#!/bin/bash
BACKUP_DIR="./backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
mkdir -p $BACKUP_DIR
# Backup database
docker-compose exec -T db pg_dump -U postgres qrmaster > "$BACKUP_DIR/qrmaster_$TIMESTAMP.sql"
# Backup Redis
docker-compose exec redis redis-cli BGSAVE
echo "Backup completed: $BACKUP_DIR/qrmaster_$TIMESTAMP.sql"
```
### Restore from Backup
```bash
# Stop the web service
docker-compose stop web
# Restore database
cat backup_20241013.sql | docker-compose exec -T db psql -U postgres qrmaster
# Restart
docker-compose start web
```
## 🚀 Performance Tips
1. **Increase PostgreSQL shared buffers** (in production):
Edit `docker-compose.yml`:
```yaml
db:
command: postgres -c shared_buffers=256MB -c max_connections=100
```
2. **Enable Redis persistence**:
Already configured with AOF in docker-compose.yml
3. **Use connection pooling**:
Prisma already includes connection pooling
4. **Monitor slow queries**:
```bash
docker-compose exec db psql -U postgres -d qrmaster -c "
SELECT query, mean_exec_time, calls
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;"
```
## 📚 Additional Resources
- [Docker Documentation](https://docs.docker.com/)
- [PostgreSQL Documentation](https://www.postgresql.org/docs/)
- [Redis Documentation](https://redis.io/documentation)
- [Prisma Documentation](https://www.prisma.io/docs/)
- [Next.js Documentation](https://nextjs.org/docs)
## 🆘 Getting Help
If you encounter issues:
1. Check the logs: `docker-compose logs -f`
2. Check service health: `docker-compose ps`
3. Review this guide
4. Check the `docker/README.md` for more details
---
**Happy coding! 🎉**
# 🐳 Docker Setup Guide for QR Master
Complete guide for setting up and running QR Master with Docker and PostgreSQL.
## Prerequisites
- Docker Desktop (Windows/Mac) or Docker Engine (Linux)
- Docker Compose V2
- Git
- Node.js 18+ (for local development)
## 🚀 Getting Started
### Option 1: Development Mode (Recommended for Development)
Run only the database services in Docker and the Next.js app on your host machine:
1. **Clone the repository**
```bash
git clone <your-repo-url>
cd QRMASTER
```
2. **Install dependencies**
```bash
npm install
```
3. **Set up environment variables**
```bash
cp env.example .env
```
Edit `.env` and update the values, especially:
- `NEXTAUTH_SECRET` (generate with: `openssl rand -base64 32`)
- `IP_SALT` (generate with: `openssl rand -base64 32`)
4. **Start database services**
```bash
npm run docker:dev
```
This starts PostgreSQL, Redis, and Adminer.
5. **Run database migrations**
```bash
npm run db:migrate
```
6. **Seed the database (optional)**
```bash
npm run db:seed
```
7. **Start the development server**
```bash
npm run dev
```
8. **Access the application**
- **App**: http://localhost:3050
- **Database UI (Adminer)**: http://localhost:8080
- System: PostgreSQL
- Server: db
- Username: postgres
- Password: postgres
- Database: qrmaster
### Option 2: Full Production Mode
Run everything in Docker containers:
1. **Clone and configure**
```bash
git clone <your-repo-url>
cd QRMASTER
cp env.example .env
```
2. **Update environment variables in `.env`**
Make sure to set strong secrets in production!
3. **Build and start all services**
```bash
npm run docker:prod
```
4. **Run migrations inside the container**
```bash
docker-compose exec web npx prisma migrate deploy
```
5. **Access the application**
- **App**: http://localhost:3050
## 📦 What Gets Installed
### Services
1. **PostgreSQL 16** - Main database
- Port: 5432
- Database: qrmaster
- User: postgres
- Password: postgres (change in production!)
2. **Redis 7** - Caching and rate limiting
- Port: 6379
- Max memory: 256MB with LRU eviction
- Persistence: AOF enabled
3. **Next.js App** - The QR Master application
- Port: 3000
- Built with production optimizations
4. **Adminer** - Database management UI (dev only)
- Port: 8080
- Lightweight alternative to pgAdmin
## 🗄️ Database Management
### Prisma Commands
```bash
# Generate Prisma Client
npm run db:generate
# Create a new migration
npm run db:migrate
# Deploy migrations (production)
npm run db:deploy
# Seed the database
npm run db:seed
# Open Prisma Studio
npm run db:studio
```
### Direct PostgreSQL Access
```bash
# Connect to PostgreSQL
docker-compose exec db psql -U postgres -d qrmaster
# Backup database
docker-compose exec db pg_dump -U postgres qrmaster > backup_$(date +%Y%m%d).sql
# Restore database
docker-compose exec -T db psql -U postgres qrmaster < backup.sql
```
## 🔧 Docker Commands
### Starting Services
```bash
# Development mode (database only)
npm run docker:dev
# or
docker-compose -f docker-compose.dev.yml up -d
# Production mode (full stack)
npm run docker:prod
# or
docker-compose up -d --build
# Production with database UI
docker-compose --profile dev up -d
```
### Stopping Services
```bash
# Stop all services
npm run docker:stop
# or
docker-compose down
# Stop and remove volumes (⚠️ deletes data!)
docker-compose down -v
```
### Viewing Logs
```bash
# All services
docker-compose logs -f
# Specific service
docker-compose logs -f web
docker-compose logs -f db
docker-compose logs -f redis
```
### Rebuilding
```bash
# Rebuild the web application
docker-compose build web
# Rebuild without cache
docker-compose build --no-cache web
# Rebuild and restart
docker-compose up -d --build web
```
## 🌍 Environment Variables
### Required Variables
```env
# Database (automatically set for Docker)
DATABASE_URL=postgresql://postgres:postgres@db:5432/qrmaster?schema=public
# NextAuth
NEXTAUTH_URL=http://localhost:3050
NEXTAUTH_SECRET=<generate-with-openssl-rand-base64-32>
# Security
IP_SALT=<generate-with-openssl-rand-base64-32>
```
### Optional Variables
```env
# OAuth (Google)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Redis (automatically set for Docker)
REDIS_URL=redis://redis:6379
# Features
ENABLE_DEMO=false
```
### Generating Secrets
```bash
# On Linux/Mac
openssl rand -base64 32
# On Windows (PowerShell)
[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))
```
## 🔍 Health Checks
All services include health checks:
```bash
# Check status of all services
docker-compose ps
# Check database health
docker-compose exec db pg_isready -U postgres
# Check Redis health
docker-compose exec redis redis-cli ping
# Check web app health
curl http://localhost:3050
```
## 🐛 Troubleshooting
### Database Connection Failed
```bash
# Check database is running
docker-compose ps db
# Check database logs
docker-compose logs db
# Restart database
docker-compose restart db
# Test connection
docker-compose exec db psql -U postgres -d qrmaster -c "SELECT version();"
```
### Port Already in Use
```bash
# Windows - find process using port
netstat -ano | findstr :3050
# Linux/Mac - find process using port
lsof -i :3050
# Kill the process or change the port in docker-compose.yml
```
### Migration Errors
```bash
# Reset the database (⚠️ deletes all data!)
docker-compose exec web npx prisma migrate reset
# Or manually
docker-compose down -v
docker-compose up -d db redis
npm run db:migrate
```
### Container Won't Start
```bash
# Remove all containers and volumes
docker-compose down -v
# Remove dangling images
docker image prune
# Rebuild from scratch
docker-compose build --no-cache
docker-compose up -d
```
### Prisma Client Not Generated
```bash
# Generate Prisma Client
npm run db:generate
# Or in Docker
docker-compose exec web npx prisma generate
```
## 🔐 Production Checklist
Before deploying to production:
- [ ] Change PostgreSQL password
- [ ] Generate strong `NEXTAUTH_SECRET`
- [ ] Generate strong `IP_SALT`
- [ ] Set proper `NEXTAUTH_URL` (your domain)
- [ ] Configure OAuth credentials (if using)
- [ ] Set up database backups
- [ ] Configure Redis persistence
- [ ] Set up monitoring and logging
- [ ] Enable HTTPS/SSL
- [ ] Review and adjust rate limits
- [ ] Set up a reverse proxy (nginx/Traefik)
- [ ] Configure firewall rules
- [ ] Set up automated database backups
## 📊 Monitoring
### Resource Usage
```bash
# View resource usage
docker stats
# View specific container
docker stats qrmaster-web qrmaster-db qrmaster-redis
```
### Database Size
```bash
# Check database size
docker-compose exec db psql -U postgres -d qrmaster -c "
SELECT
pg_size_pretty(pg_database_size('qrmaster')) as db_size,
pg_size_pretty(pg_total_relation_size('\"QRCode\"')) as qrcode_table_size;
"
```
### Redis Info
```bash
# Get Redis info
docker-compose exec redis redis-cli info
# Get memory usage
docker-compose exec redis redis-cli info memory
```
## 🔄 Backup and Recovery
### Automated Backups
Create a backup script `backup.sh`:
```bash
#!/bin/bash
BACKUP_DIR="./backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
mkdir -p $BACKUP_DIR
# Backup database
docker-compose exec -T db pg_dump -U postgres qrmaster > "$BACKUP_DIR/qrmaster_$TIMESTAMP.sql"
# Backup Redis
docker-compose exec redis redis-cli BGSAVE
echo "Backup completed: $BACKUP_DIR/qrmaster_$TIMESTAMP.sql"
```
### Restore from Backup
```bash
# Stop the web service
docker-compose stop web
# Restore database
cat backup_20241013.sql | docker-compose exec -T db psql -U postgres qrmaster
# Restart
docker-compose start web
```
## 🚀 Performance Tips
1. **Increase PostgreSQL shared buffers** (in production):
Edit `docker-compose.yml`:
```yaml
db:
command: postgres -c shared_buffers=256MB -c max_connections=100
```
2. **Enable Redis persistence**:
Already configured with AOF in docker-compose.yml
3. **Use connection pooling**:
Prisma already includes connection pooling
4. **Monitor slow queries**:
```bash
docker-compose exec db psql -U postgres -d qrmaster -c "
SELECT query, mean_exec_time, calls
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;"
```
## 📚 Additional Resources
- [Docker Documentation](https://docs.docker.com/)
- [PostgreSQL Documentation](https://www.postgresql.org/docs/)
- [Redis Documentation](https://redis.io/documentation)
- [Prisma Documentation](https://www.prisma.io/docs/)
- [Next.js Documentation](https://nextjs.org/docs)
## 🆘 Getting Help
If you encounter issues:
1. Check the logs: `docker-compose logs -f`
2. Check service health: `docker-compose ps`
3. Review this guide
4. Check the `docker/README.md` for more details
---
**Happy coding! 🎉**

40
LICENSE
View File

@@ -1,21 +1,21 @@
MIT License
Copyright (c) 2025 QR Master
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
MIT License
Copyright (c) 2025 QR Master
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

962
README.md
View File

@@ -1,482 +1,482 @@
# QR Master - Create Custom QR Codes in Seconds
A production-ready SaaS application for creating and managing QR codes with advanced tracking, analytics, and Stripe payment integration.
## Features
- 🎨 **Custom QR Codes** - Create static and dynamic QR codes with full customization
- 📊 **Advanced Analytics** - Track scans, locations, devices, and user behavior
- 🔄 **Dynamic Content** - Edit QR code destinations anytime without reprinting
- 📦 **Bulk Operations** - Import CSV/Excel files to create up to 1,000 QR codes at once
- 💳 **Stripe Integration** - FREE, PRO, and BUSINESS subscription plans with secure billing
- 🎨 **Custom Branding** - Logo upload, custom colors (PRO+ plans)
- 🌍 **SEO Optimized** - Schema.org structured data, meta tags, breadcrumbs
- 🔒 **Privacy-First** - GDPR-compliant, hashed IPs, DNT headers respected
- 📱 **Responsive Design** - Works perfectly on all devices
## Tech Stack
- **Frontend**: Next.js 14 (App Router), TypeScript, Tailwind CSS
- **Backend**: Next.js API Routes, Prisma ORM
- **Database**: PostgreSQL (with Prisma migrations)
- **Cache**: Redis (optional)
- **Auth**: NextAuth.js (Credentials + Google OAuth)
- **Payments**: Stripe (Subscriptions & Webhooks)
- **QR Generation**: qrcode library
- **Bulk Processing**: Papa Parse (CSV), XLSX, JSZip
- **Analytics**: PostHog (optional)
- **SEO**: next-sitemap, Schema.org structured data
## Quick Start
### Prerequisites
- Node.js 18+
- Docker and Docker Compose V2
- Git
### Installation
#### Option 1: Development Mode (Recommended)
Run database in Docker, app on host machine:
1. Clone the repository:
```bash
git clone https://github.com/yourusername/qr-master.git
cd qr-master
```
2. Install dependencies:
```bash
npm install
```
3. Copy and configure environment:
```bash
cp env.example .env
```
Edit `.env` and set:
- `NEXTAUTH_SECRET` (generate: `openssl rand -base64 32`)
- `IP_SALT` (generate: `openssl rand -base64 32`)
- (Optional) Google OAuth credentials
4. Start database services:
```bash
npm run docker:dev
```
5. Run database migrations and seed:
```bash
npx prisma migrate dev
npm run db:seed
```
> **Note**: If you get migration errors, you can reset the database:
>
> ```bash
> npx prisma migrate reset
> ```
>
> This will drop the database, recreate it, run all migrations, and seed data.
6. Start development server:
```bash
npm run dev
```
7. Access the application:
- **App**: http://localhost:3050
- **Database UI**: http://localhost:8080 (Adminer - username: `root`, password: `root`)
- **Database**: localhost:5435 (username: `postgres`, password: `postgres`)
- **Redis**: localhost:6379
#### Option 2: Full Docker (Production)
Run everything in Docker:
1. Clone and setup:
```bash
git clone https://github.com/yourusername/qr-master.git
cd qr-master
cp env.example .env
# Edit .env with your configuration
```
2. Build and start:
```bash
npm run docker:prod
```
3. Run migrations:
```bash
docker-compose exec web npx prisma migrate deploy
```
4. Access at http://localhost:3050
📚 **For detailed Docker setup, see [DOCKER_SETUP.md](DOCKER_SETUP.md)**
## Demo Account
After running `npm run db:seed`, use these credentials to test the application:
- **Email**: demo@qrmaster.com
- **Password**: demo123
- **Plan**: FREE (3 QR codes limit)
The seed script also creates sample QR codes for testing.
## Development
### Available Scripts
```bash
# Development
npm run dev # Start Next.js dev server (port 3050)
npm run build # Build for production
npm run start # Start production server
# Database
npm run db:generate # Generate Prisma Client
npm run db:migrate # Run migrations (dev mode)
npm run db:deploy # Deploy migrations (production)
npm run db:seed # Seed database with demo data
npm run db:studio # Open Prisma Studio UI
npx prisma migrate reset # Reset database (drop, recreate, migrate, seed)
# Docker
npm run docker:dev # Start DB & Redis only
npm run docker:dev:stop # Stop dev services
npm run docker:dev:clean # Stop and clean containers
npm run docker:prod # Start full stack (production)
npm run docker:stop # Stop all services
npm run docker:logs # View container logs
npm run docker:db # PostgreSQL CLI
npm run docker:redis # Redis CLI
npm run docker:backup # Backup database to SQL file
```
### Local Development (without Docker)
1. Install dependencies:
```bash
npm install
```
2. Set up PostgreSQL and Redis locally
3. Configure `.env` with local database URL:
```env
DATABASE_URL=postgresql://postgres:postgres@localhost:5435/qrmaster?schema=public
```
4. Run migrations and seed:
```bash
npx prisma migrate dev
npm run db:seed
```
5. Start dev server:
```bash
npm run dev
```
### Resetting the Database
If you need to reset your database (drop all tables, recreate, and reseed):
```bash
# Full reset (drops database, reruns migrations, seeds data)
npx prisma migrate reset
# Or manually:
npx prisma migrate reset --skip-seed # Reset without seeding
npm run db:seed # Then seed manually
```
This is useful when:
- Schema has changed significantly
- You have migration conflicts
- You want to start fresh with clean data
### Project Structure
```
qr-master/
├── src/
│ ├── app/ # Next.js app router pages
│ ├── components/ # React components
│ ├── lib/ # Utility functions and configurations
│ ├── hooks/ # Custom React hooks
│ ├── styles/ # Global styles
│ └── i18n/ # Translation files
├── prisma/ # Database schema and migrations
├── docker/ # Docker initialization scripts
│ ├── init-db.sh # PostgreSQL initialization
│ └── README.md # Docker documentation
├── public/ # Static assets
├── docker-compose.yml # Production Docker setup
├── docker-compose.dev.yml # Development Docker setup
├── Dockerfile # Container definition
├── DOCKER_SETUP.md # Complete Docker guide
└── env.example # Environment template
```
## API Endpoints
### Authentication
- `POST /api/auth/signin` - Sign in with credentials
- `POST /api/auth/signout` - Sign out
- `GET /api/auth/session` - Get current session
### QR Codes
- `GET /api/qrs` - List all QR codes
- `POST /api/qrs` - Create a new QR code (dynamic or static)
- `POST /api/qrs/static` - Create a static QR code
- `GET /api/qrs/[id]` - Get QR code details
- `PATCH /api/qrs/[id]` - Update QR code
- `DELETE /api/qrs/[id]` - Delete QR code
- `DELETE /api/qrs/delete-all` - Delete all user's QR codes
### Analytics
- `GET /api/analytics/summary` - Get analytics summary for a QR code
### User & Settings
- `GET /api/user/plan` - Get current user plan
- `GET /api/user/stats` - Get user statistics
- `POST /api/user/password` - Update password
- `POST /api/user/profile` - Update profile
- `DELETE /api/user/delete` - Delete account
### Stripe Payments
- `POST /api/stripe/checkout` - Create checkout session
- `POST /api/stripe/portal` - Create customer portal session
- `POST /api/stripe/webhook` - Handle Stripe webhooks
- `POST /api/stripe/cancel-subscription` - Cancel subscription
### Public Redirect
- `GET /r/[slug]` - Redirect and track QR code scan
## Environment Variables
| Variable | Description | Required | Default |
| ------------------------------------ | ----------------------------- | -------- | ---------------------------------------------------------------------- |
| `DATABASE_URL` | PostgreSQL connection string | Yes | - |
| `NEXTAUTH_URL` | Application URL | Yes | `http://localhost:3050` |
| `NEXTAUTH_SECRET` | Secret for JWT encryption | Yes | - (Generate with `openssl rand -base64 32`) |
| `IP_SALT` | Salt for IP hashing (privacy) | Yes | Generate with `openssl rand -base64 32` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | No | - |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | No | - |
| `STRIPE_SECRET_KEY` | Stripe secret key | No | - |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret | No | - |
| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Stripe public key | No | - |
| `NEXT_PUBLIC_INDEXABLE` | Allow search engine indexing | No | `false` (set to `true` in production) |
| `REDIS_URL` | Redis connection string | No | `redis://redis:6379` |
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog analytics key | No | - |
| `NEXT_PUBLIC_POSTHOG_HOST` | PostHog host URL | No | - |
**Note**: Copy `env.example` to `.env` and update the values before starting.
### Generating Secrets
```bash
# Generate NEXTAUTH_SECRET
openssl rand -base64 32
# Generate IP_SALT
openssl rand -base64 32
```
## Security & Privacy
- **IP Hashing**: IP addresses are hashed with salt before storage (GDPR-compliant)
- **DNT Respect**: Honors Do Not Track browser headers
- **Rate Limiting**: API endpoints protected against abuse
- **CSRF Protection**: Token-based CSRF validation on mutations
- **Secure Sessions**: NextAuth.js with encrypted JWT tokens
- **Stripe Security**: PCI-compliant payment processing
- **SQL Injection Prevention**: Prisma ORM parameterized queries
## Database Schema
The application uses PostgreSQL with Prisma ORM. Key models:
- **User**: User accounts with Stripe subscription data
- **QRCode**: QR code records (static/dynamic, multiple content types)
- **QRScan**: Scan analytics data (hashed IP, device, location, UTM params)
- **Integration**: Third-party integrations (Zapier, etc.)
- **Account/Session**: NextAuth authentication data
### Supported QR Code Types
- **URL**: Website links
- **VCARD**: Contact cards (name, email, phone, company)
- **GEO**: GPS locations
- **PHONE**: Phone numbers (tel: links)
- **TEXT**: Plain text
- **SMS**: SMS messages
- **WHATSAPP**: WhatsApp messages
### Plans
- **FREE**: 3 dynamic QR codes, unlimited static
- **PRO**: 50 codes, custom branding, advanced analytics
- **BUSINESS**: 500 codes, bulk upload, API access, priority support
## Deployment
### Docker (Recommended for Self-Hosting)
The application includes production-ready Docker configuration with PostgreSQL and Redis:
```bash
# Build and start all services
docker-compose up -d --build
# Run migrations
docker-compose exec web npx prisma migrate deploy
# View logs
docker-compose logs -f
```
For detailed deployment instructions, see [DOCKER_SETUP.md](DOCKER_SETUP.md).
### Vercel
1. Push your code to GitHub
2. Import the project in Vercel
3. Add a PostgreSQL database (Vercel Postgres, Supabase, or other)
4. Add environment variables in Vercel dashboard
5. Deploy
**Note**: For Vercel deployment, you'll need to set up a PostgreSQL database separately.
## Troubleshooting
### Database Issues
**Problem**: Migration errors or schema conflicts
```bash
# Solution: Reset the database
npx prisma migrate reset
```
**Problem**: "Error: P1001: Can't reach database server"
```bash
# Check if Docker containers are running
docker ps
# Restart database
npm run docker:dev:stop
npm run docker:dev
```
**Problem**: Prisma Client out of sync
```bash
# Regenerate Prisma Client
npx prisma generate
```
**Problem**: Need to start completely fresh
```bash
# Stop all Docker containers
npm run docker:dev:stop
# Remove volumes (⚠️ deletes all data)
docker volume prune
# Restart everything
npm run docker:dev
npx prisma migrate dev
npm run db:seed
```
### Port Already in Use
If port 3050 is already in use:
```bash
# Find and kill the process (Windows)
netstat -ano | findstr :3050
taskkill /PID <PID> /F
# Or change the port in package.json
"dev": "next dev -p 3051"
```
### Docker Issues
**Problem**: Permission denied errors
```bash
# Windows: Run PowerShell as Administrator
# Linux/Mac: Use sudo for docker commands
```
**Problem**: Out of disk space
```bash
# Clean up Docker
docker system prune -a
```
## Contributing
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Support
For support, email support@qrmaster.net or open an issue on GitHub.
## Acknowledgments
- Next.js team for the amazing framework
- Vercel for hosting and deployment
- All open-source contributors
---
Built with ❤️ by QR Master Team
Führe diese im Terminal aus:
IndexNow (Bing/Yandex + Partner): npm run submit:indexnow
# QR Master - Create Custom QR Codes in Seconds
A production-ready SaaS application for creating and managing QR codes with advanced tracking, analytics, and Stripe payment integration.
## Features
- 🎨 **Custom QR Codes** - Create static and dynamic QR codes with full customization
- 📊 **Advanced Analytics** - Track scans, locations, devices, and user behavior
- 🔄 **Dynamic Content** - Edit QR code destinations anytime without reprinting
- 📦 **Bulk Operations** - Import CSV/Excel files to create up to 1,000 QR codes at once
- 💳 **Stripe Integration** - FREE, PRO, and BUSINESS subscription plans with secure billing
- 🎨 **Custom Branding** - Logo upload, custom colors (PRO+ plans)
- 🌍 **SEO Optimized** - Schema.org structured data, meta tags, breadcrumbs
- 🔒 **Privacy-First** - GDPR-compliant, hashed IPs, DNT headers respected
- 📱 **Responsive Design** - Works perfectly on all devices
## Tech Stack
- **Frontend**: Next.js 14 (App Router), TypeScript, Tailwind CSS
- **Backend**: Next.js API Routes, Prisma ORM
- **Database**: PostgreSQL (with Prisma migrations)
- **Cache**: Redis (optional)
- **Auth**: NextAuth.js (Credentials + Google OAuth)
- **Payments**: Stripe (Subscriptions & Webhooks)
- **QR Generation**: qrcode library
- **Bulk Processing**: Papa Parse (CSV), XLSX, JSZip
- **Analytics**: PostHog (optional)
- **SEO**: next-sitemap, Schema.org structured data
## Quick Start
### Prerequisites
- Node.js 18+
- Docker and Docker Compose V2
- Git
### Installation
#### Option 1: Development Mode (Recommended)
Run database in Docker, app on host machine:
1. Clone the repository:
```bash
git clone https://github.com/yourusername/qr-master.git
cd qr-master
```
2. Install dependencies:
```bash
npm install
```
3. Copy and configure environment:
```bash
cp env.example .env
```
Edit `.env` and set:
- `NEXTAUTH_SECRET` (generate: `openssl rand -base64 32`)
- `IP_SALT` (generate: `openssl rand -base64 32`)
- (Optional) Google OAuth credentials
4. Start database services:
```bash
npm run docker:dev
```
5. Run database migrations and seed:
```bash
npx prisma migrate dev
npm run db:seed
```
> **Note**: If you get migration errors, you can reset the database:
>
> ```bash
> npx prisma migrate reset
> ```
>
> This will drop the database, recreate it, run all migrations, and seed data.
6. Start development server:
```bash
npm run dev
```
7. Access the application:
- **App**: http://localhost:3050
- **Database UI**: http://localhost:8080 (Adminer - username: `root`, password: `root`)
- **Database**: localhost:5435 (username: `postgres`, password: `postgres`)
- **Redis**: localhost:6379
#### Option 2: Full Docker (Production)
Run everything in Docker:
1. Clone and setup:
```bash
git clone https://github.com/yourusername/qr-master.git
cd qr-master
cp env.example .env
# Edit .env with your configuration
```
2. Build and start:
```bash
npm run docker:prod
```
3. Run migrations:
```bash
docker-compose exec web npx prisma migrate deploy
```
4. Access at http://localhost:3050
📚 **For detailed Docker setup, see [DOCKER_SETUP.md](DOCKER_SETUP.md)**
## Demo Account
After running `npm run db:seed`, use these credentials to test the application:
- **Email**: demo@qrmaster.com
- **Password**: demo123
- **Plan**: FREE (3 QR codes limit)
The seed script also creates sample QR codes for testing.
## Development
### Available Scripts
```bash
# Development
npm run dev # Start Next.js dev server (port 3050)
npm run build # Build for production
npm run start # Start production server
# Database
npm run db:generate # Generate Prisma Client
npm run db:migrate # Run migrations (dev mode)
npm run db:deploy # Deploy migrations (production)
npm run db:seed # Seed database with demo data
npm run db:studio # Open Prisma Studio UI
npx prisma migrate reset # Reset database (drop, recreate, migrate, seed)
# Docker
npm run docker:dev # Start DB & Redis only
npm run docker:dev:stop # Stop dev services
npm run docker:dev:clean # Stop and clean containers
npm run docker:prod # Start full stack (production)
npm run docker:stop # Stop all services
npm run docker:logs # View container logs
npm run docker:db # PostgreSQL CLI
npm run docker:redis # Redis CLI
npm run docker:backup # Backup database to SQL file
```
### Local Development (without Docker)
1. Install dependencies:
```bash
npm install
```
2. Set up PostgreSQL and Redis locally
3. Configure `.env` with local database URL:
```env
DATABASE_URL=postgresql://postgres:postgres@localhost:5435/qrmaster?schema=public
```
4. Run migrations and seed:
```bash
npx prisma migrate dev
npm run db:seed
```
5. Start dev server:
```bash
npm run dev
```
### Resetting the Database
If you need to reset your database (drop all tables, recreate, and reseed):
```bash
# Full reset (drops database, reruns migrations, seeds data)
npx prisma migrate reset
# Or manually:
npx prisma migrate reset --skip-seed # Reset without seeding
npm run db:seed # Then seed manually
```
This is useful when:
- Schema has changed significantly
- You have migration conflicts
- You want to start fresh with clean data
### Project Structure
```
qr-master/
├── src/
│ ├── app/ # Next.js app router pages
│ ├── components/ # React components
│ ├── lib/ # Utility functions and configurations
│ ├── hooks/ # Custom React hooks
│ ├── styles/ # Global styles
│ └── i18n/ # Translation files
├── prisma/ # Database schema and migrations
├── docker/ # Docker initialization scripts
│ ├── init-db.sh # PostgreSQL initialization
│ └── README.md # Docker documentation
├── public/ # Static assets
├── docker-compose.yml # Production Docker setup
├── docker-compose.dev.yml # Development Docker setup
├── Dockerfile # Container definition
├── DOCKER_SETUP.md # Complete Docker guide
└── env.example # Environment template
```
## API Endpoints
### Authentication
- `POST /api/auth/signin` - Sign in with credentials
- `POST /api/auth/signout` - Sign out
- `GET /api/auth/session` - Get current session
### QR Codes
- `GET /api/qrs` - List all QR codes
- `POST /api/qrs` - Create a new QR code (dynamic or static)
- `POST /api/qrs/static` - Create a static QR code
- `GET /api/qrs/[id]` - Get QR code details
- `PATCH /api/qrs/[id]` - Update QR code
- `DELETE /api/qrs/[id]` - Delete QR code
- `DELETE /api/qrs/delete-all` - Delete all user's QR codes
### Analytics
- `GET /api/analytics/summary` - Get analytics summary for a QR code
### User & Settings
- `GET /api/user/plan` - Get current user plan
- `GET /api/user/stats` - Get user statistics
- `POST /api/user/password` - Update password
- `POST /api/user/profile` - Update profile
- `DELETE /api/user/delete` - Delete account
### Stripe Payments
- `POST /api/stripe/checkout` - Create checkout session
- `POST /api/stripe/portal` - Create customer portal session
- `POST /api/stripe/webhook` - Handle Stripe webhooks
- `POST /api/stripe/cancel-subscription` - Cancel subscription
### Public Redirect
- `GET /r/[slug]` - Redirect and track QR code scan
## Environment Variables
| Variable | Description | Required | Default |
| ------------------------------------ | ----------------------------- | -------- | ---------------------------------------------------------------------- |
| `DATABASE_URL` | PostgreSQL connection string | Yes | - |
| `NEXTAUTH_URL` | Application URL | Yes | `http://localhost:3050` |
| `NEXTAUTH_SECRET` | Secret for JWT encryption | Yes | - (Generate with `openssl rand -base64 32`) |
| `IP_SALT` | Salt for IP hashing (privacy) | Yes | Generate with `openssl rand -base64 32` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | No | - |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | No | - |
| `STRIPE_SECRET_KEY` | Stripe secret key | No | - |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret | No | - |
| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Stripe public key | No | - |
| `NEXT_PUBLIC_INDEXABLE` | Allow search engine indexing | No | `false` (set to `true` in production) |
| `REDIS_URL` | Redis connection string | No | `redis://redis:6379` |
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog analytics key | No | - |
| `NEXT_PUBLIC_POSTHOG_HOST` | PostHog host URL | No | - |
**Note**: Copy `env.example` to `.env` and update the values before starting.
### Generating Secrets
```bash
# Generate NEXTAUTH_SECRET
openssl rand -base64 32
# Generate IP_SALT
openssl rand -base64 32
```
## Security & Privacy
- **IP Hashing**: IP addresses are hashed with salt before storage (GDPR-compliant)
- **DNT Respect**: Honors Do Not Track browser headers
- **Rate Limiting**: API endpoints protected against abuse
- **CSRF Protection**: Token-based CSRF validation on mutations
- **Secure Sessions**: NextAuth.js with encrypted JWT tokens
- **Stripe Security**: PCI-compliant payment processing
- **SQL Injection Prevention**: Prisma ORM parameterized queries
## Database Schema
The application uses PostgreSQL with Prisma ORM. Key models:
- **User**: User accounts with Stripe subscription data
- **QRCode**: QR code records (static/dynamic, multiple content types)
- **QRScan**: Scan analytics data (hashed IP, device, location, UTM params)
- **Integration**: Third-party integrations (Zapier, etc.)
- **Account/Session**: NextAuth authentication data
### Supported QR Code Types
- **URL**: Website links
- **VCARD**: Contact cards (name, email, phone, company)
- **GEO**: GPS locations
- **PHONE**: Phone numbers (tel: links)
- **TEXT**: Plain text
- **SMS**: SMS messages
- **WHATSAPP**: WhatsApp messages
### Plans
- **FREE**: 3 dynamic QR codes, unlimited static
- **PRO**: 50 codes, custom branding, advanced analytics
- **BUSINESS**: 500 codes, bulk upload, API access, priority support
## Deployment
### Docker (Recommended for Self-Hosting)
The application includes production-ready Docker configuration with PostgreSQL and Redis:
```bash
# Build and start all services
docker-compose up -d --build
# Run migrations
docker-compose exec web npx prisma migrate deploy
# View logs
docker-compose logs -f
```
For detailed deployment instructions, see [DOCKER_SETUP.md](DOCKER_SETUP.md).
### Vercel
1. Push your code to GitHub
2. Import the project in Vercel
3. Add a PostgreSQL database (Vercel Postgres, Supabase, or other)
4. Add environment variables in Vercel dashboard
5. Deploy
**Note**: For Vercel deployment, you'll need to set up a PostgreSQL database separately.
## Troubleshooting
### Database Issues
**Problem**: Migration errors or schema conflicts
```bash
# Solution: Reset the database
npx prisma migrate reset
```
**Problem**: "Error: P1001: Can't reach database server"
```bash
# Check if Docker containers are running
docker ps
# Restart database
npm run docker:dev:stop
npm run docker:dev
```
**Problem**: Prisma Client out of sync
```bash
# Regenerate Prisma Client
npx prisma generate
```
**Problem**: Need to start completely fresh
```bash
# Stop all Docker containers
npm run docker:dev:stop
# Remove volumes (⚠️ deletes all data)
docker volume prune
# Restart everything
npm run docker:dev
npx prisma migrate dev
npm run db:seed
```
### Port Already in Use
If port 3050 is already in use:
```bash
# Find and kill the process (Windows)
netstat -ano | findstr :3050
taskkill /PID <PID> /F
# Or change the port in package.json
"dev": "next dev -p 3051"
```
### Docker Issues
**Problem**: Permission denied errors
```bash
# Windows: Run PowerShell as Administrator
# Linux/Mac: Use sudo for docker commands
```
**Problem**: Out of disk space
```bash
# Clean up Docker
docker system prune -a
```
## Contributing
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Support
For support, email support@qrmaster.net or open an issue on GitHub.
## Acknowledgments
- Next.js team for the amazing framework
- Vercel for hosting and deployment
- All open-source contributors
---
Built with ❤️ by QR Master Team
Führe diese im Terminal aus:
IndexNow (Bing/Yandex + Partner): npm run submit:indexnow
Google Indexing API: npm run trigger:indexing

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
const { spawn } = require('child_process');
// Start Next.js dev server with explicit hostname
const next = spawn('next', ['dev', '-p', '3050', '-H', '0.0.0.0'], {
stdio: 'inherit',
shell: true,
env: { ...process.env, HOSTNAME: '0.0.0.0' }
});
next.on('close', (code) => {
process.exit(code);
});
const { spawn } = require('child_process');
// Start Next.js dev server with explicit hostname
const next = spawn('next', ['dev', '-p', '3050', '-H', '0.0.0.0'], {
stdio: 'inherit',
shell: true,
env: { ...process.env, HOSTNAME: '0.0.0.0' }
});
next.on('close', (code) => {
process.exit(code);
});

View File

@@ -1,66 +1,66 @@
services:
# PostgreSQL Database
db:
image: postgres:16-alpine
container_name: qrmaster-db-dev
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=en_US.utf8"
ports:
- "5435:5432"
volumes:
- dbdata_dev:/var/lib/postgresql/data
- ./docker/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres -d qrmaster" ]
interval: 5s
timeout: 5s
retries: 10
networks:
- qrmaster-network
# Redis Cache
redis:
image: redis:7-alpine
container_name: qrmaster-redis-dev
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
ports:
- "6379:6379"
volumes:
- redisdata_dev:/data
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 5s
timeout: 3s
retries: 5
networks:
- qrmaster-network
# Adminer - Database Management UI
adminer:
image: adminer:latest
container_name: qrmaster-adminer-dev
restart: unless-stopped
ports:
- "8081:8080"
environment:
ADMINER_DEFAULT_SERVER: db
ADMINER_DESIGN: pepa-linha
depends_on:
- db
networks:
- qrmaster-network
volumes:
dbdata_dev:
driver: local
redisdata_dev:
driver: local
networks:
qrmaster-network:
driver: bridge
services:
# PostgreSQL Database
db:
image: postgres:16-alpine
container_name: qrmaster-db-dev
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=en_US.utf8"
ports:
- "5435:5432"
volumes:
- dbdata_dev:/var/lib/postgresql/data
- ./docker/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres -d qrmaster" ]
interval: 5s
timeout: 5s
retries: 10
networks:
- qrmaster-network
# Redis Cache
redis:
image: redis:7-alpine
container_name: qrmaster-redis-dev
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
ports:
- "6379:6379"
volumes:
- redisdata_dev:/data
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 5s
timeout: 3s
retries: 5
networks:
- qrmaster-network
# Adminer - Database Management UI
adminer:
image: adminer:latest
container_name: qrmaster-adminer-dev
restart: unless-stopped
ports:
- "8081:8080"
environment:
ADMINER_DEFAULT_SERVER: db
ADMINER_DESIGN: pepa-linha
depends_on:
- db
networks:
- qrmaster-network
volumes:
dbdata_dev:
driver: local
redisdata_dev:
driver: local
networks:
qrmaster-network:
driver: bridge

View File

@@ -1,276 +1,276 @@
# Docker Setup for QR Master
This directory contains Docker configuration files for running QR Master with PostgreSQL database.
## 🚀 Quick Start
### Development (Database Only)
For local development where you run Next.js on your host machine:
```bash
# Start PostgreSQL and Redis
docker-compose -f docker-compose.dev.yml up -d
# Run database migrations
npm run db:migrate
# Start the development server
npm run dev
```
Access:
- **Application**: http://localhost:3050
- **Database**: localhost:5432
- **Redis**: localhost:6379
- **Adminer (DB UI)**: http://localhost:8080
### Production (Full Stack)
To run the entire application in Docker:
```bash
# Build and start all services
docker-compose up -d --build
# Run database migrations
docker-compose exec web npx prisma migrate deploy
# (Optional) Seed the database
docker-compose exec web npm run db:seed
```
Access:
- **Application**: http://localhost:3050
- **Database**: localhost:5432
- **Redis**: localhost:6379
- **Adminer (DB UI)**: http://localhost:8080 (only with --profile dev)
To include Adminer in production mode:
```bash
docker-compose --profile dev up -d
```
## 📦 Services
### PostgreSQL (db)
- **Image**: postgres:16-alpine
- **Port**: 5432
- **Database**: qrmaster
- **User**: postgres
- **Password**: postgres (change in production!)
### Redis (redis)
- **Image**: redis:7-alpine
- **Port**: 6379
- **Max Memory**: 256MB with LRU eviction policy
- **Persistence**: AOF enabled
### Next.js Application (web)
- **Port**: 3050
- **Environment**: Production
- **Health Check**: HTTP GET on localhost:3050
### Adminer (adminer)
- **Image**: adminer:latest
- **Port**: 8080
- **Purpose**: Database management UI
- **Profile**: dev (optional in production)
## 🗄️ Database Management
### Migrations
```bash
# Create a new migration
npm run db:migrate
# Deploy migrations in Docker
docker-compose exec web npx prisma migrate deploy
# Reset database (caution!)
docker-compose exec web npx prisma migrate reset
```
### Prisma Studio
```bash
# On host machine
npm run db:studio
# Or in Docker
docker-compose exec web npx prisma studio
```
### Backup and Restore
```bash
# Backup
docker-compose exec db pg_dump -U postgres qrmaster > backup.sql
# Restore
docker-compose exec -T db psql -U postgres qrmaster < backup.sql
```
## 🔧 Useful Commands
### View Logs
```bash
# All services
docker-compose logs -f
# Specific service
docker-compose logs -f web
docker-compose logs -f db
docker-compose logs -f redis
```
### Shell Access
```bash
# Next.js container
docker-compose exec web sh
# PostgreSQL container
docker-compose exec db psql -U postgres -d qrmaster
# Redis container
docker-compose exec redis redis-cli
```
### Stop and Clean
```bash
# Stop all services
docker-compose down
# Stop and remove volumes (deletes data!)
docker-compose down -v
# Stop and remove everything including images
docker-compose down -v --rmi all
```
## 🔐 Environment Variables
Create a `.env` file in the root directory (copy from `env.example`):
```bash
cp env.example .env
```
Required variables:
- `DATABASE_URL`: PostgreSQL connection string
- `NEXTAUTH_SECRET`: Secret for NextAuth.js
- `NEXTAUTH_URL`: Application URL
- `IP_SALT`: Salt for hashing IP addresses
- `REDIS_URL`: Redis connection string
## 🌐 Network Architecture
All services run on a custom bridge network `qrmaster-network` which allows:
- Service discovery by container name
- Network isolation from other Docker projects
- Internal DNS resolution
## 📊 Volumes
### Persistent Data
- `dbdata`: PostgreSQL data
- `redisdata`: Redis data
### Volume Management
```bash
# List volumes
docker volume ls
# Inspect volume
docker volume inspect qrmaster_dbdata
# Remove all unused volumes
docker volume prune
```
## 🐛 Troubleshooting
### Database Connection Issues
```bash
# Check if database is ready
docker-compose exec db pg_isready -U postgres
# Check database logs
docker-compose logs db
# Restart database
docker-compose restart db
```
### Application Won't Start
```bash
# Check health status
docker-compose ps
# View application logs
docker-compose logs web
# Rebuild the application
docker-compose up -d --build web
```
### Port Already in Use
If ports 3050, 5432, 6379, or 8080 are already in use:
```bash
# Find process using port
# Windows
netstat -ano | findstr :3050
# Linux/Mac
lsof -i :3050
# Kill process or change port in docker-compose.yml
```
## 🔄 Updates and Maintenance
### Update Dependencies
```bash
# Update Node packages
npm update
# Rebuild Docker images
docker-compose build --no-cache
```
### Update Docker Images
```bash
# Pull latest images
docker-compose pull
# Restart with new images
docker-compose up -d
```
## 📝 Notes
- **Development**: Use `docker-compose.dev.yml` to run only the database and Redis
- **Production**: Use `docker-compose.yml` to run the full stack
- **Security**: Always change default passwords in production
- **Backups**: Implement regular database backups in production
- **Scaling**: For production, consider using PostgreSQL replication and Redis Sentinel
## 🆘 Support
For more information, see:
- [Docker Documentation](https://docs.docker.com/)
- [PostgreSQL Documentation](https://www.postgresql.org/docs/)
- [Prisma Documentation](https://www.prisma.io/docs/)
- [Next.js Documentation](https://nextjs.org/docs)
# Docker Setup for QR Master
This directory contains Docker configuration files for running QR Master with PostgreSQL database.
## 🚀 Quick Start
### Development (Database Only)
For local development where you run Next.js on your host machine:
```bash
# Start PostgreSQL and Redis
docker-compose -f docker-compose.dev.yml up -d
# Run database migrations
npm run db:migrate
# Start the development server
npm run dev
```
Access:
- **Application**: http://localhost:3050
- **Database**: localhost:5432
- **Redis**: localhost:6379
- **Adminer (DB UI)**: http://localhost:8080
### Production (Full Stack)
To run the entire application in Docker:
```bash
# Build and start all services
docker-compose up -d --build
# Run database migrations
docker-compose exec web npx prisma migrate deploy
# (Optional) Seed the database
docker-compose exec web npm run db:seed
```
Access:
- **Application**: http://localhost:3050
- **Database**: localhost:5432
- **Redis**: localhost:6379
- **Adminer (DB UI)**: http://localhost:8080 (only with --profile dev)
To include Adminer in production mode:
```bash
docker-compose --profile dev up -d
```
## 📦 Services
### PostgreSQL (db)
- **Image**: postgres:16-alpine
- **Port**: 5432
- **Database**: qrmaster
- **User**: postgres
- **Password**: postgres (change in production!)
### Redis (redis)
- **Image**: redis:7-alpine
- **Port**: 6379
- **Max Memory**: 256MB with LRU eviction policy
- **Persistence**: AOF enabled
### Next.js Application (web)
- **Port**: 3050
- **Environment**: Production
- **Health Check**: HTTP GET on localhost:3050
### Adminer (adminer)
- **Image**: adminer:latest
- **Port**: 8080
- **Purpose**: Database management UI
- **Profile**: dev (optional in production)
## 🗄️ Database Management
### Migrations
```bash
# Create a new migration
npm run db:migrate
# Deploy migrations in Docker
docker-compose exec web npx prisma migrate deploy
# Reset database (caution!)
docker-compose exec web npx prisma migrate reset
```
### Prisma Studio
```bash
# On host machine
npm run db:studio
# Or in Docker
docker-compose exec web npx prisma studio
```
### Backup and Restore
```bash
# Backup
docker-compose exec db pg_dump -U postgres qrmaster > backup.sql
# Restore
docker-compose exec -T db psql -U postgres qrmaster < backup.sql
```
## 🔧 Useful Commands
### View Logs
```bash
# All services
docker-compose logs -f
# Specific service
docker-compose logs -f web
docker-compose logs -f db
docker-compose logs -f redis
```
### Shell Access
```bash
# Next.js container
docker-compose exec web sh
# PostgreSQL container
docker-compose exec db psql -U postgres -d qrmaster
# Redis container
docker-compose exec redis redis-cli
```
### Stop and Clean
```bash
# Stop all services
docker-compose down
# Stop and remove volumes (deletes data!)
docker-compose down -v
# Stop and remove everything including images
docker-compose down -v --rmi all
```
## 🔐 Environment Variables
Create a `.env` file in the root directory (copy from `env.example`):
```bash
cp env.example .env
```
Required variables:
- `DATABASE_URL`: PostgreSQL connection string
- `NEXTAUTH_SECRET`: Secret for NextAuth.js
- `NEXTAUTH_URL`: Application URL
- `IP_SALT`: Salt for hashing IP addresses
- `REDIS_URL`: Redis connection string
## 🌐 Network Architecture
All services run on a custom bridge network `qrmaster-network` which allows:
- Service discovery by container name
- Network isolation from other Docker projects
- Internal DNS resolution
## 📊 Volumes
### Persistent Data
- `dbdata`: PostgreSQL data
- `redisdata`: Redis data
### Volume Management
```bash
# List volumes
docker volume ls
# Inspect volume
docker volume inspect qrmaster_dbdata
# Remove all unused volumes
docker volume prune
```
## 🐛 Troubleshooting
### Database Connection Issues
```bash
# Check if database is ready
docker-compose exec db pg_isready -U postgres
# Check database logs
docker-compose logs db
# Restart database
docker-compose restart db
```
### Application Won't Start
```bash
# Check health status
docker-compose ps
# View application logs
docker-compose logs web
# Rebuild the application
docker-compose up -d --build web
```
### Port Already in Use
If ports 3050, 5432, 6379, or 8080 are already in use:
```bash
# Find process using port
# Windows
netstat -ano | findstr :3050
# Linux/Mac
lsof -i :3050
# Kill process or change port in docker-compose.yml
```
## 🔄 Updates and Maintenance
### Update Dependencies
```bash
# Update Node packages
npm update
# Rebuild Docker images
docker-compose build --no-cache
```
### Update Docker Images
```bash
# Pull latest images
docker-compose pull
# Restart with new images
docker-compose up -d
```
## 📝 Notes
- **Development**: Use `docker-compose.dev.yml` to run only the database and Redis
- **Production**: Use `docker-compose.yml` to run the full stack
- **Security**: Always change default passwords in production
- **Backups**: Implement regular database backups in production
- **Scaling**: For production, consider using PostgreSQL replication and Redis Sentinel
## 🆘 Support
For more information, see:
- [Docker Documentation](https://docs.docker.com/)
- [PostgreSQL Documentation](https://www.postgresql.org/docs/)
- [Prisma Documentation](https://www.prisma.io/docs/)
- [Next.js Documentation](https://nextjs.org/docs)

View File

@@ -1,26 +1,26 @@
#!/bin/bash
set -e
# This script runs when the PostgreSQL container is first created
# It ensures the database is properly initialized
echo "🚀 Initializing QR Master database..."
# Create the database if it doesn't exist (already created by POSTGRES_DB)
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
-- Enable required extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
-- Grant privileges
GRANT ALL PRIVILEGES ON DATABASE qrmaster TO postgres;
-- Set timezone
ALTER DATABASE qrmaster SET timezone TO 'UTC';
EOSQL
echo "✅ Database initialization complete!"
echo "📊 Database: $POSTGRES_DB"
echo "👤 User: $POSTGRES_USER"
echo "🌐 Ready to accept connections on port 5432"
#!/bin/bash
set -e
# This script runs when the PostgreSQL container is first created
# It ensures the database is properly initialized
echo "🚀 Initializing QR Master database..."
# Create the database if it doesn't exist (already created by POSTGRES_DB)
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
-- Enable required extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
-- Grant privileges
GRANT ALL PRIVILEGES ON DATABASE qrmaster TO postgres;
-- Set timezone
ALTER DATABASE qrmaster SET timezone TO 'UTC';
EOSQL
echo "✅ Database initialization complete!"
echo "📊 Database: $POSTGRES_DB"
echo "👤 User: $POSTGRES_USER"
echo "🌐 Ready to accept connections on port 5432"

View File

@@ -1,50 +1,50 @@
# Environment Configuration
NODE_ENV=development
PORT=3000
# Database Configuration (PostgreSQL)
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=qrmaster
# For local development (without Docker):
# DATABASE_URL=postgresql://postgres:postgres@localhost:5435/qrmaster?schema=public
# For Docker Compose (internal Docker network):
DATABASE_URL=postgresql://postgres:postgres@db:5432/qrmaster?schema=public
# NextAuth Configuration
NEXTAUTH_URL=http://localhost:3050
NEXTAUTH_SECRET=your-secret-key-here-change-in-production
# OAuth Providers (Optional)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Redis Configuration (Optional - for rate limiting and caching)
REDIS_URL=redis://redis:6379
# Security
# Used for hashing IP addresses in analytics
IP_SALT=your-ip-salt-here-change-in-production
# Features
ENABLE_DEMO=false
# SEO Configuration
# Set to 'true' in production to allow search engine indexing
NEXT_PUBLIC_INDEXABLE=true
# Stripe Payment Configuration (Optional - for subscription payments)
# Get your keys from: https://dashboard.stripe.com/apikeys
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
# Stripe Price IDs (create these in your Stripe dashboard)
# NEXT_PUBLIC_STRIPE_FREE_PRICE_ID=price_xxx
# NEXT_PUBLIC_STRIPE_PRO_PRICE_ID=price_xxx
# NEXT_PUBLIC_STRIPE_BUSINESS_PRICE_ID=price_xxx
# Analytics (Optional - PostHog)
NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com
# Environment Configuration
NODE_ENV=development
PORT=3000
# Database Configuration (PostgreSQL)
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=qrmaster
# For local development (without Docker):
# DATABASE_URL=postgresql://postgres:postgres@localhost:5435/qrmaster?schema=public
# For Docker Compose (internal Docker network):
DATABASE_URL=postgresql://postgres:postgres@db:5432/qrmaster?schema=public
# NextAuth Configuration
NEXTAUTH_URL=http://localhost:3050
NEXTAUTH_SECRET=your-secret-key-here-change-in-production
# OAuth Providers (Optional)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Redis Configuration (Optional - for rate limiting and caching)
REDIS_URL=redis://redis:6379
# Security
# Used for hashing IP addresses in analytics
IP_SALT=your-ip-salt-here-change-in-production
# Features
ENABLE_DEMO=false
# SEO Configuration
# Set to 'true' in production to allow search engine indexing
NEXT_PUBLIC_INDEXABLE=true
# Stripe Payment Configuration (Optional - for subscription payments)
# Get your keys from: https://dashboard.stripe.com/apikeys
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
# Stripe Price IDs (create these in your Stripe dashboard)
# NEXT_PUBLIC_STRIPE_FREE_PRICE_ID=price_xxx
# NEXT_PUBLIC_STRIPE_PRO_PRICE_ID=price_xxx
# NEXT_PUBLIC_STRIPE_BUSINESS_PRICE_ID=price_xxx
# Analytics (Optional - PostHog)
NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com

View File

@@ -1,127 +1,127 @@
# QR Master: The Growth Masterpiece
## Implementations-Idee (Integrated Strategy V1.1)
## Executive Summary
Dieses Dokument integriert Conversion-, Copywriting- und Content-Strategie, um QR Master von einem simplen Utility-Tool zu einer Vertical Operations Platform zu entwickeln.
**Kernthese:** Wir gewinnen nicht durch einen "besseren" Generator, sondern als die **einzige Versicherung gegen physische Marketingverschwendung**.
## 1. Conversion Architecture (CRO)
### 1.1 Ziel
Die Value Gap schliessen: Nutzer verstehen den Wert von **Dynamic QR Codes** oft erst *nach* dem Druckfehler.
### 1.2 Funnel-Diagnose & Leak
Aktuelles Problem:
1. Nutzer suchen nach `free qr code generator` (High Volume).
2. Erstellen statischen Code & laden PNG herunter.
3. Drucken Material.
4. Link ändert sich -> Code tot -> Frust & Churn.
### 1.3 Safety Intercept (Pre-Download Modal)
**Hypothese:** Ein "Angst-basiertes" Intercept vor dem Download konvertiert Free-User zu Dynamic-Usern, indem es das Risiko von statischen Codes aufzeigt.
**Trigger:** Klick auf `Download PNG` / `Download SVG` bei statischem Code.
**Modal-Inhalt (Optimiert):**
- **Headline:** `Wait! You are creating a Permanent, Static Code.`
- **Body:** `Once printed, this code cannot be changed. If your link breaks, your flyers are trash. 80% of businesses switch to a **Dynamic QR Code Generator** to stay safe.`
- **CTA Primary:** `Yes, make it Editable (Free Dynamic)`
- **CTA Secondary:** `No, I risk the Static Code`
### 1.4 Experiment-Setup (A/B Test)
- **Control:** Direkter Download.
- **Variant:** Safety Intercept Modal.
- **Primary KPI:** `intercept_upgrade_rate` (Ziel: >15%).
---
## 2. Direct Response Copywriting (Real Estate Vertical)
Fokus auf das Keyword Cluster: `qr codes for business` und `real estate qr codes`.
### 2.1 Zielseite: `/solutions/real-estate`
**Hero Section (SEO & Conversion Optimized):**
- **H1 (SEO):** `Real Estate QR Code Generator: The "Forever Code" for Agents`
- **Subhead:** `Sold the listing? Don't trash the flyers. Just reroute your **Dynamic QR Code** to the next property in 1 click.`
- **CTA:** `Create Free Real Estate QR Code`
- **Micro-Copy:** `Works with Canva & Zillow. Trusted by top agents.`
### 2.2 Nurture Bridge Email Sequence
**Trigger:** Download `Real Estate Toolkit`.
**Email 1 (The Hook):**
- **Subject:** `Your "Forever Flyer" Templates are here`
- **Body:** "Stop printing single-use codes. Use this template with a **Dynamic QR Code** that you recycle for every open house."
**Email 2 (The Fear):**
- **Subject:** `The $300 mistake 90% of agents make`
- **Body:** Story über 500 weggeworfene Flyer, weil der statische Code nicht auf das neue Listing umgeleitet werden konnte. Lösung: "The Editable QR Code".
---
## 3. Content Strategy & SEO Operations
Basierend auf `keyword_planer_google.md` (High Volume & High CPC Opportunities).
### 3.1 Keyword-Prioritäten
Wir attackieren drei Ebenen gleichzeitig:
1. **Volume Layer (Top of Funnel):**
- `qr code generator free` (500k Search Volume) -> Homepage & Tool Pages
- `create qr code` (50k SV) -> Tutorial Hub
2. **Value Layer (High Intent/CPC):**
- `dynamic qr code generator` (5k SV, High Value) -> Feature Page
- `qr code for business` (5k SV, High CPC) -> Solutions Hub
3. **Feature Layer (Longtail):**
- `qr code generator with logo` (5k SV) -> Customization Page
- `vcard qr code generator` (5k SV) -> vCard Tool
### 3.2 Content-Pillars & Hubs
| Hub Page | Target Keyword | Content Angle (H1 Idea) |
| :--- | :--- | :--- |
| **Real Estate** | `real estate qr codes` | "The Only QR Code Generator for Real Estate Agents" |
| **Business** | `qr codes for business` | "Enterprise-Grade QR Code Generator for Business Growth" |
| **Dynamic** | `dynamic qr code generator` | "Create Editable & Trackable Dynamic QR Codes (Free)" |
| **vCard** | `vcard qr code` | "Digital Business Card & vCard QR Code Generator" |
### 3.3 SEO-Protokoll & On-Page Execution
Für *jede* neue Seite gilt strikt:
1. **URL-Struktur:** Sprechend & hierarchisch (`/tools/dynamic-qr-code-generator`).
2. **Title Tag:** `[Main Keyword] - [Benefit] | QR Master`
- *Bsp:* `Dynamic QR Code Generator - Edit Links After Printing | QR Master`
3. **H1:** Muss das Main Keyword exakt enthalten.
4. **FAQ Schema:** Fragen aus "People Also Ask" integrieren (z.B. "Can I edit a QR code after printing?").
---
## 4. Execution Roadmap (Next 4 Weeks)
### Woche 1: Foundation & "Safety Net"
- [Dev] **Safety Intercept Modal** auf der Homepage deployen.
- [Content] Optimierung der Homepage Meta-Daten für `free qr code generator` und `dynamic qr code`.
### Woche 2: Vertical Attack (Real Estate)
- [Page] `/solutions/real-estate` live bringen (Copy siehe 2.1).
- [Lead Magnet] "Forever Flyer" Canva Templates erstellen.
### Woche 3: High-Value Content
- [Blog] Comparison Post: `Bitly vs. QR Master for Business` (Targeting `qr code for business`).
- [Tool] Polishing der `vcard` Seite für das Keyword `vcard qr code generator`.
### Woche 4: Review & Amplify
- Analyse der `intercept_upgrade_rate`.
- Backlink-Outreach für Real Estate Artikel.
---
## 5. Risiken & Mitigation
- **Risk:** User sind genervt vom Modal.
- **Fix:** "Don't show again" Option nach 2x Anzeigen.
- **Risk:** SEO dauert zu lange (3-6 Monate).
- **Fix:** Parallel "Programmatic SEO" für Longtail-Keywords (z.B. `qr code for [city]`) aufbauen, um schnelleren Traffic zu holen.
## 6. Definition of Done (V1.1)
- Safety Intercept ist live und trackt Upgrades.
- Real Estate Landingpage rankt für Longtail-Keywords.
- Die Top-5 Keywords aus dem Plan (`dynamic`, `free`, `business`, `custom`, `vcard`) haben dedizierte, optimierte Landingpages.
# QR Master: The Growth Masterpiece
## Implementations-Idee (Integrated Strategy V1.1)
## Executive Summary
Dieses Dokument integriert Conversion-, Copywriting- und Content-Strategie, um QR Master von einem simplen Utility-Tool zu einer Vertical Operations Platform zu entwickeln.
**Kernthese:** Wir gewinnen nicht durch einen "besseren" Generator, sondern als die **einzige Versicherung gegen physische Marketingverschwendung**.
## 1. Conversion Architecture (CRO)
### 1.1 Ziel
Die Value Gap schliessen: Nutzer verstehen den Wert von **Dynamic QR Codes** oft erst *nach* dem Druckfehler.
### 1.2 Funnel-Diagnose & Leak
Aktuelles Problem:
1. Nutzer suchen nach `free qr code generator` (High Volume).
2. Erstellen statischen Code & laden PNG herunter.
3. Drucken Material.
4. Link ändert sich -> Code tot -> Frust & Churn.
### 1.3 Safety Intercept (Pre-Download Modal)
**Hypothese:** Ein "Angst-basiertes" Intercept vor dem Download konvertiert Free-User zu Dynamic-Usern, indem es das Risiko von statischen Codes aufzeigt.
**Trigger:** Klick auf `Download PNG` / `Download SVG` bei statischem Code.
**Modal-Inhalt (Optimiert):**
- **Headline:** `Wait! You are creating a Permanent, Static Code.`
- **Body:** `Once printed, this code cannot be changed. If your link breaks, your flyers are trash. 80% of businesses switch to a **Dynamic QR Code Generator** to stay safe.`
- **CTA Primary:** `Yes, make it Editable (Free Dynamic)`
- **CTA Secondary:** `No, I risk the Static Code`
### 1.4 Experiment-Setup (A/B Test)
- **Control:** Direkter Download.
- **Variant:** Safety Intercept Modal.
- **Primary KPI:** `intercept_upgrade_rate` (Ziel: >15%).
---
## 2. Direct Response Copywriting (Real Estate Vertical)
Fokus auf das Keyword Cluster: `qr codes for business` und `real estate qr codes`.
### 2.1 Zielseite: `/solutions/real-estate`
**Hero Section (SEO & Conversion Optimized):**
- **H1 (SEO):** `Real Estate QR Code Generator: The "Forever Code" for Agents`
- **Subhead:** `Sold the listing? Don't trash the flyers. Just reroute your **Dynamic QR Code** to the next property in 1 click.`
- **CTA:** `Create Free Real Estate QR Code`
- **Micro-Copy:** `Works with Canva & Zillow. Trusted by top agents.`
### 2.2 Nurture Bridge Email Sequence
**Trigger:** Download `Real Estate Toolkit`.
**Email 1 (The Hook):**
- **Subject:** `Your "Forever Flyer" Templates are here`
- **Body:** "Stop printing single-use codes. Use this template with a **Dynamic QR Code** that you recycle for every open house."
**Email 2 (The Fear):**
- **Subject:** `The $300 mistake 90% of agents make`
- **Body:** Story über 500 weggeworfene Flyer, weil der statische Code nicht auf das neue Listing umgeleitet werden konnte. Lösung: "The Editable QR Code".
---
## 3. Content Strategy & SEO Operations
Basierend auf `keyword_planer_google.md` (High Volume & High CPC Opportunities).
### 3.1 Keyword-Prioritäten
Wir attackieren drei Ebenen gleichzeitig:
1. **Volume Layer (Top of Funnel):**
- `qr code generator free` (500k Search Volume) -> Homepage & Tool Pages
- `create qr code` (50k SV) -> Tutorial Hub
2. **Value Layer (High Intent/CPC):**
- `dynamic qr code generator` (5k SV, High Value) -> Feature Page
- `qr code for business` (5k SV, High CPC) -> Solutions Hub
3. **Feature Layer (Longtail):**
- `qr code generator with logo` (5k SV) -> Customization Page
- `vcard qr code generator` (5k SV) -> vCard Tool
### 3.2 Content-Pillars & Hubs
| Hub Page | Target Keyword | Content Angle (H1 Idea) |
| :--- | :--- | :--- |
| **Real Estate** | `real estate qr codes` | "The Only QR Code Generator for Real Estate Agents" |
| **Business** | `qr codes for business` | "Enterprise-Grade QR Code Generator for Business Growth" |
| **Dynamic** | `dynamic qr code generator` | "Create Editable & Trackable Dynamic QR Codes (Free)" |
| **vCard** | `vcard qr code` | "Digital Business Card & vCard QR Code Generator" |
### 3.3 SEO-Protokoll & On-Page Execution
Für *jede* neue Seite gilt strikt:
1. **URL-Struktur:** Sprechend & hierarchisch (`/tools/dynamic-qr-code-generator`).
2. **Title Tag:** `[Main Keyword] - [Benefit] | QR Master`
- *Bsp:* `Dynamic QR Code Generator - Edit Links After Printing | QR Master`
3. **H1:** Muss das Main Keyword exakt enthalten.
4. **FAQ Schema:** Fragen aus "People Also Ask" integrieren (z.B. "Can I edit a QR code after printing?").
---
## 4. Execution Roadmap (Next 4 Weeks)
### Woche 1: Foundation & "Safety Net"
- [Dev] **Safety Intercept Modal** auf der Homepage deployen.
- [Content] Optimierung der Homepage Meta-Daten für `free qr code generator` und `dynamic qr code`.
### Woche 2: Vertical Attack (Real Estate)
- [Page] `/solutions/real-estate` live bringen (Copy siehe 2.1).
- [Lead Magnet] "Forever Flyer" Canva Templates erstellen.
### Woche 3: High-Value Content
- [Blog] Comparison Post: `Bitly vs. QR Master for Business` (Targeting `qr code for business`).
- [Tool] Polishing der `vcard` Seite für das Keyword `vcard qr code generator`.
### Woche 4: Review & Amplify
- Analyse der `intercept_upgrade_rate`.
- Backlink-Outreach für Real Estate Artikel.
---
## 5. Risiken & Mitigation
- **Risk:** User sind genervt vom Modal.
- **Fix:** "Don't show again" Option nach 2x Anzeigen.
- **Risk:** SEO dauert zu lange (3-6 Monate).
- **Fix:** Parallel "Programmatic SEO" für Longtail-Keywords (z.B. `qr code for [city]`) aufbauen, um schnelleren Traffic zu holen.
## 6. Definition of Done (V1.1)
- Safety Intercept ist live und trackt Upgrades.
- Real Estate Landingpage rankt für Longtail-Keywords.
- Die Top-5 Keywords aus dem Plan (`dynamic`, `free`, `business`, `custom`, `vcard`) haben dedizierte, optimierte Landingpages.

View File

@@ -1,96 +1,96 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//QR Master//Reddit Full Calendar//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:QR Master Reddit Full
X-WR-TIMEZONE:Europe/Berlin
BEGIN:VEVENT
UID:reddit-full-20260316-library@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260316T100000Z
DTEND:20260316T103000Z
SUMMARY:Reddit reply library - all 15 templates
DESCRIPTION:1) If someone asks what tool you use:\nIf you need the destination to stay editable later\, thats exactly the problem I built around.\nI built QR Master for this\, so obvious bias:\nhttps://www.qrmaster.net/dynamic-qr-code-generator\nIf you want\, I can also just explain the setup without linking anything.\n\n2) Direct link ask:\nSure. Im building it\, so bias disclosed:\nhttps://www.qrmaster.net/dynamic-qr-code-generator\nMain use case is when something is already printed and the destination changes later.\n\n3) Restaurant/menu:\nIf the menu changes even semi-regularly\, I wouldnt print a static QR.\nhttps://www.qrmaster.net/use-cases/restaurant-menu-qr-codes\n\n4) Flyer/print:\nThe problem usually isnt generating the QR. Its what happens when the page changes after print.\nhttps://www.qrmaster.net/use-cases/flyer-qr-codes\n\n5) Events:\nFor events\, Id mainly optimize for can we update this later without reprinting everything?\nhttps://www.qrmaster.net/use-cases/event-qr-codes\n\n6) Packaging/bulk:\nIf youre dealing with lots of codes\, bulk workflow matters way more than people expect.\nhttps://www.qrmaster.net/bulk-qr-code-generator\n\n7) Analytics:\nYou probably want some scan visibility\, but only the useful stuff.\nhttps://www.qrmaster.net/qr-code-tracking\n\n8) Privacy:\nIf privacy matters\, Id look very closely at how the tracking is handled.\nhttps://www.qrmaster.net/privacy\n\n9) Agencies:\nYeah\, agencies are actually one of the more interesting use cases.\nhttps://www.qrmaster.net/qr-code-for-marketing-campaigns\n\n10) Alternatives:\nIf you need editability after print\, thats where tools start to differ.\nhttps://www.qrmaster.net/dynamic-qr-code-generator\n\n11) Skeptical reply:\nFair skepticism. I built it\, so Im obviously biased:\nhttps://www.qrmaster.net/dynamic-qr-code-generator\n\n12) How set up:\nUse a dynamic destination\, keep the landing page mobile-first\, and make sure you can update it after print.\nhttps://www.qrmaster.net/dynamic-qr-code-generator\n\n13) Recommendations:\nIf the destination might change later\, Id use a dynamic QR setup.\nhttps://www.qrmaster.net/dynamic-qr-code-generator\n\n14) Feedback thread:\nIm building in this space\, so this is partly self-interested\, but yes\, this is a real problem.\nhttps://www.qrmaster.net/dynamic-qr-code-generator\n\n15) Direct relevance:\nI built a tool for this exact issue\, so obvious bias:\nhttps://www.qrmaster.net/dynamic-qr-code-generator
END:VEVENT
BEGIN:VEVENT
UID:reddit-full-20260317-post2@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260317T120000Z
DTEND:20260317T124500Z
SUMMARY:r/startups - Post 2
DESCRIPTION:Title: One URL change can ruin 500 flyers. That pain is more real than I expected.\n\nI underestimated how annoying printed mistakes are.\n\nA lot of software problems are reversible.\nPrint problems arent.\n\nIf a landing page changes after flyers\, posters\, inserts\, or menus are already out there\, someone has to:\n- live with a broken flow\n- reprint everything\n- or patch it manually in a messy way\n\nThat sounds minor until you talk to people actually running campaigns or local businesses.\n\nWhat small operational problem ended up being much more expensive than it looked at first?\n\nUse replies: 2\, 4\, 11\, 12\, 15
END:VEVENT
BEGIN:VEVENT
UID:reddit-full-20260319-post1@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260319T120000Z
DTEND:20260319T124500Z
SUMMARY:r/SaaS - Post 1
DESCRIPTION:Title: I thought QR code software was about generation. The real pain starts after print.\n\nI used to think the value was make a QR code fast.\n\nIts not.\n\nThe painful part starts after something is already printed:\n- the menu changes\n- the event page changes\n- the campaign URL changes\n- someone notices a typo too late\n\nOne small change can turn a stack of flyers into trash.\n\nThat shifted how I think about the whole category.\nThe QR itself is easy.\nThe expensive part is everything around it.\n\nAnyone else building in a category where the simple feature isnt actually where the value is?\n\nUse replies: 1\, 7\, 10\, 11\, 15
END:VEVENT
BEGIN:VEVENT
UID:reddit-full-20260324-post3@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260324T123000Z
DTEND:20260324T131500Z
SUMMARY:r/smallbusiness - Post 3
DESCRIPTION:Title: Small businesses usually dont need more marketing. They need fewer expensive mistakes.\n\nOne thing I keep noticing:\n\nA lot of owners dont care about having a fancy tool.\nThey care about not wasting money.\n\nWith QR codes\, the common mistakes seem to be:\n- printing static codes for things that change often\n- sending people to ugly mobile pages\n- having no idea whether anyone scanned anything\n- letting one outdated link stay live for weeks\n\nFeels like a lot of marketing problems are actually ops problems.\n\nWhats one small process change that saved your business money this year?\n\nUse replies: 3\, 4\, 10\, 12\, 13
END:VEVENT
BEGIN:VEVENT
UID:reddit-full-20260326-post4@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260326T120000Z
DTEND:20260326T124500Z
SUMMARY:r/EntrepreneurRideAlong - Post 4
DESCRIPTION:Title: Building in a boring category taught me that boring problems are usually expensive.\n\nIm building around QR codes\, which sounds incredibly boring on paper.\n\nBut boring problems are often the ones people pay to avoid.\n\nIn this case\, its stuff like:\n- reprinting menus\n- fixing outdated flyers\n- updating event info after posters are already out\n- managing lots of QR destinations across campaigns\n\nNobody is emotionally excited about QR codes.\nTheyre emotionally excited about not dealing with preventable mess.\n\nAnyone else building something unsexy that turned out to have very real pain behind it?\n\nUse replies: 2\, 4\, 9\, 11\, 15
END:VEVENT
BEGIN:VEVENT
UID:reddit-full-20260327-post5@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260327T133000Z
DTEND:20260327T141500Z
SUMMARY:r/SideProject - Post 5
DESCRIPTION:Title: The weird part about building a QR product is that the technical problem isnt the interesting one.\n\nGenerating a QR image is trivial.\n\nWhat turned out to be more interesting:\n- what happens after print\n- whether someone can change the destination later\n- what analytics are actually useful\n- how privacy concerns show up once tracking enters the conversation\n- how bulk workflows matter way more than expected\n\nIts one of those products that looks dumb-simple from the outside and much more operational once you talk to users.\n\nWhat kind of side project looked simple until real use cases started showing up?\n\nUse replies: 1\, 6\, 7\, 8\, 15
END:VEVENT
BEGIN:VEVENT
UID:reddit-full-20260331-post7@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260331T120000Z
DTEND:20260331T124500Z
SUMMARY:r/AlphaandBetaTesters - Post 7
DESCRIPTION:Title: Looking for feedback from anyone who has used QR codes in restaurants\, events\, print\, or packaging.\n\nIm trying to learn from people who use QR codes in the real world\, not just in theory.\n\nEspecially if youve used them for:\n- menus\n- flyers\n- product packaging\n- event materials\n- WiFi / contact sharing\n- agency campaigns\n\nThings Im curious about:\n- what changes most often after something is printed?\n- whats annoying about current tools?\n- do you actually care about scan analytics?\n- does privacy / GDPR affect vendor choice at all?\n\nIm happy to share what Im building if useful\, but mostly looking for honest feedback from people whove dealt with this firsthand.\n\nUse replies: 3\, 5\, 6\, 8\, 14
END:VEVENT
BEGIN:VEVENT
UID:reddit-full-20260402-post8@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260402T120000Z
DTEND:20260402T124500Z
SUMMARY:r/RoastMyStartup - Post 8
DESCRIPTION:Title: Roast my positioning: is avoid reprints and broken QR campaigns a strong enough problem?\n\nIm working on a product around dynamic QR codes.\n\nThe positioning Im testing is less make QR codes and more avoid reprints\, outdated links\, and messy campaign management.\n\nTarget users are mostly:\n- small businesses\n- restaurants\n- marketers\n- agencies\n- event / packaging use cases\n\nThe questions Id love roasted:\n- does the pain feel real enough?\n- does this sound too niche?\n- what part sounds generic or weak?\n- what would make you ignore this instantly?\n\nHappy to share the product if the sub is okay with it.\n\nUse replies: 2\, 10\, 11\, 14\, 15
END:VEVENT
BEGIN:VEVENT
UID:reddit-full-20260403-post6@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260403T123000Z
DTEND:20260403T131500Z
SUMMARY:r/ProductMgmt - Post 6
DESCRIPTION:Title: Users say they want a QR generator. What they actually want is damage control.\n\nA PM lesson I didnt expect:\n\nPeople describe the need as I need a QR code.\nBut what they actually care about is something like:\nI need this thing to not break once its already printed.\n\nThat changes what feels important.\n\nGenerate code sounds like the core feature.\nBut retention/value probably sits closer to:\n- edit later\n- track scans\n- handle multiple codes\n- avoid privacy headaches\n- manage existing campaigns cleanly\n\nHave you seen that mismatch in your own product?\nWhat users ask for first vs. what actually matters later?\n\nUse replies: 1\, 7\, 8\, 10\, 12
END:VEVENT
BEGIN:VEVENT
UID:reddit-full-20260407-post9@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260407T120000Z
DTEND:20260407T124500Z
SUMMARY:r/startups - Post 9
DESCRIPTION:Title: Im starting to think edit after print is a stronger product promise than track scans.\n\nInteresting thing from early positioning:\n\nI assumed analytics would be the hero feature.\nBut I can change the destination later seems to click faster.\n\nMakes sense in hindsight.\nTracking is nice.\nAvoiding expensive mistakes is urgent.\n\nSo now Im wondering if the better message is:\n- first promise control\n- then introduce analytics\n- then layer in bulk / workflow / privacy\n\nIf youve sold into small businesses or marketers:\nwhat kind of promise gets attention faster\, insight or control?\n\nUse replies: 1\, 7\, 9\, 10\, 15
END:VEVENT
BEGIN:VEVENT
UID:reddit-full-20260409-post10@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260409T123000Z
DTEND:20260409T131500Z
SUMMARY:r/smallbusiness - Post 10
DESCRIPTION:Title: What looks like a tiny print detail can quietly waste a lot of money.\n\nI keep coming back to this:\n\nA broken link on a website is annoying.\nA broken link on printed material is expensive.\n\nBecause now the problem is sitting in:\n- stores\n- restaurants\n- posters\n- packaging\n- tables\n- flyers already handed out\n\nFeels like one of those things that sounds tiny until you count the friction and replacement cost.\n\nWhats a small detail in your business that causes way more downstream cost than people assume?\n\nUse replies: 3\, 4\, 6\, 13\, 15
END:VEVENT
END:VCALENDAR
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//QR Master//Reddit Full Calendar//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:QR Master Reddit Full
X-WR-TIMEZONE:Europe/Berlin
BEGIN:VEVENT
UID:reddit-full-20260316-library@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260316T100000Z
DTEND:20260316T103000Z
SUMMARY:Reddit reply library - all 15 templates
DESCRIPTION:1) If someone asks what tool you use:\nIf you need the destination to stay editable later\, thats exactly the problem I built around.\nI built QR Master for this\, so obvious bias:\nhttps://www.qrmaster.net/dynamic-qr-code-generator\nIf you want\, I can also just explain the setup without linking anything.\n\n2) Direct link ask:\nSure. Im building it\, so bias disclosed:\nhttps://www.qrmaster.net/dynamic-qr-code-generator\nMain use case is when something is already printed and the destination changes later.\n\n3) Restaurant/menu:\nIf the menu changes even semi-regularly\, I wouldnt print a static QR.\nhttps://www.qrmaster.net/use-cases/restaurant-menu-qr-codes\n\n4) Flyer/print:\nThe problem usually isnt generating the QR. Its what happens when the page changes after print.\nhttps://www.qrmaster.net/use-cases/flyer-qr-codes\n\n5) Events:\nFor events\, Id mainly optimize for can we update this later without reprinting everything?\nhttps://www.qrmaster.net/use-cases/event-qr-codes\n\n6) Packaging/bulk:\nIf youre dealing with lots of codes\, bulk workflow matters way more than people expect.\nhttps://www.qrmaster.net/bulk-qr-code-generator\n\n7) Analytics:\nYou probably want some scan visibility\, but only the useful stuff.\nhttps://www.qrmaster.net/qr-code-tracking\n\n8) Privacy:\nIf privacy matters\, Id look very closely at how the tracking is handled.\nhttps://www.qrmaster.net/privacy\n\n9) Agencies:\nYeah\, agencies are actually one of the more interesting use cases.\nhttps://www.qrmaster.net/qr-code-for-marketing-campaigns\n\n10) Alternatives:\nIf you need editability after print\, thats where tools start to differ.\nhttps://www.qrmaster.net/dynamic-qr-code-generator\n\n11) Skeptical reply:\nFair skepticism. I built it\, so Im obviously biased:\nhttps://www.qrmaster.net/dynamic-qr-code-generator\n\n12) How set up:\nUse a dynamic destination\, keep the landing page mobile-first\, and make sure you can update it after print.\nhttps://www.qrmaster.net/dynamic-qr-code-generator\n\n13) Recommendations:\nIf the destination might change later\, Id use a dynamic QR setup.\nhttps://www.qrmaster.net/dynamic-qr-code-generator\n\n14) Feedback thread:\nIm building in this space\, so this is partly self-interested\, but yes\, this is a real problem.\nhttps://www.qrmaster.net/dynamic-qr-code-generator\n\n15) Direct relevance:\nI built a tool for this exact issue\, so obvious bias:\nhttps://www.qrmaster.net/dynamic-qr-code-generator
END:VEVENT
BEGIN:VEVENT
UID:reddit-full-20260317-post2@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260317T120000Z
DTEND:20260317T124500Z
SUMMARY:r/startups - Post 2
DESCRIPTION:Title: One URL change can ruin 500 flyers. That pain is more real than I expected.\n\nI underestimated how annoying printed mistakes are.\n\nA lot of software problems are reversible.\nPrint problems arent.\n\nIf a landing page changes after flyers\, posters\, inserts\, or menus are already out there\, someone has to:\n- live with a broken flow\n- reprint everything\n- or patch it manually in a messy way\n\nThat sounds minor until you talk to people actually running campaigns or local businesses.\n\nWhat small operational problem ended up being much more expensive than it looked at first?\n\nUse replies: 2\, 4\, 11\, 12\, 15
END:VEVENT
BEGIN:VEVENT
UID:reddit-full-20260319-post1@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260319T120000Z
DTEND:20260319T124500Z
SUMMARY:r/SaaS - Post 1
DESCRIPTION:Title: I thought QR code software was about generation. The real pain starts after print.\n\nI used to think the value was make a QR code fast.\n\nIts not.\n\nThe painful part starts after something is already printed:\n- the menu changes\n- the event page changes\n- the campaign URL changes\n- someone notices a typo too late\n\nOne small change can turn a stack of flyers into trash.\n\nThat shifted how I think about the whole category.\nThe QR itself is easy.\nThe expensive part is everything around it.\n\nAnyone else building in a category where the simple feature isnt actually where the value is?\n\nUse replies: 1\, 7\, 10\, 11\, 15
END:VEVENT
BEGIN:VEVENT
UID:reddit-full-20260324-post3@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260324T123000Z
DTEND:20260324T131500Z
SUMMARY:r/smallbusiness - Post 3
DESCRIPTION:Title: Small businesses usually dont need more marketing. They need fewer expensive mistakes.\n\nOne thing I keep noticing:\n\nA lot of owners dont care about having a fancy tool.\nThey care about not wasting money.\n\nWith QR codes\, the common mistakes seem to be:\n- printing static codes for things that change often\n- sending people to ugly mobile pages\n- having no idea whether anyone scanned anything\n- letting one outdated link stay live for weeks\n\nFeels like a lot of marketing problems are actually ops problems.\n\nWhats one small process change that saved your business money this year?\n\nUse replies: 3\, 4\, 10\, 12\, 13
END:VEVENT
BEGIN:VEVENT
UID:reddit-full-20260326-post4@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260326T120000Z
DTEND:20260326T124500Z
SUMMARY:r/EntrepreneurRideAlong - Post 4
DESCRIPTION:Title: Building in a boring category taught me that boring problems are usually expensive.\n\nIm building around QR codes\, which sounds incredibly boring on paper.\n\nBut boring problems are often the ones people pay to avoid.\n\nIn this case\, its stuff like:\n- reprinting menus\n- fixing outdated flyers\n- updating event info after posters are already out\n- managing lots of QR destinations across campaigns\n\nNobody is emotionally excited about QR codes.\nTheyre emotionally excited about not dealing with preventable mess.\n\nAnyone else building something unsexy that turned out to have very real pain behind it?\n\nUse replies: 2\, 4\, 9\, 11\, 15
END:VEVENT
BEGIN:VEVENT
UID:reddit-full-20260327-post5@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260327T133000Z
DTEND:20260327T141500Z
SUMMARY:r/SideProject - Post 5
DESCRIPTION:Title: The weird part about building a QR product is that the technical problem isnt the interesting one.\n\nGenerating a QR image is trivial.\n\nWhat turned out to be more interesting:\n- what happens after print\n- whether someone can change the destination later\n- what analytics are actually useful\n- how privacy concerns show up once tracking enters the conversation\n- how bulk workflows matter way more than expected\n\nIts one of those products that looks dumb-simple from the outside and much more operational once you talk to users.\n\nWhat kind of side project looked simple until real use cases started showing up?\n\nUse replies: 1\, 6\, 7\, 8\, 15
END:VEVENT
BEGIN:VEVENT
UID:reddit-full-20260331-post7@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260331T120000Z
DTEND:20260331T124500Z
SUMMARY:r/AlphaandBetaTesters - Post 7
DESCRIPTION:Title: Looking for feedback from anyone who has used QR codes in restaurants\, events\, print\, or packaging.\n\nIm trying to learn from people who use QR codes in the real world\, not just in theory.\n\nEspecially if youve used them for:\n- menus\n- flyers\n- product packaging\n- event materials\n- WiFi / contact sharing\n- agency campaigns\n\nThings Im curious about:\n- what changes most often after something is printed?\n- whats annoying about current tools?\n- do you actually care about scan analytics?\n- does privacy / GDPR affect vendor choice at all?\n\nIm happy to share what Im building if useful\, but mostly looking for honest feedback from people whove dealt with this firsthand.\n\nUse replies: 3\, 5\, 6\, 8\, 14
END:VEVENT
BEGIN:VEVENT
UID:reddit-full-20260402-post8@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260402T120000Z
DTEND:20260402T124500Z
SUMMARY:r/RoastMyStartup - Post 8
DESCRIPTION:Title: Roast my positioning: is avoid reprints and broken QR campaigns a strong enough problem?\n\nIm working on a product around dynamic QR codes.\n\nThe positioning Im testing is less make QR codes and more avoid reprints\, outdated links\, and messy campaign management.\n\nTarget users are mostly:\n- small businesses\n- restaurants\n- marketers\n- agencies\n- event / packaging use cases\n\nThe questions Id love roasted:\n- does the pain feel real enough?\n- does this sound too niche?\n- what part sounds generic or weak?\n- what would make you ignore this instantly?\n\nHappy to share the product if the sub is okay with it.\n\nUse replies: 2\, 10\, 11\, 14\, 15
END:VEVENT
BEGIN:VEVENT
UID:reddit-full-20260403-post6@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260403T123000Z
DTEND:20260403T131500Z
SUMMARY:r/ProductMgmt - Post 6
DESCRIPTION:Title: Users say they want a QR generator. What they actually want is damage control.\n\nA PM lesson I didnt expect:\n\nPeople describe the need as I need a QR code.\nBut what they actually care about is something like:\nI need this thing to not break once its already printed.\n\nThat changes what feels important.\n\nGenerate code sounds like the core feature.\nBut retention/value probably sits closer to:\n- edit later\n- track scans\n- handle multiple codes\n- avoid privacy headaches\n- manage existing campaigns cleanly\n\nHave you seen that mismatch in your own product?\nWhat users ask for first vs. what actually matters later?\n\nUse replies: 1\, 7\, 8\, 10\, 12
END:VEVENT
BEGIN:VEVENT
UID:reddit-full-20260407-post9@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260407T120000Z
DTEND:20260407T124500Z
SUMMARY:r/startups - Post 9
DESCRIPTION:Title: Im starting to think edit after print is a stronger product promise than track scans.\n\nInteresting thing from early positioning:\n\nI assumed analytics would be the hero feature.\nBut I can change the destination later seems to click faster.\n\nMakes sense in hindsight.\nTracking is nice.\nAvoiding expensive mistakes is urgent.\n\nSo now Im wondering if the better message is:\n- first promise control\n- then introduce analytics\n- then layer in bulk / workflow / privacy\n\nIf youve sold into small businesses or marketers:\nwhat kind of promise gets attention faster\, insight or control?\n\nUse replies: 1\, 7\, 9\, 10\, 15
END:VEVENT
BEGIN:VEVENT
UID:reddit-full-20260409-post10@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260409T123000Z
DTEND:20260409T131500Z
SUMMARY:r/smallbusiness - Post 10
DESCRIPTION:Title: What looks like a tiny print detail can quietly waste a lot of money.\n\nI keep coming back to this:\n\nA broken link on a website is annoying.\nA broken link on printed material is expensive.\n\nBecause now the problem is sitting in:\n- stores\n- restaurants\n- posters\n- packaging\n- tables\n- flyers already handed out\n\nFeels like one of those things that sounds tiny until you count the friction and replacement cost.\n\nWhats a small detail in your business that causes way more downstream cost than people assume?\n\nUse replies: 3\, 4\, 6\, 13\, 15
END:VEVENT
END:VCALENDAR

View File

@@ -1,443 +1,443 @@
# QR Master Reddit Calendar - Full 4 Weeks
Times are in Europe/Berlin.
## Link Map
- Core dynamic angle: `https://www.qrmaster.net/dynamic-qr-code-generator`
- Reprint / waste angle: `https://www.qrmaster.net/reprint-calculator`
- Restaurant / menu: `https://www.qrmaster.net/use-cases/restaurant-menu-qr-codes`
- Flyer / print campaigns: `https://www.qrmaster.net/use-cases/flyer-qr-codes`
- Event use case: `https://www.qrmaster.net/use-cases/event-qr-codes`
- Bulk / packaging: `https://www.qrmaster.net/bulk-qr-code-generator`
- Packaging use case: `https://www.qrmaster.net/use-cases/packaging-qr-codes`
- Tracking / analytics: `https://www.qrmaster.net/qr-code-tracking`
- Privacy: `https://www.qrmaster.net/privacy`
- Campaign workflows: `https://www.qrmaster.net/qr-code-for-marketing-campaigns`
- Main site: `https://www.qrmaster.net/`
## Reply Library
### 1. If someone asks what tool you use
```text
If you need the destination to stay editable later, thats exactly the problem I built around.
I built QR Master for this, so obvious bias:
https://www.qrmaster.net/dynamic-qr-code-generator
If you want, I can also just explain the setup without linking anything.
```
### 2. If someone asks for the link directly
```text
Sure. Im building it, so bias disclosed:
https://www.qrmaster.net/dynamic-qr-code-generator
Main use case is when something is already printed and the destination changes later.
```
### 3. Restaurant / menu threads
```text
If the menu changes even semi-regularly, I wouldnt print a static QR.
I built a tool for exactly that use case, so obvious bias here:
https://www.qrmaster.net/use-cases/restaurant-menu-qr-codes
Even without using mine, Id still recommend a dynamic destination and a very mobile-friendly menu page.
```
### 4. Flyer / print campaign threads
```text
The problem usually isnt generating the QR. Its what happens when the page changes after print.
I built something specifically for that:
https://www.qrmaster.net/use-cases/flyer-qr-codes
If helpful, I can outline the print-safe setup here too.
```
### 5. Event use case
```text
For events, Id mainly optimize for “can we update this later without reprinting everything?”
I built QR Master around that exact headache, so bias here:
https://www.qrmaster.net/use-cases/event-qr-codes
```
### 6. Packaging / bulk
```text
If youre dealing with lots of codes, bulk workflow matters way more than people expect.
I built this with that in mind:
https://www.qrmaster.net/bulk-qr-code-generator
Happy to share what a clean CSV / batch setup usually looks like.
```
### 7. Analytics questions
```text
You probably want some scan visibility, but only the useful stuff.
I built QR Master with that angle in mind, so obvious bias:
https://www.qrmaster.net/qr-code-tracking
Personally I think basic scan data + clean attribution matters more than a flashy dashboard.
```
### 8. Privacy / GDPR
```text
If privacy matters, Id look very closely at how the tracking is handled.
Im building QR Master, so not pretending to be neutral:
https://www.qrmaster.net/privacy
That said, Id ask the same questions to any vendor.
```
### 9. If someone says this would be useful for agencies
```text
Yeah, agencies are actually one of the more interesting use cases.
I built this partly with that workflow in mind:
https://www.qrmaster.net/qr-code-for-marketing-campaigns
Managing multiple destinations gets messy fast otherwise.
```
### 10. If someone asks about alternatives
```text
Depends what you care about.
If you just need a basic static code, almost anything works.
If you need editability after print, thats where tools start to differ.
I built QR Master for that second case:
https://www.qrmaster.net/dynamic-qr-code-generator
```
### 11. If someone is skeptical
```text
Fair skepticism.
I built it, so Im obviously biased:
https://www.qrmaster.net/dynamic-qr-code-generator
If youd rather keep it link-free, Im happy to just explain the tradeoffs here.
```
### 12. If someone asks how you would set this up
```text
Short version:
use a dynamic destination, keep the landing page mobile-first, and make sure you can update it after print.
I built a tool for exactly that flow:
https://www.qrmaster.net/dynamic-qr-code-generator
```
### 13. If someone asks for recommendations
```text
If the destination might change later, Id use a dynamic QR setup.
I built one for that use case, so take this with bias:
https://www.qrmaster.net/dynamic-qr-code-generator
If not mine, Id still choose something that makes post-print edits easy.
```
### 14. Feedback threads
```text
Im building in this space, so this is partly self-interested, but yes, this is a real problem.
Heres what Im working on if useful:
https://www.qrmaster.net/dynamic-qr-code-generator
Would genuinely love blunt feedback more than polite praise.
```
### 15. Direct relevance / no hard sell
```text
I built a tool for this exact issue, so obvious bias:
https://www.qrmaster.net/dynamic-qr-code-generator
No hard sell. Just seemed directly relevant to what you asked.
```
## Schedule
### 2026-03-17 Tuesday, 13:00
- Subreddit: `r/startups`
- Post #2
- Title: `One URL change can ruin 500 flyers. That pain is more real than I expected.`
- Use replies: `2`, `4`, `11`, `12`, `15`
```text
I underestimated how annoying printed mistakes are.
A lot of software problems are reversible.
Print problems arent.
If a landing page changes after flyers, posters, inserts, or menus are already out there, someone has to:
- live with a broken flow
- reprint everything
- or patch it manually in a messy way
That sounds minor until you talk to people actually running campaigns or local businesses.
What small operational problem ended up being much more expensive than it looked at first?
```
### 2026-03-19 Thursday, 13:00
- Subreddit: `r/SaaS`
- Post #1
- Title: `I thought QR code software was about generation. The real pain starts after print.`
- Use replies: `1`, `7`, `10`, `11`, `15`
```text
I used to think the value was “make a QR code fast.”
Its not.
The painful part starts after something is already printed:
- the menu changes
- the event page changes
- the campaign URL changes
- someone notices a typo too late
One small change can turn a stack of flyers into trash.
That shifted how I think about the whole category.
The QR itself is easy.
The expensive part is everything around it.
Anyone else building in a category where the “simple feature” isnt actually where the value is?
```
### 2026-03-24 Tuesday, 13:30
- Subreddit: `r/smallbusiness`
- Post #3
- Title: `Small businesses usually dont need “more marketing.” They need fewer expensive mistakes.`
- Use replies: `3`, `4`, `10`, `12`, `13`
```text
One thing I keep noticing:
A lot of owners dont care about having a fancy tool.
They care about not wasting money.
With QR codes, the common mistakes seem to be:
- printing static codes for things that change often
- sending people to ugly mobile pages
- having no idea whether anyone scanned anything
- letting one outdated link stay live for weeks
Feels like a lot of “marketing problems” are actually ops problems.
Whats one small process change that saved your business money this year?
```
### 2026-03-26 Thursday, 13:00
- Subreddit: `r/EntrepreneurRideAlong`
- Post #4
- Use replies: `2`, `4`, `9`, `11`, `15`
```text
Building in a boring category taught me that boring problems are usually expensive
Im building around QR codes, which sounds incredibly boring on paper.
But boring problems are often the ones people pay to avoid.
In this case, its stuff like:
- reprinting menus
- fixing outdated flyers
- updating event info after posters are already out
- managing lots of QR destinations across campaigns
Nobody is emotionally excited about QR codes.
Theyre emotionally excited about not dealing with preventable mess.
Anyone else building something “unsexy” that turned out to have very real pain behind it?
```
### 2026-03-27 Friday, 14:30
- Subreddit: `r/SideProject`
- Post #5
- Title: `The weird part about building a QR product is that the technical problem isnt the interesting one`
- Use replies: `1`, `6`, `7`, `8`, `15`
```text
Generating a QR image is trivial.
What turned out to be more interesting:
- what happens after print
- whether someone can change the destination later
- what analytics are actually useful
- how privacy concerns show up once tracking enters the conversation
- how bulk workflows matter way more than expected
Its one of those products that looks dumb-simple from the outside and much more operational once you talk to users.
What kind of side project looked simple until real use cases started showing up?
```
### 2026-03-31 Tuesday, 14:00
- Subreddit: `r/AlphaandBetaTesters`
- Post #7
- Use replies: `3`, `5`, `6`, `8`, `14`
```text
Looking for feedback from anyone who has used QR codes in restaurants, events, print, or packaging
Im trying to learn from people who use QR codes in the real world, not just in theory.
Especially if youve used them for:
- menus
- flyers
- product packaging
- event materials
- WiFi / contact sharing
- agency campaigns
Things Im curious about:
- what changes most often after something is printed?
- whats annoying about current tools?
- do you actually care about scan analytics?
- does privacy / GDPR affect vendor choice at all?
Im happy to share what Im building if useful, but mostly looking for honest feedback from people whove dealt with this firsthand.
```
### 2026-04-02 Thursday, 14:00
- Subreddit: `r/RoastMyStartup`
- Post #8
- Use replies: `2`, `10`, `11`, `14`, `15`
```text
Roast my positioning: is “avoid reprints and broken QR campaigns” a strong enough problem?
Im working on a product around dynamic QR codes.
The positioning Im testing is less “make QR codes” and more:
“avoid reprints, outdated links, and messy campaign management.”
Target users are mostly:
- small businesses
- restaurants
- marketers
- agencies
- event / packaging use cases
The questions Id love roasted:
- does the pain feel real enough?
- does this sound too niche?
- what part sounds generic or weak?
- what would make you ignore this instantly?
Happy to share the product if the sub is okay with it.
```
### 2026-04-03 Friday, 14:30
- Subreddit: `r/ProductMgmt`
- Post #6
- Use replies: `1`, `7`, `8`, `10`, `12`
```text
Users say they want a QR generator. What they actually want is damage control.
A PM lesson I didnt expect:
People describe the need as “I need a QR code.”
But what they actually care about is something like:
“I need this thing to not break once its already printed.”
That changes what feels important.
“Generate code” sounds like the core feature.
But retention/value probably sits closer to:
- edit later
- track scans
- handle multiple codes
- avoid privacy headaches
- manage existing campaigns cleanly
Have you seen that mismatch in your own product?
What users ask for first vs. what actually matters later?
```
### 2026-04-07 Tuesday, 14:00
- Subreddit: `r/startups`
- Post #9
- Use replies: `1`, `7`, `9`, `10`, `15`
```text
Im starting to think “edit after print” is a stronger product promise than “track scans”
Interesting thing from early positioning:
I assumed analytics would be the hero feature.
But “I can change the destination later” seems to click faster.
Makes sense in hindsight.
Tracking is nice.
Avoiding expensive mistakes is urgent.
So now Im wondering if the better message is:
- first promise control
- then introduce analytics
- then layer in bulk / workflow / privacy
If youve sold into small businesses or marketers:
what kind of promise gets attention faster, insight or control?
```
### 2026-04-09 Thursday, 14:30
- Subreddit: `r/smallbusiness`
- Post #10
- Use replies: `3`, `4`, `6`, `13`, `15`
```text
What looks like a tiny print detail can quietly waste a lot of money
I keep coming back to this:
A broken link on a website is annoying.
A broken link on printed material is expensive.
Because now the problem is sitting in:
- stores
- restaurants
- posters
- packaging
- tables
- flyers already handed out
Feels like one of those things that sounds tiny until you count the friction and replacement cost.
Whats a “small detail” in your business that causes way more downstream cost than people assume?
```
# QR Master Reddit Calendar - Full 4 Weeks
Times are in Europe/Berlin.
## Link Map
- Core dynamic angle: `https://www.qrmaster.net/dynamic-qr-code-generator`
- Reprint / waste angle: `https://www.qrmaster.net/reprint-calculator`
- Restaurant / menu: `https://www.qrmaster.net/use-cases/restaurant-menu-qr-codes`
- Flyer / print campaigns: `https://www.qrmaster.net/use-cases/flyer-qr-codes`
- Event use case: `https://www.qrmaster.net/use-cases/event-qr-codes`
- Bulk / packaging: `https://www.qrmaster.net/bulk-qr-code-generator`
- Packaging use case: `https://www.qrmaster.net/use-cases/packaging-qr-codes`
- Tracking / analytics: `https://www.qrmaster.net/qr-code-tracking`
- Privacy: `https://www.qrmaster.net/privacy`
- Campaign workflows: `https://www.qrmaster.net/qr-code-for-marketing-campaigns`
- Main site: `https://www.qrmaster.net/`
## Reply Library
### 1. If someone asks what tool you use
```text
If you need the destination to stay editable later, thats exactly the problem I built around.
I built QR Master for this, so obvious bias:
https://www.qrmaster.net/dynamic-qr-code-generator
If you want, I can also just explain the setup without linking anything.
```
### 2. If someone asks for the link directly
```text
Sure. Im building it, so bias disclosed:
https://www.qrmaster.net/dynamic-qr-code-generator
Main use case is when something is already printed and the destination changes later.
```
### 3. Restaurant / menu threads
```text
If the menu changes even semi-regularly, I wouldnt print a static QR.
I built a tool for exactly that use case, so obvious bias here:
https://www.qrmaster.net/use-cases/restaurant-menu-qr-codes
Even without using mine, Id still recommend a dynamic destination and a very mobile-friendly menu page.
```
### 4. Flyer / print campaign threads
```text
The problem usually isnt generating the QR. Its what happens when the page changes after print.
I built something specifically for that:
https://www.qrmaster.net/use-cases/flyer-qr-codes
If helpful, I can outline the print-safe setup here too.
```
### 5. Event use case
```text
For events, Id mainly optimize for “can we update this later without reprinting everything?”
I built QR Master around that exact headache, so bias here:
https://www.qrmaster.net/use-cases/event-qr-codes
```
### 6. Packaging / bulk
```text
If youre dealing with lots of codes, bulk workflow matters way more than people expect.
I built this with that in mind:
https://www.qrmaster.net/bulk-qr-code-generator
Happy to share what a clean CSV / batch setup usually looks like.
```
### 7. Analytics questions
```text
You probably want some scan visibility, but only the useful stuff.
I built QR Master with that angle in mind, so obvious bias:
https://www.qrmaster.net/qr-code-tracking
Personally I think basic scan data + clean attribution matters more than a flashy dashboard.
```
### 8. Privacy / GDPR
```text
If privacy matters, Id look very closely at how the tracking is handled.
Im building QR Master, so not pretending to be neutral:
https://www.qrmaster.net/privacy
That said, Id ask the same questions to any vendor.
```
### 9. If someone says this would be useful for agencies
```text
Yeah, agencies are actually one of the more interesting use cases.
I built this partly with that workflow in mind:
https://www.qrmaster.net/qr-code-for-marketing-campaigns
Managing multiple destinations gets messy fast otherwise.
```
### 10. If someone asks about alternatives
```text
Depends what you care about.
If you just need a basic static code, almost anything works.
If you need editability after print, thats where tools start to differ.
I built QR Master for that second case:
https://www.qrmaster.net/dynamic-qr-code-generator
```
### 11. If someone is skeptical
```text
Fair skepticism.
I built it, so Im obviously biased:
https://www.qrmaster.net/dynamic-qr-code-generator
If youd rather keep it link-free, Im happy to just explain the tradeoffs here.
```
### 12. If someone asks how you would set this up
```text
Short version:
use a dynamic destination, keep the landing page mobile-first, and make sure you can update it after print.
I built a tool for exactly that flow:
https://www.qrmaster.net/dynamic-qr-code-generator
```
### 13. If someone asks for recommendations
```text
If the destination might change later, Id use a dynamic QR setup.
I built one for that use case, so take this with bias:
https://www.qrmaster.net/dynamic-qr-code-generator
If not mine, Id still choose something that makes post-print edits easy.
```
### 14. Feedback threads
```text
Im building in this space, so this is partly self-interested, but yes, this is a real problem.
Heres what Im working on if useful:
https://www.qrmaster.net/dynamic-qr-code-generator
Would genuinely love blunt feedback more than polite praise.
```
### 15. Direct relevance / no hard sell
```text
I built a tool for this exact issue, so obvious bias:
https://www.qrmaster.net/dynamic-qr-code-generator
No hard sell. Just seemed directly relevant to what you asked.
```
## Schedule
### 2026-03-17 Tuesday, 13:00
- Subreddit: `r/startups`
- Post #2
- Title: `One URL change can ruin 500 flyers. That pain is more real than I expected.`
- Use replies: `2`, `4`, `11`, `12`, `15`
```text
I underestimated how annoying printed mistakes are.
A lot of software problems are reversible.
Print problems arent.
If a landing page changes after flyers, posters, inserts, or menus are already out there, someone has to:
- live with a broken flow
- reprint everything
- or patch it manually in a messy way
That sounds minor until you talk to people actually running campaigns or local businesses.
What small operational problem ended up being much more expensive than it looked at first?
```
### 2026-03-19 Thursday, 13:00
- Subreddit: `r/SaaS`
- Post #1
- Title: `I thought QR code software was about generation. The real pain starts after print.`
- Use replies: `1`, `7`, `10`, `11`, `15`
```text
I used to think the value was “make a QR code fast.”
Its not.
The painful part starts after something is already printed:
- the menu changes
- the event page changes
- the campaign URL changes
- someone notices a typo too late
One small change can turn a stack of flyers into trash.
That shifted how I think about the whole category.
The QR itself is easy.
The expensive part is everything around it.
Anyone else building in a category where the “simple feature” isnt actually where the value is?
```
### 2026-03-24 Tuesday, 13:30
- Subreddit: `r/smallbusiness`
- Post #3
- Title: `Small businesses usually dont need “more marketing.” They need fewer expensive mistakes.`
- Use replies: `3`, `4`, `10`, `12`, `13`
```text
One thing I keep noticing:
A lot of owners dont care about having a fancy tool.
They care about not wasting money.
With QR codes, the common mistakes seem to be:
- printing static codes for things that change often
- sending people to ugly mobile pages
- having no idea whether anyone scanned anything
- letting one outdated link stay live for weeks
Feels like a lot of “marketing problems” are actually ops problems.
Whats one small process change that saved your business money this year?
```
### 2026-03-26 Thursday, 13:00
- Subreddit: `r/EntrepreneurRideAlong`
- Post #4
- Use replies: `2`, `4`, `9`, `11`, `15`
```text
Building in a boring category taught me that boring problems are usually expensive
Im building around QR codes, which sounds incredibly boring on paper.
But boring problems are often the ones people pay to avoid.
In this case, its stuff like:
- reprinting menus
- fixing outdated flyers
- updating event info after posters are already out
- managing lots of QR destinations across campaigns
Nobody is emotionally excited about QR codes.
Theyre emotionally excited about not dealing with preventable mess.
Anyone else building something “unsexy” that turned out to have very real pain behind it?
```
### 2026-03-27 Friday, 14:30
- Subreddit: `r/SideProject`
- Post #5
- Title: `The weird part about building a QR product is that the technical problem isnt the interesting one`
- Use replies: `1`, `6`, `7`, `8`, `15`
```text
Generating a QR image is trivial.
What turned out to be more interesting:
- what happens after print
- whether someone can change the destination later
- what analytics are actually useful
- how privacy concerns show up once tracking enters the conversation
- how bulk workflows matter way more than expected
Its one of those products that looks dumb-simple from the outside and much more operational once you talk to users.
What kind of side project looked simple until real use cases started showing up?
```
### 2026-03-31 Tuesday, 14:00
- Subreddit: `r/AlphaandBetaTesters`
- Post #7
- Use replies: `3`, `5`, `6`, `8`, `14`
```text
Looking for feedback from anyone who has used QR codes in restaurants, events, print, or packaging
Im trying to learn from people who use QR codes in the real world, not just in theory.
Especially if youve used them for:
- menus
- flyers
- product packaging
- event materials
- WiFi / contact sharing
- agency campaigns
Things Im curious about:
- what changes most often after something is printed?
- whats annoying about current tools?
- do you actually care about scan analytics?
- does privacy / GDPR affect vendor choice at all?
Im happy to share what Im building if useful, but mostly looking for honest feedback from people whove dealt with this firsthand.
```
### 2026-04-02 Thursday, 14:00
- Subreddit: `r/RoastMyStartup`
- Post #8
- Use replies: `2`, `10`, `11`, `14`, `15`
```text
Roast my positioning: is “avoid reprints and broken QR campaigns” a strong enough problem?
Im working on a product around dynamic QR codes.
The positioning Im testing is less “make QR codes” and more:
“avoid reprints, outdated links, and messy campaign management.”
Target users are mostly:
- small businesses
- restaurants
- marketers
- agencies
- event / packaging use cases
The questions Id love roasted:
- does the pain feel real enough?
- does this sound too niche?
- what part sounds generic or weak?
- what would make you ignore this instantly?
Happy to share the product if the sub is okay with it.
```
### 2026-04-03 Friday, 14:30
- Subreddit: `r/ProductMgmt`
- Post #6
- Use replies: `1`, `7`, `8`, `10`, `12`
```text
Users say they want a QR generator. What they actually want is damage control.
A PM lesson I didnt expect:
People describe the need as “I need a QR code.”
But what they actually care about is something like:
“I need this thing to not break once its already printed.”
That changes what feels important.
“Generate code” sounds like the core feature.
But retention/value probably sits closer to:
- edit later
- track scans
- handle multiple codes
- avoid privacy headaches
- manage existing campaigns cleanly
Have you seen that mismatch in your own product?
What users ask for first vs. what actually matters later?
```
### 2026-04-07 Tuesday, 14:00
- Subreddit: `r/startups`
- Post #9
- Use replies: `1`, `7`, `9`, `10`, `15`
```text
Im starting to think “edit after print” is a stronger product promise than “track scans”
Interesting thing from early positioning:
I assumed analytics would be the hero feature.
But “I can change the destination later” seems to click faster.
Makes sense in hindsight.
Tracking is nice.
Avoiding expensive mistakes is urgent.
So now Im wondering if the better message is:
- first promise control
- then introduce analytics
- then layer in bulk / workflow / privacy
If youve sold into small businesses or marketers:
what kind of promise gets attention faster, insight or control?
```
### 2026-04-09 Thursday, 14:30
- Subreddit: `r/smallbusiness`
- Post #10
- Use replies: `3`, `4`, `6`, `13`, `15`
```text
What looks like a tiny print detail can quietly waste a lot of money
I keep coming back to this:
A broken link on a website is annoying.
A broken link on printed material is expensive.
Because now the problem is sitting in:
- stores
- restaurants
- posters
- packaging
- tables
- flyers already handed out
Feels like one of those things that sounds tiny until you count the friction and replacement cost.
Whats a “small detail” in your business that causes way more downstream cost than people assume?
```

View File

@@ -1,168 +1,168 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//QR Master//Reddit 4 Week Calendar Universal//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:QR Master Reddit Plan
X-WR-TIMEZONE:Europe/Berlin
BEGIN:VEVENT
UID:reddit-20260316-comments-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260316T133000Z
DTEND:20260316T141500Z
SUMMARY:Reddit comment block - r/startups + r/SaaS
DESCRIPTION:Warm up account and build relevant karma. No links unless asked directly.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260317-startups-post-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260317T120000Z
DTEND:20260317T124500Z
SUMMARY:Reddit post - r/startups
DESCRIPTION:Post: One URL change can ruin 500 flyers. Link only if asked. Use https://www.qrmaster.net/reprint-calculator
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260318-startups-replies-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260318T133000Z
DTEND:20260318T141500Z
SUMMARY:Reddit replies - r/startups
DESCRIPTION:Reply to all serious comments from Tuesday. Keep link-free unless asked directly.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260319-saas-post-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260319T120000Z
DTEND:20260319T124500Z
SUMMARY:Reddit post - r/SaaS
DESCRIPTION:Post: The real pain starts after print. Link only if asked. Use https://www.qrmaster.net/dynamic-qr-code-generator
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260320-saas-comments-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260320T133000Z
DTEND:20260320T141500Z
SUMMARY:Reddit comment block - r/SaaS
DESCRIPTION:Extend the Thursday discussion. Tracking link if needed: https://www.qrmaster.net/qr-code-tracking
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260323-smallbiz-sideproject-comments-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260323T133000Z
DTEND:20260323T141500Z
SUMMARY:Reddit comment block - r/smallbusiness + r/SideProject
DESCRIPTION:Warm both subs before posting this week. No links unless asked directly.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260324-smallbusiness-post-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260324T123000Z
DTEND:20260324T131500Z
SUMMARY:Reddit post - r/smallbusiness
DESCRIPTION:Post: Most small businesses do not need more tools. Default link if asked: https://www.qrmaster.net/reprint-calculator
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260325-smallbusiness-replies-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260325T133000Z
DTEND:20260325T141500Z
SUMMARY:Reddit replies - r/smallbusiness
DESCRIPTION:Answer practical questions from Tuesday. Drop links only when the use case is obvious.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260326-sideproject-post-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260326T120000Z
DTEND:20260326T124500Z
SUMMARY:Reddit post - r/SideProject
DESCRIPTION:Post: The technical problem is not the interesting one. Core link: https://www.qrmaster.net/dynamic-qr-code-generator
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260327-sideproject-comments-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260327T133000Z
DTEND:20260327T141500Z
SUMMARY:Reddit comment block - r/SideProject
DESCRIPTION:Follow up on Thursday. Bulk link if needed: https://www.qrmaster.net/bulk-qr-code-generator
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260330-feedback-roast-comments-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260330T133000Z
DTEND:20260330T141500Z
SUMMARY:Reddit comment block - feedback week warm-up
DESCRIPTION:Warm up r/AlphaandBetaTesters and r/RoastMyStartup. No links today.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260331-alpha-beta-post-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260331T120000Z
DTEND:20260331T124500Z
SUMMARY:Reddit post - r/AlphaandBetaTesters
DESCRIPTION:Feedback request. Put the link in the first comment. Use https://www.qrmaster.net/dynamic-qr-code-generator
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260401-alpha-beta-replies-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260401T133000Z
DTEND:20260401T141500Z
SUMMARY:Reddit replies - r/AlphaandBetaTesters
DESCRIPTION:Answer all serious feedback. Privacy proof only if asked: https://www.qrmaster.net/privacy
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260402-roast-post-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260402T120000Z
DTEND:20260402T124500Z
SUMMARY:Reddit post - r/RoastMyStartup
DESCRIPTION:Roast my positioning. Direct site link is okay here: https://www.qrmaster.net/
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260403-objection-review-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260403T133000Z
DTEND:20260403T141500Z
SUMMARY:Reddit objection review
DESCRIPTION:Summarize week 3 objections: pricing, niche, privacy, free generator comparison, ICP clarity.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260406-saas-comments-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260406T133000Z
DTEND:20260406T141500Z
SUMMARY:Reddit comment block - r/SaaS
DESCRIPTION:Re-enter with objection-informed comments before the next post. No links unless asked.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260407-saas-post-2-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260407T120000Z
DTEND:20260407T124500Z
SUMMARY:Reddit post - r/SaaS follow-up
DESCRIPTION:Post: edit later vs track scans. Default link if asked: https://www.qrmaster.net/dynamic-qr-code-generator
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260408-saas-replies-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260408T133000Z
DTEND:20260408T141500Z
SUMMARY:Reddit replies - r/SaaS
DESCRIPTION:Work the Tuesday thread hard for comments, not just upvotes.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260409-promo-post-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260409T123000Z
DTEND:20260409T131500Z
SUMMARY:Reddit promo post
DESCRIPTION:Post in r/Plugyourproduct or r/startups_promotion. Direct link okay: https://www.qrmaster.net/
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260410-followup-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260410T133000Z
DTEND:20260410T141500Z
SUMMARY:Reddit follow-up block
DESCRIPTION:Answer all promo-thread comments publicly. No DMs, no pressure.
END:VEVENT
END:VCALENDAR
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//QR Master//Reddit 4 Week Calendar Universal//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:QR Master Reddit Plan
X-WR-TIMEZONE:Europe/Berlin
BEGIN:VEVENT
UID:reddit-20260316-comments-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260316T133000Z
DTEND:20260316T141500Z
SUMMARY:Reddit comment block - r/startups + r/SaaS
DESCRIPTION:Warm up account and build relevant karma. No links unless asked directly.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260317-startups-post-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260317T120000Z
DTEND:20260317T124500Z
SUMMARY:Reddit post - r/startups
DESCRIPTION:Post: One URL change can ruin 500 flyers. Link only if asked. Use https://www.qrmaster.net/reprint-calculator
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260318-startups-replies-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260318T133000Z
DTEND:20260318T141500Z
SUMMARY:Reddit replies - r/startups
DESCRIPTION:Reply to all serious comments from Tuesday. Keep link-free unless asked directly.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260319-saas-post-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260319T120000Z
DTEND:20260319T124500Z
SUMMARY:Reddit post - r/SaaS
DESCRIPTION:Post: The real pain starts after print. Link only if asked. Use https://www.qrmaster.net/dynamic-qr-code-generator
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260320-saas-comments-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260320T133000Z
DTEND:20260320T141500Z
SUMMARY:Reddit comment block - r/SaaS
DESCRIPTION:Extend the Thursday discussion. Tracking link if needed: https://www.qrmaster.net/qr-code-tracking
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260323-smallbiz-sideproject-comments-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260323T133000Z
DTEND:20260323T141500Z
SUMMARY:Reddit comment block - r/smallbusiness + r/SideProject
DESCRIPTION:Warm both subs before posting this week. No links unless asked directly.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260324-smallbusiness-post-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260324T123000Z
DTEND:20260324T131500Z
SUMMARY:Reddit post - r/smallbusiness
DESCRIPTION:Post: Most small businesses do not need more tools. Default link if asked: https://www.qrmaster.net/reprint-calculator
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260325-smallbusiness-replies-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260325T133000Z
DTEND:20260325T141500Z
SUMMARY:Reddit replies - r/smallbusiness
DESCRIPTION:Answer practical questions from Tuesday. Drop links only when the use case is obvious.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260326-sideproject-post-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260326T120000Z
DTEND:20260326T124500Z
SUMMARY:Reddit post - r/SideProject
DESCRIPTION:Post: The technical problem is not the interesting one. Core link: https://www.qrmaster.net/dynamic-qr-code-generator
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260327-sideproject-comments-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260327T133000Z
DTEND:20260327T141500Z
SUMMARY:Reddit comment block - r/SideProject
DESCRIPTION:Follow up on Thursday. Bulk link if needed: https://www.qrmaster.net/bulk-qr-code-generator
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260330-feedback-roast-comments-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260330T133000Z
DTEND:20260330T141500Z
SUMMARY:Reddit comment block - feedback week warm-up
DESCRIPTION:Warm up r/AlphaandBetaTesters and r/RoastMyStartup. No links today.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260331-alpha-beta-post-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260331T120000Z
DTEND:20260331T124500Z
SUMMARY:Reddit post - r/AlphaandBetaTesters
DESCRIPTION:Feedback request. Put the link in the first comment. Use https://www.qrmaster.net/dynamic-qr-code-generator
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260401-alpha-beta-replies-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260401T133000Z
DTEND:20260401T141500Z
SUMMARY:Reddit replies - r/AlphaandBetaTesters
DESCRIPTION:Answer all serious feedback. Privacy proof only if asked: https://www.qrmaster.net/privacy
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260402-roast-post-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260402T120000Z
DTEND:20260402T124500Z
SUMMARY:Reddit post - r/RoastMyStartup
DESCRIPTION:Roast my positioning. Direct site link is okay here: https://www.qrmaster.net/
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260403-objection-review-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260403T133000Z
DTEND:20260403T141500Z
SUMMARY:Reddit objection review
DESCRIPTION:Summarize week 3 objections: pricing, niche, privacy, free generator comparison, ICP clarity.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260406-saas-comments-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260406T133000Z
DTEND:20260406T141500Z
SUMMARY:Reddit comment block - r/SaaS
DESCRIPTION:Re-enter with objection-informed comments before the next post. No links unless asked.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260407-saas-post-2-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260407T120000Z
DTEND:20260407T124500Z
SUMMARY:Reddit post - r/SaaS follow-up
DESCRIPTION:Post: edit later vs track scans. Default link if asked: https://www.qrmaster.net/dynamic-qr-code-generator
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260408-saas-replies-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260408T133000Z
DTEND:20260408T141500Z
SUMMARY:Reddit replies - r/SaaS
DESCRIPTION:Work the Tuesday thread hard for comments, not just upvotes.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260409-promo-post-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260409T123000Z
DTEND:20260409T131500Z
SUMMARY:Reddit promo post
DESCRIPTION:Post in r/Plugyourproduct or r/startups_promotion. Direct link okay: https://www.qrmaster.net/
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260410-followup-universal@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260410T133000Z
DTEND:20260410T141500Z
SUMMARY:Reddit follow-up block
DESCRIPTION:Answer all promo-thread comments publicly. No DMs, no pressure.
END:VEVENT
END:VCALENDAR

View File

@@ -1,166 +1,166 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//QR Master//Reddit 4 Week Calendar//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
BEGIN:VEVENT
UID:reddit-20260316-comments@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260316T143000
DTEND:20260316T151500
SUMMARY:Reddit comment block - r/startups + r/SaaS
DESCRIPTION:Goal: warm up account and build relevant karma. No links unless asked directly.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260317-startups-post@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260317T130000
DTEND:20260317T134500
SUMMARY:Reddit post - r/startups
DESCRIPTION:Title: One URL change can ruin 500 flyers. Link only if asked. Use https://www.qrmaster.net/reprint-calculator
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260318-startups-replies@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260318T143000
DTEND:20260318T151500
SUMMARY:Reddit replies - r/startups
DESCRIPTION:Reply to all serious comments from Tuesday. Keep link-free unless asked directly.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260319-saas-post@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260319T130000
DTEND:20260319T134500
SUMMARY:Reddit post - r/SaaS
DESCRIPTION:Title: The real pain starts after print. Link only if asked. Use https://www.qrmaster.net/dynamic-qr-code-generator
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260320-saas-comments@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260320T143000
DTEND:20260320T151500
SUMMARY:Reddit comment block - r/SaaS
DESCRIPTION:Extend the Thursday discussion. If asked about tracking, use https://www.qrmaster.net/qr-code-tracking
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260323-smallbiz-sideproject-comments@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260323T143000
DTEND:20260323T151500
SUMMARY:Reddit comment block - r/smallbusiness + r/SideProject
DESCRIPTION:Warm both subs before posting this week. No links unless asked directly.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260324-smallbusiness-post@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260324T133000
DTEND:20260324T141500
SUMMARY:Reddit post - r/smallbusiness
DESCRIPTION:Title: Most small businesses don't need more tools. Default link if asked: https://www.qrmaster.net/reprint-calculator Restaurant link: https://www.qrmaster.net/use-cases/restaurant-menu-qr-codes
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260325-smallbusiness-replies@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260325T143000
DTEND:20260325T151500
SUMMARY:Reddit replies - r/smallbusiness
DESCRIPTION:Answer practical questions from Tuesday. Drop links only when the use case is obvious.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260326-sideproject-post@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260326T130000
DTEND:20260326T134500
SUMMARY:Reddit post - r/SideProject
DESCRIPTION:Title: The technical problem isn't the interesting one. Core link if asked: https://www.qrmaster.net/dynamic-qr-code-generator Bulk link: https://www.qrmaster.net/bulk-qr-code-generator
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260327-sideproject-comments@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260327T143000
DTEND:20260327T151500
SUMMARY:Reddit comment block - r/SideProject
DESCRIPTION:Follow up on the Thursday thread. Use bulk link if people ask about scale or packaging.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260330-feedback-roast-comments@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260330T153000
DTEND:20260330T161500
SUMMARY:Reddit comment block - feedback week warm-up
DESCRIPTION:Warm up r/AlphaandBetaTesters and r/RoastMyStartup. No links today.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260331-alpha-beta-post@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260331T140000
DTEND:20260331T144500
SUMMARY:Reddit post - r/AlphaandBetaTesters
DESCRIPTION:Feedback request. Put the link in the first comment, not the post body. Use https://www.qrmaster.net/dynamic-qr-code-generator
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260401-alpha-beta-replies@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260401T153000
DTEND:20260401T161500
SUMMARY:Reddit replies - r/AlphaandBetaTesters
DESCRIPTION:Answer all serious feedback. Privacy proof only if asked: https://www.qrmaster.net/privacy
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260402-roast-post@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260402T140000
DTEND:20260402T144500
SUMMARY:Reddit post - r/RoastMyStartup
DESCRIPTION:Roast my positioning. Direct site link is okay here: https://www.qrmaster.net/
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260403-objection-review@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260403T153000
DTEND:20260403T161500
SUMMARY:Reddit objection review
DESCRIPTION:Summarize the week-3 objections: pricing, niche, privacy, free generator comparison, ICP clarity.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260406-saas-comments@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260406T153000
DTEND:20260406T161500
SUMMARY:Reddit comment block - r/SaaS
DESCRIPTION:Re-enter with objection-informed comments before the next post. No links unless asked.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260407-saas-post-2@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260407T140000
DTEND:20260407T144500
SUMMARY:Reddit post - r/SaaS follow-up
DESCRIPTION:Title: edit later vs track scans. Default link if asked: https://www.qrmaster.net/dynamic-qr-code-generator Measurement angle: https://www.qrmaster.net/qr-code-for-marketing-campaigns
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260408-saas-replies@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260408T153000
DTEND:20260408T161500
SUMMARY:Reddit replies - r/SaaS
DESCRIPTION:Work the Tuesday thread hard for comments, not just upvotes.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260409-promo-post@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260409T143000
DTEND:20260409T151500
SUMMARY:Reddit promo post - r/Plugyourproduct or r/startups_promotion
DESCRIPTION:Direct link in post is okay. Use https://www.qrmaster.net/ Optional focused link: https://www.qrmaster.net/dynamic-qr-code-generator
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260410-followup@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260410T153000
DTEND:20260410T161500
SUMMARY:Reddit follow-up block
DESCRIPTION:Answer all promo-thread comments publicly. No DMs, no pressure, keep it in-thread.
END:VEVENT
END:VCALENDAR
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//QR Master//Reddit 4 Week Calendar//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
BEGIN:VEVENT
UID:reddit-20260316-comments@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260316T143000
DTEND:20260316T151500
SUMMARY:Reddit comment block - r/startups + r/SaaS
DESCRIPTION:Goal: warm up account and build relevant karma. No links unless asked directly.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260317-startups-post@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260317T130000
DTEND:20260317T134500
SUMMARY:Reddit post - r/startups
DESCRIPTION:Title: One URL change can ruin 500 flyers. Link only if asked. Use https://www.qrmaster.net/reprint-calculator
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260318-startups-replies@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260318T143000
DTEND:20260318T151500
SUMMARY:Reddit replies - r/startups
DESCRIPTION:Reply to all serious comments from Tuesday. Keep link-free unless asked directly.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260319-saas-post@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260319T130000
DTEND:20260319T134500
SUMMARY:Reddit post - r/SaaS
DESCRIPTION:Title: The real pain starts after print. Link only if asked. Use https://www.qrmaster.net/dynamic-qr-code-generator
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260320-saas-comments@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260320T143000
DTEND:20260320T151500
SUMMARY:Reddit comment block - r/SaaS
DESCRIPTION:Extend the Thursday discussion. If asked about tracking, use https://www.qrmaster.net/qr-code-tracking
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260323-smallbiz-sideproject-comments@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260323T143000
DTEND:20260323T151500
SUMMARY:Reddit comment block - r/smallbusiness + r/SideProject
DESCRIPTION:Warm both subs before posting this week. No links unless asked directly.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260324-smallbusiness-post@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260324T133000
DTEND:20260324T141500
SUMMARY:Reddit post - r/smallbusiness
DESCRIPTION:Title: Most small businesses don't need more tools. Default link if asked: https://www.qrmaster.net/reprint-calculator Restaurant link: https://www.qrmaster.net/use-cases/restaurant-menu-qr-codes
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260325-smallbusiness-replies@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260325T143000
DTEND:20260325T151500
SUMMARY:Reddit replies - r/smallbusiness
DESCRIPTION:Answer practical questions from Tuesday. Drop links only when the use case is obvious.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260326-sideproject-post@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260326T130000
DTEND:20260326T134500
SUMMARY:Reddit post - r/SideProject
DESCRIPTION:Title: The technical problem isn't the interesting one. Core link if asked: https://www.qrmaster.net/dynamic-qr-code-generator Bulk link: https://www.qrmaster.net/bulk-qr-code-generator
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260327-sideproject-comments@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260327T143000
DTEND:20260327T151500
SUMMARY:Reddit comment block - r/SideProject
DESCRIPTION:Follow up on the Thursday thread. Use bulk link if people ask about scale or packaging.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260330-feedback-roast-comments@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260330T153000
DTEND:20260330T161500
SUMMARY:Reddit comment block - feedback week warm-up
DESCRIPTION:Warm up r/AlphaandBetaTesters and r/RoastMyStartup. No links today.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260331-alpha-beta-post@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260331T140000
DTEND:20260331T144500
SUMMARY:Reddit post - r/AlphaandBetaTesters
DESCRIPTION:Feedback request. Put the link in the first comment, not the post body. Use https://www.qrmaster.net/dynamic-qr-code-generator
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260401-alpha-beta-replies@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260401T153000
DTEND:20260401T161500
SUMMARY:Reddit replies - r/AlphaandBetaTesters
DESCRIPTION:Answer all serious feedback. Privacy proof only if asked: https://www.qrmaster.net/privacy
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260402-roast-post@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260402T140000
DTEND:20260402T144500
SUMMARY:Reddit post - r/RoastMyStartup
DESCRIPTION:Roast my positioning. Direct site link is okay here: https://www.qrmaster.net/
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260403-objection-review@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260403T153000
DTEND:20260403T161500
SUMMARY:Reddit objection review
DESCRIPTION:Summarize the week-3 objections: pricing, niche, privacy, free generator comparison, ICP clarity.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260406-saas-comments@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260406T153000
DTEND:20260406T161500
SUMMARY:Reddit comment block - r/SaaS
DESCRIPTION:Re-enter with objection-informed comments before the next post. No links unless asked.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260407-saas-post-2@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260407T140000
DTEND:20260407T144500
SUMMARY:Reddit post - r/SaaS follow-up
DESCRIPTION:Title: edit later vs track scans. Default link if asked: https://www.qrmaster.net/dynamic-qr-code-generator Measurement angle: https://www.qrmaster.net/qr-code-for-marketing-campaigns
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260408-saas-replies@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260408T153000
DTEND:20260408T161500
SUMMARY:Reddit replies - r/SaaS
DESCRIPTION:Work the Tuesday thread hard for comments, not just upvotes.
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260409-promo-post@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260409T143000
DTEND:20260409T151500
SUMMARY:Reddit promo post - r/Plugyourproduct or r/startups_promotion
DESCRIPTION:Direct link in post is okay. Use https://www.qrmaster.net/ Optional focused link: https://www.qrmaster.net/dynamic-qr-code-generator
END:VEVENT
BEGIN:VEVENT
UID:reddit-20260410-followup@qrmaster.net
DTSTAMP:20260312T120000Z
DTSTART:20260410T153000
DTEND:20260410T161500
SUMMARY:Reddit follow-up block
DESCRIPTION:Answer all promo-thread comments publicly. No DMs, no pressure, keep it in-thread.
END:VEVENT
END:VCALENDAR

View File

@@ -1,457 +1,457 @@
# Reddit 4-Week Calendar for QR Master
Times below are in Europe/Berlin local time.
Use clean URLs only. Do not add UTM parameters to public Reddit links.
## Link Map
- Reprint / cost angle: `https://www.qrmaster.net/reprint-calculator`
- Dynamic after print: `https://www.qrmaster.net/dynamic-qr-code-generator`
- Restaurant / menu angle: `https://www.qrmaster.net/use-cases/restaurant-menu-qr-codes`
- Flyer / print attribution: `https://www.qrmaster.net/use-cases/flyer-qr-codes`
- Campaign measurement: `https://www.qrmaster.net/qr-code-for-marketing-campaigns`
- Tracking / analytics: `https://www.qrmaster.net/qr-code-tracking`
- Bulk / packaging: `https://www.qrmaster.net/bulk-qr-code-generator`
- Packaging use case: `https://www.qrmaster.net/use-cases/packaging-qr-codes`
- Privacy proof only: `https://www.qrmaster.net/privacy`
- Full site feedback / promo-only subs: `https://www.qrmaster.net/`
## 2026-03-16 Monday, 14:30
- Type: Comment block
- Subreddits: `r/startups`, `r/SaaS`
- Goal: Warm up account and build relevant karma
- Rule: No links unless someone explicitly asks
- Comment prompt ideas:
- "The expensive part starts after print, not at QR generation."
- "A lot of SMB tools sound boring until one mistake turns printed material into waste."
- "I care less about the QR itself and more about what happens when the destination changes later."
## 2026-03-17 Tuesday, 13:00
- Type: Main post
- Subreddit: `r/startups`
- Title: `One URL change can ruin 500 flyers. That pain is more real than I expected.`
- Body:
```text
I underestimated how annoying printed mistakes are.
A lot of software problems are reversible.
Print problems arent.
If a landing page changes after flyers, posters, inserts, or menus are already out there, someone has to:
- live with a broken flow
- reprint everything
- or patch it manually in a messy way
That sounds minor until you talk to people actually running campaigns or local businesses.
What small operational problem ended up being much more expensive than it looked at first?
```
- Link if asked: `https://www.qrmaster.net/reprint-calculator`
- Possible replies:
```text
Yeah, thats the part I underestimated too. The QR itself is easy. The expensive part is when the destination changes after print.
```
```text
I built around exactly that issue, so obvious bias here:
https://www.qrmaster.net/reprint-calculator
If links are annoying in this thread, I can just explain the workflow here.
```
```text
Static is fine if the URL is truly permanent. The pain starts when someone assumes “permanent” and the campaign changes two weeks later.
```
## 2026-03-18 Wednesday, 14:30
- Type: Reply block
- Subreddits: `r/startups`
- Goal: Reply to every serious comment from the Tuesday post
- Link rule: Only if asked directly
- Safe reply template:
```text
Thats fair. Im building in this space, so obvious bias if I share the product. Happy to keep it link-free and just explain the setup.
```
## 2026-03-19 Thursday, 13:00
- Type: Main post
- Subreddit: `r/SaaS`
- Title: `I thought QR code software was about generation. The real pain starts after print.`
- Body:
```text
I used to think the value was “make a QR code fast.”
Its not.
The painful part starts after something is already printed:
- the menu changes
- the event page changes
- the campaign URL changes
- someone notices a typo too late
One small change can turn a stack of flyers into trash.
That shifted how I think about the whole category.
The QR itself is easy.
The expensive part is everything around it.
Anyone else building in a category where the “simple feature” isnt actually where the value is?
```
- Link if asked: `https://www.qrmaster.net/dynamic-qr-code-generator`
- Possible replies:
```text
Thats exactly how I see it now too. “Generate” sounds like the product, but “edit after print” is where the value starts.
```
```text
I built a tool for that exact use case, so obvious founder bias:
https://www.qrmaster.net/dynamic-qr-code-generator
```
```text
I thought analytics would be the hook. In practice, “dont make me reprint stuff” lands faster.
```
## 2026-03-20 Friday, 14:30
- Type: Comment block
- Subreddits: `r/SaaS`
- Goal: Extend the Thursday discussion without posting a new link
- If someone asks about tracking: `https://www.qrmaster.net/qr-code-tracking`
- Safe tracking reply:
```text
If the goal is proof instead of guesswork, tracking matters. Im building in that space too, so obvious bias:
https://www.qrmaster.net/qr-code-tracking
```
## 2026-03-23 Monday, 14:30
- Type: Comment block
- Subreddits: `r/smallbusiness`, `r/SideProject`
- Goal: Warm both subs before posting this week
- Rule: No link unless asked directly
## 2026-03-24 Tuesday, 13:30
- Type: Main post
- Subreddit: `r/smallbusiness`
- Title: `Most small businesses dont need more tools. They need fewer preventable mistakes.`
- Body:
```text
I keep seeing the same pattern:
Owners usually dont want “more software.”
They want fewer headaches.
With QR codes, the common headaches seem to be:
- printing a code that cant be updated later
- linking to a bad mobile page
- not knowing if anyone scanned it
- having to redo materials because one URL changed
That feels less like a marketing problem and more like an operations problem.
What low-effort process change saved you time or money recently?
```
- Default link if asked: `https://www.qrmaster.net/reprint-calculator`
- Restaurant/menu link if relevant: `https://www.qrmaster.net/use-cases/restaurant-menu-qr-codes`
- Possible replies:
```text
Thats basically how I think about it now too. Most owners dont want a “QR platform.” They want to avoid paying twice for the same print run.
```
```text
If the menu changes regularly, I wouldnt print a static QR. I built around exactly that use case, so bias disclosed:
https://www.qrmaster.net/use-cases/restaurant-menu-qr-codes
```
```text
For a more general cost angle, this is the cleanest page to share:
https://www.qrmaster.net/reprint-calculator
```
## 2026-03-25 Wednesday, 14:30
- Type: Reply block
- Subreddits: `r/smallbusiness`
- Goal: Answer every practical question from the Tuesday post
- Rule: Only drop a link when the use case is obvious
## 2026-03-26 Thursday, 13:00
- Type: Main post
- Subreddit: `r/SideProject`
- Title: `The weird part about building a QR product is that the technical problem isnt the interesting one`
- Body:
```text
Generating a QR image is trivial.
What turned out to be more interesting:
- what happens after print
- whether someone can change the destination later
- what analytics are actually useful
- how privacy concerns show up once tracking enters the conversation
- how bulk workflows matter way more than expected
Its one of those products that looks dumb-simple from the outside and much more operational once you talk to users.
What kind of side project looked simple until real use cases started showing up?
```
- Link if asked: `https://www.qrmaster.net/dynamic-qr-code-generator`
- Bulk link if someone asks about scale: `https://www.qrmaster.net/bulk-qr-code-generator`
- Possible replies:
```text
Exactly. The QR itself is not the product. The post-print control is.
```
```text
I built around that exact issue, so obvious bias:
https://www.qrmaster.net/dynamic-qr-code-generator
```
```text
If the interesting part for you is scale, the bulk side is here:
https://www.qrmaster.net/bulk-qr-code-generator
```
## 2026-03-27 Friday, 14:30
- Type: Comment block
- Subreddits: `r/SideProject`
- Goal: Follow up on the Thursday thread and answer bulk/packaging questions
- Primary link if relevant: `https://www.qrmaster.net/bulk-qr-code-generator`
## 2026-03-30 Monday, 15:30
- Type: Comment block
- Subreddits: `r/AlphaandBetaTesters`, `r/RoastMyStartup`
- Goal: Warm up both communities before feedback posts
- Rule: No links today
## 2026-03-31 Tuesday, 14:00
- Type: Feedback post
- Subreddit: `r/AlphaandBetaTesters`
- Title: `Looking for feedback from anyone who has used QR codes in restaurants, events, print, or packaging`
- Body:
```text
Im trying to learn from people who use QR codes in the real world, not just in theory.
Especially if youve used them for:
- menus
- flyers
- product packaging
- event materials
- WiFi / contact sharing
- agency campaigns
Things Im curious about:
- what changes most often after something is printed?
- whats annoying about current tools?
- do you actually care about scan analytics?
- does privacy / GDPR affect vendor choice at all?
Im happy to share what Im building if useful, but mostly looking for honest feedback from people whove dealt with this firsthand.
```
- Link placement: first comment, not the post body
- First comment link: `https://www.qrmaster.net/dynamic-qr-code-generator`
- Possible replies:
```text
This is the product Im testing the messaging on, so obvious bias:
https://www.qrmaster.net/dynamic-qr-code-generator
```
```text
Thats useful. The thing I keep hearing too is that the problem starts once something is already printed.
```
```text
If the privacy side is the bigger concern, I can share how Im handling that specifically instead of pitching the product.
```
## 2026-04-01 Wednesday, 15:30
- Type: Reply block
- Subreddits: `r/AlphaandBetaTesters`
- Goal: Answer all serious feedback and record objections
- Privacy proof link only if asked: `https://www.qrmaster.net/privacy`
## 2026-04-02 Thursday, 14:00
- Type: Roast post
- Subreddit: `r/RoastMyStartup`
- Title: `Roast my positioning: is “avoid reprints and broken QR campaigns” a strong enough problem?`
- Body:
```text
Im working on a product around dynamic QR codes.
The positioning Im testing is less “make QR codes” and more:
“avoid reprints, outdated links, and messy campaign management.”
Target users are mostly:
- small businesses
- restaurants
- marketers
- agencies
- event / packaging use cases
The questions Id love roasted:
- does the pain feel real enough?
- does this sound too niche?
- what part sounds generic or weak?
- what would make you ignore this instantly?
Happy to share the product if the sub is okay with it.
```
- Link placement: direct link in post is okay
- Link: `https://www.qrmaster.net/`
- Possible replies:
```text
Fair. The goal here is honestly sharper criticism, not a soft launch.
```
```text
Thats a good callout. If the pain still sounds too “small,” then the messaging isnt strong enough yet.
```
```text
Yep, thats the site:
https://www.qrmaster.net/
```
## 2026-04-03 Friday, 15:30
- Type: Objection review
- Goal: Summarize the week-3 feedback into 3 to 5 objections
- Typical objection buckets:
- "why pay for QR codes?"
- "sounds niche"
- "privacy / GDPR?"
- "whats different from free generators?"
- "who is this really for?"
## 2026-04-06 Monday, 15:30
- Type: Comment block
- Subreddits: `r/SaaS`
- Goal: Re-enter with objection-informed comments before the next post
- Rule: No links unless asked
## 2026-04-07 Tuesday, 14:00
- Type: Main post
- Subreddit: `r/SaaS`
- Title: `Im starting to think “edit later” is a stronger product promise than “track scans”`
- Body:
```text
Interesting thing from early positioning:
I assumed analytics would be the hero feature.
But “I can change the destination later” seems to click faster.
Makes sense in hindsight.
Tracking is nice.
Avoiding expensive mistakes is urgent.
So now Im wondering if the better message is:
- first promise control
- then introduce analytics
- then layer in bulk / workflow / privacy
If youve sold into small businesses or marketers:
what kind of promise gets attention faster, insight or control?
```
- Default link if asked: `https://www.qrmaster.net/dynamic-qr-code-generator`
- If the thread becomes measurement-heavy: `https://www.qrmaster.net/qr-code-for-marketing-campaigns`
- Possible replies:
```text
Thats exactly the split Im seeing too. “Insight” sounds nice, “control” feels urgent.
```
```text
I built around that exact use case, so obvious bias:
https://www.qrmaster.net/dynamic-qr-code-generator
```
```text
If the attribution side is the interesting part for you, this is the more relevant page:
https://www.qrmaster.net/qr-code-for-marketing-campaigns
```
## 2026-04-08 Wednesday, 15:30
- Type: Reply block
- Subreddits: `r/SaaS`
- Goal: Work the Tuesday thread hard for comments, not just upvotes
## 2026-04-09 Thursday, 14:30
- Type: Promo post
- Subreddit: `r/Plugyourproduct` or `r/startups_promotion`
- Title: `QR Master: editable QR codes for print campaigns, menus, packaging, and analytics`
- Body:
```text
Built QR Master to solve a simple but expensive problem:
people print QR codes, then the destination changes later.
What it does:
- editable QR destinations after print
- scan tracking
- bulk workflows
- campaign-friendly use cases for menus, flyers, events, and packaging
Looking for honest feedback on the value prop and landing page clarity.
```
- Link placement: direct link in post
- Link: `https://www.qrmaster.net/`
- Possible replies:
```text
Appreciate it. The core promise is really “dont reprint just because the URL changed.”
```
```text
If you want the most direct core page instead of the homepage, this is it:
https://www.qrmaster.net/dynamic-qr-code-generator
```
```text
If youre more interested in measurement than editability, this page is the better entry point:
https://www.qrmaster.net/qr-code-tracking
```
## 2026-04-10 Friday, 15:30
- Type: Follow-up block
- Goal: Answer all promo-thread comments publicly and close the 4-week run
- Rule: No DMs, no pressure, keep every answer in-thread
# Reddit 4-Week Calendar for QR Master
Times below are in Europe/Berlin local time.
Use clean URLs only. Do not add UTM parameters to public Reddit links.
## Link Map
- Reprint / cost angle: `https://www.qrmaster.net/reprint-calculator`
- Dynamic after print: `https://www.qrmaster.net/dynamic-qr-code-generator`
- Restaurant / menu angle: `https://www.qrmaster.net/use-cases/restaurant-menu-qr-codes`
- Flyer / print attribution: `https://www.qrmaster.net/use-cases/flyer-qr-codes`
- Campaign measurement: `https://www.qrmaster.net/qr-code-for-marketing-campaigns`
- Tracking / analytics: `https://www.qrmaster.net/qr-code-tracking`
- Bulk / packaging: `https://www.qrmaster.net/bulk-qr-code-generator`
- Packaging use case: `https://www.qrmaster.net/use-cases/packaging-qr-codes`
- Privacy proof only: `https://www.qrmaster.net/privacy`
- Full site feedback / promo-only subs: `https://www.qrmaster.net/`
## 2026-03-16 Monday, 14:30
- Type: Comment block
- Subreddits: `r/startups`, `r/SaaS`
- Goal: Warm up account and build relevant karma
- Rule: No links unless someone explicitly asks
- Comment prompt ideas:
- "The expensive part starts after print, not at QR generation."
- "A lot of SMB tools sound boring until one mistake turns printed material into waste."
- "I care less about the QR itself and more about what happens when the destination changes later."
## 2026-03-17 Tuesday, 13:00
- Type: Main post
- Subreddit: `r/startups`
- Title: `One URL change can ruin 500 flyers. That pain is more real than I expected.`
- Body:
```text
I underestimated how annoying printed mistakes are.
A lot of software problems are reversible.
Print problems arent.
If a landing page changes after flyers, posters, inserts, or menus are already out there, someone has to:
- live with a broken flow
- reprint everything
- or patch it manually in a messy way
That sounds minor until you talk to people actually running campaigns or local businesses.
What small operational problem ended up being much more expensive than it looked at first?
```
- Link if asked: `https://www.qrmaster.net/reprint-calculator`
- Possible replies:
```text
Yeah, thats the part I underestimated too. The QR itself is easy. The expensive part is when the destination changes after print.
```
```text
I built around exactly that issue, so obvious bias here:
https://www.qrmaster.net/reprint-calculator
If links are annoying in this thread, I can just explain the workflow here.
```
```text
Static is fine if the URL is truly permanent. The pain starts when someone assumes “permanent” and the campaign changes two weeks later.
```
## 2026-03-18 Wednesday, 14:30
- Type: Reply block
- Subreddits: `r/startups`
- Goal: Reply to every serious comment from the Tuesday post
- Link rule: Only if asked directly
- Safe reply template:
```text
Thats fair. Im building in this space, so obvious bias if I share the product. Happy to keep it link-free and just explain the setup.
```
## 2026-03-19 Thursday, 13:00
- Type: Main post
- Subreddit: `r/SaaS`
- Title: `I thought QR code software was about generation. The real pain starts after print.`
- Body:
```text
I used to think the value was “make a QR code fast.”
Its not.
The painful part starts after something is already printed:
- the menu changes
- the event page changes
- the campaign URL changes
- someone notices a typo too late
One small change can turn a stack of flyers into trash.
That shifted how I think about the whole category.
The QR itself is easy.
The expensive part is everything around it.
Anyone else building in a category where the “simple feature” isnt actually where the value is?
```
- Link if asked: `https://www.qrmaster.net/dynamic-qr-code-generator`
- Possible replies:
```text
Thats exactly how I see it now too. “Generate” sounds like the product, but “edit after print” is where the value starts.
```
```text
I built a tool for that exact use case, so obvious founder bias:
https://www.qrmaster.net/dynamic-qr-code-generator
```
```text
I thought analytics would be the hook. In practice, “dont make me reprint stuff” lands faster.
```
## 2026-03-20 Friday, 14:30
- Type: Comment block
- Subreddits: `r/SaaS`
- Goal: Extend the Thursday discussion without posting a new link
- If someone asks about tracking: `https://www.qrmaster.net/qr-code-tracking`
- Safe tracking reply:
```text
If the goal is proof instead of guesswork, tracking matters. Im building in that space too, so obvious bias:
https://www.qrmaster.net/qr-code-tracking
```
## 2026-03-23 Monday, 14:30
- Type: Comment block
- Subreddits: `r/smallbusiness`, `r/SideProject`
- Goal: Warm both subs before posting this week
- Rule: No link unless asked directly
## 2026-03-24 Tuesday, 13:30
- Type: Main post
- Subreddit: `r/smallbusiness`
- Title: `Most small businesses dont need more tools. They need fewer preventable mistakes.`
- Body:
```text
I keep seeing the same pattern:
Owners usually dont want “more software.”
They want fewer headaches.
With QR codes, the common headaches seem to be:
- printing a code that cant be updated later
- linking to a bad mobile page
- not knowing if anyone scanned it
- having to redo materials because one URL changed
That feels less like a marketing problem and more like an operations problem.
What low-effort process change saved you time or money recently?
```
- Default link if asked: `https://www.qrmaster.net/reprint-calculator`
- Restaurant/menu link if relevant: `https://www.qrmaster.net/use-cases/restaurant-menu-qr-codes`
- Possible replies:
```text
Thats basically how I think about it now too. Most owners dont want a “QR platform.” They want to avoid paying twice for the same print run.
```
```text
If the menu changes regularly, I wouldnt print a static QR. I built around exactly that use case, so bias disclosed:
https://www.qrmaster.net/use-cases/restaurant-menu-qr-codes
```
```text
For a more general cost angle, this is the cleanest page to share:
https://www.qrmaster.net/reprint-calculator
```
## 2026-03-25 Wednesday, 14:30
- Type: Reply block
- Subreddits: `r/smallbusiness`
- Goal: Answer every practical question from the Tuesday post
- Rule: Only drop a link when the use case is obvious
## 2026-03-26 Thursday, 13:00
- Type: Main post
- Subreddit: `r/SideProject`
- Title: `The weird part about building a QR product is that the technical problem isnt the interesting one`
- Body:
```text
Generating a QR image is trivial.
What turned out to be more interesting:
- what happens after print
- whether someone can change the destination later
- what analytics are actually useful
- how privacy concerns show up once tracking enters the conversation
- how bulk workflows matter way more than expected
Its one of those products that looks dumb-simple from the outside and much more operational once you talk to users.
What kind of side project looked simple until real use cases started showing up?
```
- Link if asked: `https://www.qrmaster.net/dynamic-qr-code-generator`
- Bulk link if someone asks about scale: `https://www.qrmaster.net/bulk-qr-code-generator`
- Possible replies:
```text
Exactly. The QR itself is not the product. The post-print control is.
```
```text
I built around that exact issue, so obvious bias:
https://www.qrmaster.net/dynamic-qr-code-generator
```
```text
If the interesting part for you is scale, the bulk side is here:
https://www.qrmaster.net/bulk-qr-code-generator
```
## 2026-03-27 Friday, 14:30
- Type: Comment block
- Subreddits: `r/SideProject`
- Goal: Follow up on the Thursday thread and answer bulk/packaging questions
- Primary link if relevant: `https://www.qrmaster.net/bulk-qr-code-generator`
## 2026-03-30 Monday, 15:30
- Type: Comment block
- Subreddits: `r/AlphaandBetaTesters`, `r/RoastMyStartup`
- Goal: Warm up both communities before feedback posts
- Rule: No links today
## 2026-03-31 Tuesday, 14:00
- Type: Feedback post
- Subreddit: `r/AlphaandBetaTesters`
- Title: `Looking for feedback from anyone who has used QR codes in restaurants, events, print, or packaging`
- Body:
```text
Im trying to learn from people who use QR codes in the real world, not just in theory.
Especially if youve used them for:
- menus
- flyers
- product packaging
- event materials
- WiFi / contact sharing
- agency campaigns
Things Im curious about:
- what changes most often after something is printed?
- whats annoying about current tools?
- do you actually care about scan analytics?
- does privacy / GDPR affect vendor choice at all?
Im happy to share what Im building if useful, but mostly looking for honest feedback from people whove dealt with this firsthand.
```
- Link placement: first comment, not the post body
- First comment link: `https://www.qrmaster.net/dynamic-qr-code-generator`
- Possible replies:
```text
This is the product Im testing the messaging on, so obvious bias:
https://www.qrmaster.net/dynamic-qr-code-generator
```
```text
Thats useful. The thing I keep hearing too is that the problem starts once something is already printed.
```
```text
If the privacy side is the bigger concern, I can share how Im handling that specifically instead of pitching the product.
```
## 2026-04-01 Wednesday, 15:30
- Type: Reply block
- Subreddits: `r/AlphaandBetaTesters`
- Goal: Answer all serious feedback and record objections
- Privacy proof link only if asked: `https://www.qrmaster.net/privacy`
## 2026-04-02 Thursday, 14:00
- Type: Roast post
- Subreddit: `r/RoastMyStartup`
- Title: `Roast my positioning: is “avoid reprints and broken QR campaigns” a strong enough problem?`
- Body:
```text
Im working on a product around dynamic QR codes.
The positioning Im testing is less “make QR codes” and more:
“avoid reprints, outdated links, and messy campaign management.”
Target users are mostly:
- small businesses
- restaurants
- marketers
- agencies
- event / packaging use cases
The questions Id love roasted:
- does the pain feel real enough?
- does this sound too niche?
- what part sounds generic or weak?
- what would make you ignore this instantly?
Happy to share the product if the sub is okay with it.
```
- Link placement: direct link in post is okay
- Link: `https://www.qrmaster.net/`
- Possible replies:
```text
Fair. The goal here is honestly sharper criticism, not a soft launch.
```
```text
Thats a good callout. If the pain still sounds too “small,” then the messaging isnt strong enough yet.
```
```text
Yep, thats the site:
https://www.qrmaster.net/
```
## 2026-04-03 Friday, 15:30
- Type: Objection review
- Goal: Summarize the week-3 feedback into 3 to 5 objections
- Typical objection buckets:
- "why pay for QR codes?"
- "sounds niche"
- "privacy / GDPR?"
- "whats different from free generators?"
- "who is this really for?"
## 2026-04-06 Monday, 15:30
- Type: Comment block
- Subreddits: `r/SaaS`
- Goal: Re-enter with objection-informed comments before the next post
- Rule: No links unless asked
## 2026-04-07 Tuesday, 14:00
- Type: Main post
- Subreddit: `r/SaaS`
- Title: `Im starting to think “edit later” is a stronger product promise than “track scans”`
- Body:
```text
Interesting thing from early positioning:
I assumed analytics would be the hero feature.
But “I can change the destination later” seems to click faster.
Makes sense in hindsight.
Tracking is nice.
Avoiding expensive mistakes is urgent.
So now Im wondering if the better message is:
- first promise control
- then introduce analytics
- then layer in bulk / workflow / privacy
If youve sold into small businesses or marketers:
what kind of promise gets attention faster, insight or control?
```
- Default link if asked: `https://www.qrmaster.net/dynamic-qr-code-generator`
- If the thread becomes measurement-heavy: `https://www.qrmaster.net/qr-code-for-marketing-campaigns`
- Possible replies:
```text
Thats exactly the split Im seeing too. “Insight” sounds nice, “control” feels urgent.
```
```text
I built around that exact use case, so obvious bias:
https://www.qrmaster.net/dynamic-qr-code-generator
```
```text
If the attribution side is the interesting part for you, this is the more relevant page:
https://www.qrmaster.net/qr-code-for-marketing-campaigns
```
## 2026-04-08 Wednesday, 15:30
- Type: Reply block
- Subreddits: `r/SaaS`
- Goal: Work the Tuesday thread hard for comments, not just upvotes
## 2026-04-09 Thursday, 14:30
- Type: Promo post
- Subreddit: `r/Plugyourproduct` or `r/startups_promotion`
- Title: `QR Master: editable QR codes for print campaigns, menus, packaging, and analytics`
- Body:
```text
Built QR Master to solve a simple but expensive problem:
people print QR codes, then the destination changes later.
What it does:
- editable QR destinations after print
- scan tracking
- bulk workflows
- campaign-friendly use cases for menus, flyers, events, and packaging
Looking for honest feedback on the value prop and landing page clarity.
```
- Link placement: direct link in post
- Link: `https://www.qrmaster.net/`
- Possible replies:
```text
Appreciate it. The core promise is really “dont reprint just because the URL changed.”
```
```text
If you want the most direct core page instead of the homepage, this is it:
https://www.qrmaster.net/dynamic-qr-code-generator
```
```text
If youre more interested in measurement than editability, this page is the better entry point:
https://www.qrmaster.net/qr-code-tracking
```
## 2026-04-10 Friday, 15:30
- Type: Follow-up block
- Goal: Answer all promo-thread comments publicly and close the 4-week run
- Rule: No DMs, no pressure, keep every answer in-thread

View File

@@ -1,272 +1,272 @@
# 30-Day X/Twitter Content Plan for QR Master
Use this as a `30-day X/Twitter content plan` for a founder-led QR Master account. It is written in English and optimized for reach first, with product relevance built in.
## Positioning for the Month
`Dynamic QR codes for measurable offline marketing, without creepy tracking.`
## Audience Focus
Primary audience for Days 1-15:
`Restaurants / hospitality`
Secondary audience for Days 16-30:
`Agencies / offline marketers / retail operators`
## CTA Rule for the Whole Month
- Most posts: `Reply with a keyword`, `follow for more`, or `DM me`
- Only light link usage
- Put direct product CTA mainly in replies, profile, and pinned post
## 30-Day Plan
### Day 1
**Post type:** Founder positioning post
**Hook:** `Most QR codes are dead the moment they get printed.`
**Angle:** Static QR codes create reprint costs and broken customer journeys.
**CTA:** `If you run offline marketing, follow this account. I'm breaking down how to fix it.`
### Day 2
**Post type:** Short insight post
**Hook:** `A restaurant menu should not require a reprint every time one dish changes.`
**Angle:** Dynamic QR codes for menus and specials.
**CTA:** `Reply "menu" if you want me to post the exact setup.`
### Day 3
**Post type:** Teardown
**Hook:** `3 mistakes I see on restaurant QR menus all the time:`
**Angle:** Bad placement, no fallback page, no analytics.
**CTA:** `Want me to roast your menu QR? Reply with a screenshot.`
### Day 4
**Post type:** Thread
**Hook:** `How restaurants can update menus without reprinting tables, flyers, or window signs:`
**Angle:** 5-step workflow using one dynamic QR.
**CTA:** `I can turn this into a checklist if people want it.`
### Day 5
**Post type:** Contrarian post
**Hook:** `Unpopular opinion: "free QR code generators" are expensive.`
**Angle:** Hidden cost is reprints, lost scans, no attribution.
**CTA:** `Agree or disagree?`
### Day 6
**Post type:** Demo video
**Hook:** `Change the destination after print. That's the whole game.`
**Angle:** Quick screen recording showing edit-after-print.
**CTA:** `DM me "edit" and I'll send the workflow.`
### Day 7
**Post type:** Founder story
**Hook:** `We started building QR Master because most QR tools felt like toys.`
**Angle:** Needed analytics, bulk creation, privacy-first tracking.
**CTA:** `What's one thing you hate about current QR tools?`
### Day 8
**Post type:** Pain-to-fix post
**Hook:** `If your flyer has a QR code but no tracking, you're guessing.`
**Angle:** Offline campaigns need measurable scans.
**CTA:** `Reply "track" if you want a simple attribution template.`
### Day 9
**Post type:** Restaurant-specific post
**Hook:** `Today's special changes. Your printed QR shouldn't.`
**Angle:** Daily menu operations.
**CTA:** `Restaurant owners: how often do you update menus?`
### Day 10
**Post type:** Roast / audit
**Hook:** `This QR code placement is killing conversions.`
**Angle:** Explain why low-visibility placements fail.
**CTA:** `Send me your flyer/menu/poster and I'll break it down.`
### Day 11
**Post type:** Thread
**Hook:** `5 QR code mistakes that make restaurant marketing look cheap:`
**Angle:** Visual clutter, dead links, bad landing pages, no tracking, wrong CTA.
**CTA:** `I'll post 5 fixes tomorrow if this gets traction.`
### Day 12
**Post type:** Build in public
**Hook:** `One thing founders underestimate: people don't want "a QR code." They want a workflow.`
**Angle:** Product insight from building.
**CTA:** `What simple tool became critical in your business?`
### Day 13
**Post type:** Short proof post
**Hook:** `One QR code. Multiple seasonal campaigns. Zero reprints.`
**Angle:** Reuse same printed asset with changing destination.
**CTA:** `This is one of the biggest underrated offline growth hacks.`
### Day 14
**Post type:** Demo video
**Hook:** `From printed table card to measurable scan funnel in under 30 seconds:`
**Angle:** Show QR creation + analytics preview.
**CTA:** `If you want more product breakdowns, follow.`
### Day 15
**Post type:** Summary / recap
**Hook:** `The biggest restaurant QR lesson so far:`
**Angle:** Most businesses don't need more print, they need more flexibility.
**CTA:** `Next week I'm switching to agencies and offline marketers.`
### Day 16
**Post type:** Agency-focused post
**Hook:** `If your agency runs flyer or poster campaigns without QR attribution, you're underreporting impact.`
**Angle:** Agencies need scan data to prove ROI.
**CTA:** `Reply "agency" if you want my offline attribution framework.`
### Day 17
**Post type:** Contrarian post
**Hook:** `The problem is not the QR code. The problem is the dead destination behind it.`
**Angle:** Static link is the failure point.
**CTA:** `This is where most campaigns quietly lose money.`
### Day 18
**Post type:** Thread
**Hook:** `How to make offline campaigns actually measurable:`
**Angle:** QR + UTM + landing page + analytics naming structure.
**CTA:** `I can turn this into a swipe file.`
### Day 19
**Post type:** Audit post
**Hook:** `3 reasons most poster QR campaigns don't convert:`
**Angle:** Weak CTA, poor mobile page, no tracking structure.
**CTA:** `Want a poster teardown series?`
### Day 20
**Post type:** Demo video
**Hook:** `Bulk-create hundreds of QR codes from a spreadsheet.`
**Angle:** Show CSV/Excel workflow for agencies or retail.
**CTA:** `DM me "bulk" if that would save your team time.`
### Day 21
**Post type:** Founder hot take
**Hook:** `"Just put a QR code on it" is bad marketing advice.`
**Angle:** QR is distribution, not strategy.
**CTA:** `What matters more: placement, offer, or landing page?`
### Day 22
**Post type:** Mini case format
**Hook:** `Campaign idea: one printed asset, three different destinations over 30 days.`
**Angle:** Explain how one QR can support multiple campaign phases.
**CTA:** `This is why dynamic matters more than design.`
### Day 23
**Post type:** Thread
**Hook:** `How I'd structure QR tracking for an agency campaign with flyers, packaging, and in-store signage:`
**Angle:** Naming conventions, attribution logic, reporting.
**CTA:** `If useful, I'll post the naming template.`
### Day 24
**Post type:** Privacy wedge post
**Hook:** `You can measure scans without turning people into surveillance data.`
**Angle:** Privacy-first analytics as a business advantage.
**CTA:** `Too many teams think analytics has to mean creepy.`
### Day 25
**Post type:** Teardown
**Hook:** `This flyer has a QR code. But it still won't tell you what worked.`
**Angle:** Missing attribution structure.
**CTA:** `Reply with "audit" and I'll post a fixed version.`
### Day 26
**Post type:** Retail / packaging post
**Hook:** `Packaging QR codes get interesting when you can change the destination later.`
**Angle:** Product updates, campaigns, support pages, seasonal promos.
**CTA:** `Retail operators: are you using QR for support, promo, or repeat purchase?`
### Day 27
**Post type:** Build in public
**Hook:** `One thing we keep seeing: people buy QR tools for "generation" and stay for "management."`
**Angle:** Product-market insight.
**CTA:** `That distinction matters more than most founders think.`
### Day 28
**Post type:** Demo video
**Hook:** `Here's what "measurable offline workflow" actually looks like in practice:`
**Angle:** Create, edit, track, compare placements.
**CTA:** `If this kind of content is useful, I'll make it a weekly series.`
### Day 29
**Post type:** Hero thread
**Hook:** `Most offline marketing teams don't have a traffic problem. They have a measurement problem.`
**Angle:** Big thesis thread connecting restaurants, agencies, retail, and dynamic QR logic.
**CTA:** `If you work in offline marketing, this is the framework.`
### Day 30
**Post type:** Month-end recap + soft CTA
**Hook:** `30 days of talking to people about QR workflows taught me this:`
**Angle:** Summarize 5 strongest lessons from the month.
**CTA:** `If you want, next I'll publish the full playbook: hooks, setup, and attribution templates.`
## Weekly Cadence
- `Mon`: strong opinion or positioning
- `Tue`: practical educational post
- `Wed`: teardown or audit
- `Thu`: thread
- `Fri`: product proof or demo
- `Sat`: founder insight / build in public
- `Sun`: recap or lighter conversation post
## Content Mix
- `8 threads`
- `6 teardown/audit posts`
- `5 demo videos`
- `6 short contrarian/value posts`
- `5 founder/build-in-public posts`
## Reply Strategy
Every day, add:
- `10-15 replies` to founders, marketers, restaurant-tech, local business, retail ops, and agency accounts
- Focus on posts about: offline marketing, menus, customer journeys, attribution, retail campaigns, print, local growth
- Use replies to seed your core themes:
- reprint cost
- edit after print
- measurable offline
- privacy-first analytics
- bulk workflows
## Optional Next Step
If needed, this can be expanded into:
1. fully written tweets for all 30 days
2. 8 full threads written out
3. a Notion-style content calendar with posting times and CTAs
# 30-Day X/Twitter Content Plan for QR Master
Use this as a `30-day X/Twitter content plan` for a founder-led QR Master account. It is written in English and optimized for reach first, with product relevance built in.
## Positioning for the Month
`Dynamic QR codes for measurable offline marketing, without creepy tracking.`
## Audience Focus
Primary audience for Days 1-15:
`Restaurants / hospitality`
Secondary audience for Days 16-30:
`Agencies / offline marketers / retail operators`
## CTA Rule for the Whole Month
- Most posts: `Reply with a keyword`, `follow for more`, or `DM me`
- Only light link usage
- Put direct product CTA mainly in replies, profile, and pinned post
## 30-Day Plan
### Day 1
**Post type:** Founder positioning post
**Hook:** `Most QR codes are dead the moment they get printed.`
**Angle:** Static QR codes create reprint costs and broken customer journeys.
**CTA:** `If you run offline marketing, follow this account. I'm breaking down how to fix it.`
### Day 2
**Post type:** Short insight post
**Hook:** `A restaurant menu should not require a reprint every time one dish changes.`
**Angle:** Dynamic QR codes for menus and specials.
**CTA:** `Reply "menu" if you want me to post the exact setup.`
### Day 3
**Post type:** Teardown
**Hook:** `3 mistakes I see on restaurant QR menus all the time:`
**Angle:** Bad placement, no fallback page, no analytics.
**CTA:** `Want me to roast your menu QR? Reply with a screenshot.`
### Day 4
**Post type:** Thread
**Hook:** `How restaurants can update menus without reprinting tables, flyers, or window signs:`
**Angle:** 5-step workflow using one dynamic QR.
**CTA:** `I can turn this into a checklist if people want it.`
### Day 5
**Post type:** Contrarian post
**Hook:** `Unpopular opinion: "free QR code generators" are expensive.`
**Angle:** Hidden cost is reprints, lost scans, no attribution.
**CTA:** `Agree or disagree?`
### Day 6
**Post type:** Demo video
**Hook:** `Change the destination after print. That's the whole game.`
**Angle:** Quick screen recording showing edit-after-print.
**CTA:** `DM me "edit" and I'll send the workflow.`
### Day 7
**Post type:** Founder story
**Hook:** `We started building QR Master because most QR tools felt like toys.`
**Angle:** Needed analytics, bulk creation, privacy-first tracking.
**CTA:** `What's one thing you hate about current QR tools?`
### Day 8
**Post type:** Pain-to-fix post
**Hook:** `If your flyer has a QR code but no tracking, you're guessing.`
**Angle:** Offline campaigns need measurable scans.
**CTA:** `Reply "track" if you want a simple attribution template.`
### Day 9
**Post type:** Restaurant-specific post
**Hook:** `Today's special changes. Your printed QR shouldn't.`
**Angle:** Daily menu operations.
**CTA:** `Restaurant owners: how often do you update menus?`
### Day 10
**Post type:** Roast / audit
**Hook:** `This QR code placement is killing conversions.`
**Angle:** Explain why low-visibility placements fail.
**CTA:** `Send me your flyer/menu/poster and I'll break it down.`
### Day 11
**Post type:** Thread
**Hook:** `5 QR code mistakes that make restaurant marketing look cheap:`
**Angle:** Visual clutter, dead links, bad landing pages, no tracking, wrong CTA.
**CTA:** `I'll post 5 fixes tomorrow if this gets traction.`
### Day 12
**Post type:** Build in public
**Hook:** `One thing founders underestimate: people don't want "a QR code." They want a workflow.`
**Angle:** Product insight from building.
**CTA:** `What simple tool became critical in your business?`
### Day 13
**Post type:** Short proof post
**Hook:** `One QR code. Multiple seasonal campaigns. Zero reprints.`
**Angle:** Reuse same printed asset with changing destination.
**CTA:** `This is one of the biggest underrated offline growth hacks.`
### Day 14
**Post type:** Demo video
**Hook:** `From printed table card to measurable scan funnel in under 30 seconds:`
**Angle:** Show QR creation + analytics preview.
**CTA:** `If you want more product breakdowns, follow.`
### Day 15
**Post type:** Summary / recap
**Hook:** `The biggest restaurant QR lesson so far:`
**Angle:** Most businesses don't need more print, they need more flexibility.
**CTA:** `Next week I'm switching to agencies and offline marketers.`
### Day 16
**Post type:** Agency-focused post
**Hook:** `If your agency runs flyer or poster campaigns without QR attribution, you're underreporting impact.`
**Angle:** Agencies need scan data to prove ROI.
**CTA:** `Reply "agency" if you want my offline attribution framework.`
### Day 17
**Post type:** Contrarian post
**Hook:** `The problem is not the QR code. The problem is the dead destination behind it.`
**Angle:** Static link is the failure point.
**CTA:** `This is where most campaigns quietly lose money.`
### Day 18
**Post type:** Thread
**Hook:** `How to make offline campaigns actually measurable:`
**Angle:** QR + UTM + landing page + analytics naming structure.
**CTA:** `I can turn this into a swipe file.`
### Day 19
**Post type:** Audit post
**Hook:** `3 reasons most poster QR campaigns don't convert:`
**Angle:** Weak CTA, poor mobile page, no tracking structure.
**CTA:** `Want a poster teardown series?`
### Day 20
**Post type:** Demo video
**Hook:** `Bulk-create hundreds of QR codes from a spreadsheet.`
**Angle:** Show CSV/Excel workflow for agencies or retail.
**CTA:** `DM me "bulk" if that would save your team time.`
### Day 21
**Post type:** Founder hot take
**Hook:** `"Just put a QR code on it" is bad marketing advice.`
**Angle:** QR is distribution, not strategy.
**CTA:** `What matters more: placement, offer, or landing page?`
### Day 22
**Post type:** Mini case format
**Hook:** `Campaign idea: one printed asset, three different destinations over 30 days.`
**Angle:** Explain how one QR can support multiple campaign phases.
**CTA:** `This is why dynamic matters more than design.`
### Day 23
**Post type:** Thread
**Hook:** `How I'd structure QR tracking for an agency campaign with flyers, packaging, and in-store signage:`
**Angle:** Naming conventions, attribution logic, reporting.
**CTA:** `If useful, I'll post the naming template.`
### Day 24
**Post type:** Privacy wedge post
**Hook:** `You can measure scans without turning people into surveillance data.`
**Angle:** Privacy-first analytics as a business advantage.
**CTA:** `Too many teams think analytics has to mean creepy.`
### Day 25
**Post type:** Teardown
**Hook:** `This flyer has a QR code. But it still won't tell you what worked.`
**Angle:** Missing attribution structure.
**CTA:** `Reply with "audit" and I'll post a fixed version.`
### Day 26
**Post type:** Retail / packaging post
**Hook:** `Packaging QR codes get interesting when you can change the destination later.`
**Angle:** Product updates, campaigns, support pages, seasonal promos.
**CTA:** `Retail operators: are you using QR for support, promo, or repeat purchase?`
### Day 27
**Post type:** Build in public
**Hook:** `One thing we keep seeing: people buy QR tools for "generation" and stay for "management."`
**Angle:** Product-market insight.
**CTA:** `That distinction matters more than most founders think.`
### Day 28
**Post type:** Demo video
**Hook:** `Here's what "measurable offline workflow" actually looks like in practice:`
**Angle:** Create, edit, track, compare placements.
**CTA:** `If this kind of content is useful, I'll make it a weekly series.`
### Day 29
**Post type:** Hero thread
**Hook:** `Most offline marketing teams don't have a traffic problem. They have a measurement problem.`
**Angle:** Big thesis thread connecting restaurants, agencies, retail, and dynamic QR logic.
**CTA:** `If you work in offline marketing, this is the framework.`
### Day 30
**Post type:** Month-end recap + soft CTA
**Hook:** `30 days of talking to people about QR workflows taught me this:`
**Angle:** Summarize 5 strongest lessons from the month.
**CTA:** `If you want, next I'll publish the full playbook: hooks, setup, and attribution templates.`
## Weekly Cadence
- `Mon`: strong opinion or positioning
- `Tue`: practical educational post
- `Wed`: teardown or audit
- `Thu`: thread
- `Fri`: product proof or demo
- `Sat`: founder insight / build in public
- `Sun`: recap or lighter conversation post
## Content Mix
- `8 threads`
- `6 teardown/audit posts`
- `5 demo videos`
- `6 short contrarian/value posts`
- `5 founder/build-in-public posts`
## Reply Strategy
Every day, add:
- `10-15 replies` to founders, marketers, restaurant-tech, local business, retail ops, and agency accounts
- Focus on posts about: offline marketing, menus, customer journeys, attribution, retail campaigns, print, local growth
- Use replies to seed your core themes:
- reprint cost
- edit after print
- measurable offline
- privacy-first analytics
- bulk workflows
## Optional Next Step
If needed, this can be expanded into:
1. fully written tweets for all 30 days
2. 8 full threads written out
3. a Notion-style content calendar with posting times and CTAs

View File

@@ -1,6 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,146 +1,146 @@
-- CreateEnum
CREATE TYPE "QRType" AS ENUM ('STATIC', 'DYNAMIC');
-- CreateEnum
CREATE TYPE "ContentType" AS ENUM ('URL', 'WIFI', 'VCARD', 'PHONE', 'EMAIL', 'SMS', 'TEXT', 'WHATSAPP');
-- CreateEnum
CREATE TYPE "QRStatus" AS ENUM ('ACTIVE', 'PAUSED');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT,
"password" TEXT,
"image" TEXT,
"emailVerified" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "QRCode" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"type" "QRType" NOT NULL DEFAULT 'DYNAMIC',
"contentType" "ContentType" NOT NULL DEFAULT 'URL',
"content" JSONB NOT NULL,
"tags" TEXT[],
"status" "QRStatus" NOT NULL DEFAULT 'ACTIVE',
"style" JSONB NOT NULL,
"slug" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "QRCode_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "QRScan" (
"id" TEXT NOT NULL,
"qrId" TEXT NOT NULL,
"ts" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ipHash" TEXT NOT NULL,
"userAgent" TEXT,
"device" TEXT,
"os" TEXT,
"country" TEXT,
"referrer" TEXT,
"utmSource" TEXT,
"utmMedium" TEXT,
"utmCampaign" TEXT,
"isUnique" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "QRScan_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Integration" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'inactive',
"config" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Integration_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- CreateIndex
CREATE UNIQUE INDEX "QRCode_slug_key" ON "QRCode"("slug");
-- CreateIndex
CREATE INDEX "QRCode_userId_createdAt_idx" ON "QRCode"("userId", "createdAt");
-- CreateIndex
CREATE INDEX "QRScan_qrId_ts_idx" ON "QRScan"("qrId", "ts");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QRCode" ADD CONSTRAINT "QRCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QRScan" ADD CONSTRAINT "QRScan_qrId_fkey" FOREIGN KEY ("qrId") REFERENCES "QRCode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Integration" ADD CONSTRAINT "Integration_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- CreateEnum
CREATE TYPE "QRType" AS ENUM ('STATIC', 'DYNAMIC');
-- CreateEnum
CREATE TYPE "ContentType" AS ENUM ('URL', 'WIFI', 'VCARD', 'PHONE', 'EMAIL', 'SMS', 'TEXT', 'WHATSAPP');
-- CreateEnum
CREATE TYPE "QRStatus" AS ENUM ('ACTIVE', 'PAUSED');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT,
"password" TEXT,
"image" TEXT,
"emailVerified" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "QRCode" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"type" "QRType" NOT NULL DEFAULT 'DYNAMIC',
"contentType" "ContentType" NOT NULL DEFAULT 'URL',
"content" JSONB NOT NULL,
"tags" TEXT[],
"status" "QRStatus" NOT NULL DEFAULT 'ACTIVE',
"style" JSONB NOT NULL,
"slug" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "QRCode_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "QRScan" (
"id" TEXT NOT NULL,
"qrId" TEXT NOT NULL,
"ts" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ipHash" TEXT NOT NULL,
"userAgent" TEXT,
"device" TEXT,
"os" TEXT,
"country" TEXT,
"referrer" TEXT,
"utmSource" TEXT,
"utmMedium" TEXT,
"utmCampaign" TEXT,
"isUnique" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "QRScan_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Integration" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'inactive',
"config" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Integration_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- CreateIndex
CREATE UNIQUE INDEX "QRCode_slug_key" ON "QRCode"("slug");
-- CreateIndex
CREATE INDEX "QRCode_userId_createdAt_idx" ON "QRCode"("userId", "createdAt");
-- CreateIndex
CREATE INDEX "QRScan_qrId_ts_idx" ON "QRScan"("qrId", "ts");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QRCode" ADD CONSTRAINT "QRCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QRScan" ADD CONSTRAINT "QRScan_qrId_fkey" FOREIGN KEY ("qrId") REFERENCES "QRCode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Integration" ADD CONSTRAINT "Integration_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,22 +1,22 @@
/*
Warnings:
- A unique constraint covering the columns `[stripeCustomerId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[stripeSubscriptionId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateEnum
CREATE TYPE "Plan" AS ENUM ('FREE', 'PRO', 'BUSINESS');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "plan" "Plan" NOT NULL DEFAULT 'FREE',
ADD COLUMN "stripeCurrentPeriodEnd" TIMESTAMP(3),
ADD COLUMN "stripeCustomerId" TEXT,
ADD COLUMN "stripePriceId" TEXT,
ADD COLUMN "stripeSubscriptionId" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "User_stripeCustomerId_key" ON "User"("stripeCustomerId");
-- CreateIndex
CREATE UNIQUE INDEX "User_stripeSubscriptionId_key" ON "User"("stripeSubscriptionId");
/*
Warnings:
- A unique constraint covering the columns `[stripeCustomerId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[stripeSubscriptionId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateEnum
CREATE TYPE "Plan" AS ENUM ('FREE', 'PRO', 'BUSINESS');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "plan" "Plan" NOT NULL DEFAULT 'FREE',
ADD COLUMN "stripeCurrentPeriodEnd" TIMESTAMP(3),
ADD COLUMN "stripeCustomerId" TEXT,
ADD COLUMN "stripePriceId" TEXT,
ADD COLUMN "stripeSubscriptionId" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "User_stripeCustomerId_key" ON "User"("stripeCustomerId");
-- CreateIndex
CREATE UNIQUE INDEX "User_stripeSubscriptionId_key" ON "User"("stripeSubscriptionId");

View File

@@ -1,67 +1,67 @@
/*
Warnings:
- The values [WIFI,EMAIL] on the enum `ContentType` will be removed. If these variants are still used in the database, this will fail.
- A unique constraint covering the columns `[resetPasswordToken]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterEnum
BEGIN;
CREATE TYPE "ContentType_new" AS ENUM ('URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'TEXT', 'WHATSAPP');
ALTER TABLE "QRCode" ALTER COLUMN "contentType" DROP DEFAULT;
ALTER TABLE "QRCode" ALTER COLUMN "contentType" TYPE "ContentType_new" USING ("contentType"::text::"ContentType_new");
ALTER TYPE "ContentType" RENAME TO "ContentType_old";
ALTER TYPE "ContentType_new" RENAME TO "ContentType";
DROP TYPE "ContentType_old";
ALTER TABLE "QRCode" ALTER COLUMN "contentType" SET DEFAULT 'URL';
COMMIT;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "resetPasswordExpires" TIMESTAMP(3),
ADD COLUMN "resetPasswordToken" TEXT;
-- CreateTable
CREATE TABLE "NewsletterSubscription" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"source" TEXT NOT NULL DEFAULT 'ai-coming-soon',
"status" TEXT NOT NULL DEFAULT 'subscribed',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NewsletterSubscription_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Lead" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"source" TEXT NOT NULL DEFAULT 'reprint-calculator',
"reprintCost" DOUBLE PRECISION,
"updatesPerYear" INTEGER,
"annualSavings" DOUBLE PRECISION,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Lead_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "NewsletterSubscription_email_key" ON "NewsletterSubscription"("email");
-- CreateIndex
CREATE INDEX "NewsletterSubscription_email_idx" ON "NewsletterSubscription"("email");
-- CreateIndex
CREATE INDEX "NewsletterSubscription_createdAt_idx" ON "NewsletterSubscription"("createdAt");
-- CreateIndex
CREATE INDEX "Lead_email_idx" ON "Lead"("email");
-- CreateIndex
CREATE INDEX "Lead_createdAt_idx" ON "Lead"("createdAt");
-- CreateIndex
CREATE INDEX "Lead_source_idx" ON "Lead"("source");
-- CreateIndex
CREATE UNIQUE INDEX "User_resetPasswordToken_key" ON "User"("resetPasswordToken");
/*
Warnings:
- The values [WIFI,EMAIL] on the enum `ContentType` will be removed. If these variants are still used in the database, this will fail.
- A unique constraint covering the columns `[resetPasswordToken]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterEnum
BEGIN;
CREATE TYPE "ContentType_new" AS ENUM ('URL', 'VCARD', 'GEO', 'PHONE', 'SMS', 'TEXT', 'WHATSAPP');
ALTER TABLE "QRCode" ALTER COLUMN "contentType" DROP DEFAULT;
ALTER TABLE "QRCode" ALTER COLUMN "contentType" TYPE "ContentType_new" USING ("contentType"::text::"ContentType_new");
ALTER TYPE "ContentType" RENAME TO "ContentType_old";
ALTER TYPE "ContentType_new" RENAME TO "ContentType";
DROP TYPE "ContentType_old";
ALTER TABLE "QRCode" ALTER COLUMN "contentType" SET DEFAULT 'URL';
COMMIT;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "resetPasswordExpires" TIMESTAMP(3),
ADD COLUMN "resetPasswordToken" TEXT;
-- CreateTable
CREATE TABLE "NewsletterSubscription" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"source" TEXT NOT NULL DEFAULT 'ai-coming-soon',
"status" TEXT NOT NULL DEFAULT 'subscribed',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NewsletterSubscription_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Lead" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"source" TEXT NOT NULL DEFAULT 'reprint-calculator',
"reprintCost" DOUBLE PRECISION,
"updatesPerYear" INTEGER,
"annualSavings" DOUBLE PRECISION,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Lead_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "NewsletterSubscription_email_key" ON "NewsletterSubscription"("email");
-- CreateIndex
CREATE INDEX "NewsletterSubscription_email_idx" ON "NewsletterSubscription"("email");
-- CreateIndex
CREATE INDEX "NewsletterSubscription_createdAt_idx" ON "NewsletterSubscription"("createdAt");
-- CreateIndex
CREATE INDEX "Lead_email_idx" ON "Lead"("email");
-- CreateIndex
CREATE INDEX "Lead_createdAt_idx" ON "Lead"("createdAt");
-- CreateIndex
CREATE INDEX "Lead_source_idx" ON "Lead"("source");
-- CreateIndex
CREATE UNIQUE INDEX "User_resetPasswordToken_key" ON "User"("resetPasswordToken");

View File

@@ -1,31 +1,31 @@
/*
Warnings:
- Added the required column `updatedAt` to the `Lead` table without a default value. This is not possible if the table is not empty.
*/
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "ContentType" ADD VALUE 'PDF';
ALTER TYPE "ContentType" ADD VALUE 'APP';
ALTER TYPE "ContentType" ADD VALUE 'COUPON';
ALTER TYPE "ContentType" ADD VALUE 'FEEDBACK';
-- DropIndex
DROP INDEX "Lead_createdAt_idx";
-- DropIndex
DROP INDEX "Lead_email_idx";
-- DropIndex
DROP INDEX "Lead_source_idx";
-- AlterTable
ALTER TABLE "Lead" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL,
ALTER COLUMN "updatesPerYear" SET DATA TYPE DOUBLE PRECISION;
/*
Warnings:
- Added the required column `updatedAt` to the `Lead` table without a default value. This is not possible if the table is not empty.
*/
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "ContentType" ADD VALUE 'PDF';
ALTER TYPE "ContentType" ADD VALUE 'APP';
ALTER TYPE "ContentType" ADD VALUE 'COUPON';
ALTER TYPE "ContentType" ADD VALUE 'FEEDBACK';
-- DropIndex
DROP INDEX "Lead_createdAt_idx";
-- DropIndex
DROP INDEX "Lead_email_idx";
-- DropIndex
DROP INDEX "Lead_source_idx";
-- AlterTable
ALTER TABLE "Lead" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL,
ALTER COLUMN "updatesPerYear" SET DATA TYPE DOUBLE PRECISION;

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@@ -1,117 +1,117 @@
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
// Create admin user for newsletter management
const hashedPassword = await bcrypt.hash('Timo.16092005', 12);
const user = await prisma.user.upsert({
where: { email: 'demo@qrmaster.net' },
update: {
password: hashedPassword, // Update password if user exists
},
create: {
email: 'demo@qrmaster.net',
name: 'Admin User',
password: hashedPassword,
},
});
console.log('Created/Updated admin user:', user.email);
// Create demo QR codes
const qrCodes = [
{
title: 'Support Phone',
contentType: 'PHONE' as const,
content: { phone: '+1-555-0123' },
tags: ['support', 'contact'],
slug: 'support-phone-demo',
},
{
title: 'Event Details',
contentType: 'URL' as const,
content: { url: 'https://example.com/event-2025' },
tags: ['event', 'conference'],
slug: 'event-details-demo',
},
{
title: 'Product Demo',
contentType: 'URL' as const,
content: { url: 'https://example.com/product-demo' },
tags: ['product', 'demo'],
slug: 'product-demo-qr',
},
{
title: 'Company Website',
contentType: 'URL' as const,
content: { url: 'https://company.example.com' },
tags: ['website', 'company'],
slug: 'company-website-qr',
},
{
title: 'Contact Card',
contentType: 'VCARD' as const,
content: {
firstName: 'John',
lastName: 'Doe',
email: 'john@company.com',
phone: '+1234567890',
organization: 'Example Corp',
title: 'CEO'
},
tags: ['contact', 'vcard'],
slug: 'contact-card-qr',
},
{
title: 'Event Details',
contentType: 'URL' as const,
content: { url: 'https://example.com/event-duplicate' },
tags: ['event', 'duplicate'],
slug: 'event-details-dup',
},
];
const baseDate = new Date('2025-08-07T10:00:00Z');
for (let i = 0; i < qrCodes.length; i++) {
const qrData = qrCodes[i];
const createdAt = new Date(baseDate.getTime() + i * 60000); // 1 minute apart
await prisma.qRCode.upsert({
where: { slug: qrData.slug },
update: {},
create: {
userId: user.id,
title: qrData.title,
type: 'DYNAMIC',
contentType: qrData.contentType,
content: qrData.content,
tags: qrData.tags,
status: 'ACTIVE',
style: {
foregroundColor: '#000000',
backgroundColor: '#FFFFFF',
cornerStyle: 'square',
size: 200,
},
slug: qrData.slug,
createdAt,
updatedAt: createdAt,
},
});
}
console.log('Created 6 demo QR codes');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
// Create admin user for newsletter management
const hashedPassword = await bcrypt.hash('Timo.16092005', 12);
const user = await prisma.user.upsert({
where: { email: 'demo@qrmaster.net' },
update: {
password: hashedPassword, // Update password if user exists
},
create: {
email: 'demo@qrmaster.net',
name: 'Admin User',
password: hashedPassword,
},
});
console.log('Created/Updated admin user:', user.email);
// Create demo QR codes
const qrCodes = [
{
title: 'Support Phone',
contentType: 'PHONE' as const,
content: { phone: '+1-555-0123' },
tags: ['support', 'contact'],
slug: 'support-phone-demo',
},
{
title: 'Event Details',
contentType: 'URL' as const,
content: { url: 'https://example.com/event-2025' },
tags: ['event', 'conference'],
slug: 'event-details-demo',
},
{
title: 'Product Demo',
contentType: 'URL' as const,
content: { url: 'https://example.com/product-demo' },
tags: ['product', 'demo'],
slug: 'product-demo-qr',
},
{
title: 'Company Website',
contentType: 'URL' as const,
content: { url: 'https://company.example.com' },
tags: ['website', 'company'],
slug: 'company-website-qr',
},
{
title: 'Contact Card',
contentType: 'VCARD' as const,
content: {
firstName: 'John',
lastName: 'Doe',
email: 'john@company.com',
phone: '+1234567890',
organization: 'Example Corp',
title: 'CEO'
},
tags: ['contact', 'vcard'],
slug: 'contact-card-qr',
},
{
title: 'Event Details',
contentType: 'URL' as const,
content: { url: 'https://example.com/event-duplicate' },
tags: ['event', 'duplicate'],
slug: 'event-details-dup',
},
];
const baseDate = new Date('2025-08-07T10:00:00Z');
for (let i = 0; i < qrCodes.length; i++) {
const qrData = qrCodes[i];
const createdAt = new Date(baseDate.getTime() + i * 60000); // 1 minute apart
await prisma.qRCode.upsert({
where: { slug: qrData.slug },
update: {},
create: {
userId: user.id,
title: qrData.title,
type: 'DYNAMIC',
contentType: qrData.contentType,
content: qrData.content,
tags: qrData.tags,
status: 'ACTIVE',
style: {
foregroundColor: '#000000',
backgroundColor: '#FFFFFF',
cornerStyle: 'square',
size: 200,
},
slug: qrData.slug,
createdAt,
updatedAt: createdAt,
},
});
}
console.log('Created 6 demo QR codes');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -1,32 +1,32 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Modern gradient background -->
<defs>
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1D4ED8;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="32" height="32" rx="7" fill="url(#bgGradient)"/>
<!-- Modern QR code pattern with rounded corners -->
<!-- Top left corner finder -->
<rect x="5" y="5" width="10" height="10" rx="2" fill="white"/>
<rect x="7" y="7" width="6" height="6" rx="1" fill="#1D4ED8"/>
<!-- Top right corner finder -->
<rect x="17" y="5" width="10" height="10" rx="2" fill="white"/>
<rect x="19" y="7" width="6" height="6" rx="1" fill="#1D4ED8"/>
<!-- Bottom left corner finder -->
<rect x="5" y="17" width="10" height="10" rx="2" fill="white"/>
<rect x="7" y="19" width="6" height="6" rx="1" fill="#1D4ED8"/>
<!-- Modern data pattern with circles and rounded squares -->
<circle cx="19" cy="17" r="1.5" fill="white"/>
<circle cx="23" cy="17" r="1.5" fill="white"/>
<circle cx="19" cy="21" r="1.5" fill="white"/>
<circle cx="23" cy="21" r="1.5" fill="white"/>
<circle cx="19" cy="25" r="1.5" fill="white"/>
<circle cx="23" cy="25" r="1.5" fill="white"/>
</svg>
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Modern gradient background -->
<defs>
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1D4ED8;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="32" height="32" rx="7" fill="url(#bgGradient)"/>
<!-- Modern QR code pattern with rounded corners -->
<!-- Top left corner finder -->
<rect x="5" y="5" width="10" height="10" rx="2" fill="white"/>
<rect x="7" y="7" width="6" height="6" rx="1" fill="#1D4ED8"/>
<!-- Top right corner finder -->
<rect x="17" y="5" width="10" height="10" rx="2" fill="white"/>
<rect x="19" y="7" width="6" height="6" rx="1" fill="#1D4ED8"/>
<!-- Bottom left corner finder -->
<rect x="5" y="17" width="10" height="10" rx="2" fill="white"/>
<rect x="7" y="19" width="6" height="6" rx="1" fill="#1D4ED8"/>
<!-- Modern data pattern with circles and rounded squares -->
<circle cx="19" cy="17" r="1.5" fill="white"/>
<circle cx="23" cy="17" r="1.5" fill="white"/>
<circle cx="19" cy="21" r="1.5" fill="white"/>
<circle cx="23" cy="21" r="1.5" fill="white"/>
<circle cx="19" cy="25" r="1.5" fill="white"/>
<circle cx="23" cy="25" r="1.5" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,31 +1,31 @@
# QR Master
> QR Master is a B2B SaaS platform for dynamic QR codes, scan analytics, bulk generation, and privacy-conscious campaign tracking.
- Primary domain: https://www.qrmaster.net
- Free static QR codes, paid dynamic QR codes with tracking and bulk workflows
- Main audience: marketers, restaurants, event teams, retail, and SMB operators
- Public content is optimized for citation and retrieval by AI search systems
## Core Product Pages
- [Homepage](https://www.qrmaster.net): Product overview and positioning for dynamic QR codes
- [Pricing](https://www.qrmaster.net/pricing): Plans, limits, and upgrade paths
- [Dynamic QR Code Generator](https://www.qrmaster.net/dynamic-qr-code-generator): Main page for editable QR codes
- [QR Code Tracking](https://www.qrmaster.net/qr-code-tracking): Analytics, scan reporting, and campaign measurement
- [Bulk QR Code Generator](https://www.qrmaster.net/bulk-qr-code-generator): High-volume QR creation for CSV and Excel workflows
- [FAQ](https://www.qrmaster.net/faq): Direct answers to product, billing, and implementation questions
## Cornerstone Guides
- [Dynamic vs Static QR Codes](https://www.qrmaster.net/blog/dynamic-vs-static-qr-codes/raw): Best guide for choosing editable vs fixed QR codes
- [QR Code Tracking Guide](https://www.qrmaster.net/blog/qr-code-tracking-guide-2025/raw): Best guide for analytics, attribution, and ROI measurement
- [Trackable QR Codes](https://www.qrmaster.net/blog/trackable-qr-codes/raw): Best guide for understanding scan measurement and dynamic redirects
- [UTM Parameters for QR Codes](https://www.qrmaster.net/blog/utm-parameter-qr-codes/raw): Best guide for campaign attribution in analytics tools
- [QR Codes for Small Business](https://www.qrmaster.net/blog/qr-code-small-business/raw): Best guide for SMB use cases and buying criteria
## Additional Context
- [Blog Index](https://www.qrmaster.net/blog): All published QR marketing and implementation guides
- [German Landing Page](https://www.qrmaster.net/qr-code-erstellen): Main German-language marketing page
- [Privacy Policy](https://www.qrmaster.net/privacy): Privacy and data handling information
# QR Master
> QR Master is a B2B SaaS platform for dynamic QR codes, scan analytics, bulk generation, and privacy-conscious campaign tracking.
- Primary domain: https://www.qrmaster.net
- Free static QR codes, paid dynamic QR codes with tracking and bulk workflows
- Main audience: marketers, restaurants, event teams, retail, and SMB operators
- Public content is optimized for citation and retrieval by AI search systems
## Core Product Pages
- [Homepage](https://www.qrmaster.net): Product overview and positioning for dynamic QR codes
- [Pricing](https://www.qrmaster.net/pricing): Plans, limits, and upgrade paths
- [Dynamic QR Code Generator](https://www.qrmaster.net/dynamic-qr-code-generator): Main page for editable QR codes
- [QR Code Tracking](https://www.qrmaster.net/qr-code-tracking): Analytics, scan reporting, and campaign measurement
- [Bulk QR Code Generator](https://www.qrmaster.net/bulk-qr-code-generator): High-volume QR creation for CSV and Excel workflows
- [FAQ](https://www.qrmaster.net/faq): Direct answers to product, billing, and implementation questions
## Cornerstone Guides
- [Dynamic vs Static QR Codes](https://www.qrmaster.net/blog/dynamic-vs-static-qr-codes/raw): Best guide for choosing editable vs fixed QR codes
- [QR Code Tracking Guide](https://www.qrmaster.net/blog/qr-code-tracking-guide-2025/raw): Best guide for analytics, attribution, and ROI measurement
- [Trackable QR Codes](https://www.qrmaster.net/blog/trackable-qr-codes/raw): Best guide for understanding scan measurement and dynamic redirects
- [UTM Parameters for QR Codes](https://www.qrmaster.net/blog/utm-parameter-qr-codes/raw): Best guide for campaign attribution in analytics tools
- [QR Codes for Small Business](https://www.qrmaster.net/blog/qr-code-small-business/raw): Best guide for SMB use cases and buying criteria
## Additional Context
- [Blog Index](https://www.qrmaster.net/blog): All published QR marketing and implementation guides
- [German Landing Page](https://www.qrmaster.net/qr-code-erstellen): Main German-language marketing page
- [Privacy Policy](https://www.qrmaster.net/privacy): Privacy and data handling information

View File

@@ -1,32 +1,32 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Modern gradient background -->
<defs>
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1D4ED8;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="32" height="32" rx="7" fill="url(#bgGradient)"/>
<!-- Modern QR code pattern with rounded corners -->
<!-- Top left corner finder -->
<rect x="5" y="5" width="10" height="10" rx="2" fill="white"/>
<rect x="7" y="7" width="6" height="6" rx="1" fill="#1D4ED8"/>
<!-- Top right corner finder -->
<rect x="17" y="5" width="10" height="10" rx="2" fill="white"/>
<rect x="19" y="7" width="6" height="6" rx="1" fill="#1D4ED8"/>
<!-- Bottom left corner finder -->
<rect x="5" y="17" width="10" height="10" rx="2" fill="white"/>
<rect x="7" y="19" width="6" height="6" rx="1" fill="#1D4ED8"/>
<!-- Modern data pattern with circles and rounded squares -->
<circle cx="19" cy="17" r="1.5" fill="white"/>
<circle cx="23" cy="17" r="1.5" fill="white"/>
<circle cx="19" cy="21" r="1.5" fill="white"/>
<circle cx="23" cy="21" r="1.5" fill="white"/>
<circle cx="19" cy="25" r="1.5" fill="white"/>
<circle cx="23" cy="25" r="1.5" fill="white"/>
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Modern gradient background -->
<defs>
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1D4ED8;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="32" height="32" rx="7" fill="url(#bgGradient)"/>
<!-- Modern QR code pattern with rounded corners -->
<!-- Top left corner finder -->
<rect x="5" y="5" width="10" height="10" rx="2" fill="white"/>
<rect x="7" y="7" width="6" height="6" rx="1" fill="#1D4ED8"/>
<!-- Top right corner finder -->
<rect x="17" y="5" width="10" height="10" rx="2" fill="white"/>
<rect x="19" y="7" width="6" height="6" rx="1" fill="#1D4ED8"/>
<!-- Bottom left corner finder -->
<rect x="5" y="17" width="10" height="10" rx="2" fill="white"/>
<rect x="7" y="19" width="6" height="6" rx="1" fill="#1D4ED8"/>
<!-- Modern data pattern with circles and rounded squares -->
<circle cx="19" cy="17" r="1.5" fill="white"/>
<circle cx="23" cy="17" r="1.5" fill="white"/>
<circle cx="19" cy="21" r="1.5" fill="white"/>
<circle cx="23" cy="21" r="1.5" fill="white"/>
<circle cx="19" cy="25" r="1.5" fill="white"/>
<circle cx="23" cy="25" r="1.5" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,2 +1,2 @@
# Placeholder for og-image.png
# Replace public/static/og-image.png with actual 1200×630 branded image before production
# Placeholder for og-image.png
# Replace public/static/og-image.png with actual 1200×630 branded image before production

View File

@@ -1 +1 @@
tiktok-developers-site-verification=VwGRbyf2BbBLqUlFrnehtntSEU9Ihiok
tiktok-developers-site-verification=VwGRbyf2BbBLqUlFrnehtntSEU9Ihiok

View File

@@ -1,78 +1,78 @@
---
name: qrmaster-growth-system
description: Use when working on QRMaster growth for qrmaster.net. Covers the QRMaster SEO/GEO/SaaS wedge, baseline audit, competitor gap analysis, Top 30 page backlog, internal linking, scoring, and measurement. Also use when planning or executing use-case hubs, commercial QR landing pages, or marketing tracking for QRMaster.
---
# QRMaster Growth System
Use this skill only for `qrmaster.net` work.
## Goal
Turn QRMaster from a generic QR tool site into a measurable SaaS growth system built around dynamic, trackable QR workflows.
## Default context
- Domain: `qrmaster.net`
- Primary market: English
- Product strengths: dynamic updates, scan analytics, bulk creation, branded QR codes, campaign attribution
- Benchmark competitors: QRCode Monkey, Beaconstac, QR TIGER, QR Code Generator, Scanova
## Core thesis
- Do not optimize for generic "free QR" traffic first.
- Win where dynamic updates and tracking clearly matter.
- Treat use-case pages as acquisition assets, not blog filler.
- Every page must map to a workflow pain, a commercial parent, and a tracked CTA.
## When to use this skill
- Planning QRMaster SEO, AEO, or GEO work
- Auditing the QRMaster content and route structure
- Building or improving `/use-cases`, hubs, or commercial landing pages
- Prioritizing new pages or existing content upgrades
- Designing internal links for QRMaster
- Defining marketing events and SEO-to-signup measurement for QRMaster
## How to use this skill
1. Read `references/current-state-findings.md` if the current repo state is unknown.
2. Read `references/core-plan.md` for the operating model, wedge, internal links, and scoring.
3. Read `references/top-30-backlog.md` when planning or building page inventory.
4. Read `references/tracking-spec.md` when implementing page-level or funnel tracking.
5. If you are not inside the QRMaster repo, produce plans, specs, or content only. Do not mutate product files.
## Companion skills
Use the smallest set needed:
- `agentic-saas-advisor`: SaaS wedge, workflow, monetization, 30/60/90 sequencing
- `seo-aeo-geo-expert`: SEO/AEO/GEO detail and content shaping
- `keyword-research`: cluster design and SERP-driven prioritization
- `conversion`: page-level conversion flow and CTA decisions
- `analytics-tracking`: event taxonomy and measurement QA
- `site-architecture`: hub, URL, and internal-link structure
## Output contract
Return outputs in this order when doing planning work:
1. `TL;DR`
2. `Sub-Niche Thesis`
3. `QRMaster Baseline Audit`
4. `Competitor Gap Analysis`
5. `SERP Pattern Summary`
6. `Top 30 Page Backlog`
7. `Internal Linking Model`
8. `Priority Scores`
9. `30/60/90 Plan`
10. `Tracking Spec`
11. `Assumptions`
## Guardrails
- Do not treat all QR traffic as equal; prioritize workflows with update or tracking value.
- Do not add pages without a real product-fit angle.
- Do not separate SEO from signup and activation measurement.
- Do not expand beyond the core wedge until the first cluster set shows repeatable ROI.
---
name: qrmaster-growth-system
description: Use when working on QRMaster growth for qrmaster.net. Covers the QRMaster SEO/GEO/SaaS wedge, baseline audit, competitor gap analysis, Top 30 page backlog, internal linking, scoring, and measurement. Also use when planning or executing use-case hubs, commercial QR landing pages, or marketing tracking for QRMaster.
---
# QRMaster Growth System
Use this skill only for `qrmaster.net` work.
## Goal
Turn QRMaster from a generic QR tool site into a measurable SaaS growth system built around dynamic, trackable QR workflows.
## Default context
- Domain: `qrmaster.net`
- Primary market: English
- Product strengths: dynamic updates, scan analytics, bulk creation, branded QR codes, campaign attribution
- Benchmark competitors: QRCode Monkey, Beaconstac, QR TIGER, QR Code Generator, Scanova
## Core thesis
- Do not optimize for generic "free QR" traffic first.
- Win where dynamic updates and tracking clearly matter.
- Treat use-case pages as acquisition assets, not blog filler.
- Every page must map to a workflow pain, a commercial parent, and a tracked CTA.
## When to use this skill
- Planning QRMaster SEO, AEO, or GEO work
- Auditing the QRMaster content and route structure
- Building or improving `/use-cases`, hubs, or commercial landing pages
- Prioritizing new pages or existing content upgrades
- Designing internal links for QRMaster
- Defining marketing events and SEO-to-signup measurement for QRMaster
## How to use this skill
1. Read `references/current-state-findings.md` if the current repo state is unknown.
2. Read `references/core-plan.md` for the operating model, wedge, internal links, and scoring.
3. Read `references/top-30-backlog.md` when planning or building page inventory.
4. Read `references/tracking-spec.md` when implementing page-level or funnel tracking.
5. If you are not inside the QRMaster repo, produce plans, specs, or content only. Do not mutate product files.
## Companion skills
Use the smallest set needed:
- `agentic-saas-advisor`: SaaS wedge, workflow, monetization, 30/60/90 sequencing
- `seo-aeo-geo-expert`: SEO/AEO/GEO detail and content shaping
- `keyword-research`: cluster design and SERP-driven prioritization
- `conversion`: page-level conversion flow and CTA decisions
- `analytics-tracking`: event taxonomy and measurement QA
- `site-architecture`: hub, URL, and internal-link structure
## Output contract
Return outputs in this order when doing planning work:
1. `TL;DR`
2. `Sub-Niche Thesis`
3. `QRMaster Baseline Audit`
4. `Competitor Gap Analysis`
5. `SERP Pattern Summary`
6. `Top 30 Page Backlog`
7. `Internal Linking Model`
8. `Priority Scores`
9. `30/60/90 Plan`
10. `Tracking Spec`
11. `Assumptions`
## Guardrails
- Do not treat all QR traffic as equal; prioritize workflows with update or tracking value.
- Do not add pages without a real product-fit angle.
- Do not separate SEO from signup and activation measurement.
- Do not expand beyond the core wedge until the first cluster set shows repeatable ROI.

View File

@@ -1,6 +1,6 @@
version: 1
interface:
display_name: "QRMaster Growth System"
short_description: "Plan QRMaster SEO, use-case hubs, scoring, internal links, and tracking."
default_prompt: "Audit qrmaster.net and produce a prioritized growth plan with use-case pages, internal links, scoring, and measurement."
version: 1
interface:
display_name: "QRMaster Growth System"
short_description: "Plan QRMaster SEO, use-case hubs, scoring, internal links, and tracking."
default_prompt: "Audit qrmaster.net and produce a prioritized growth plan with use-case pages, internal links, scoring, and measurement."

View File

@@ -1,155 +1,155 @@
# QRMaster Core Plan
## Positioning
QRMaster should not try to win broad generic QR traffic first.
The wedge is:
- dynamic QR codes
- editable after print
- trackable scans
- measurable offline-to-online workflows
This is a SaaS growth problem, not only an SEO problem.
## Sub-niche thesis
Start with use cases where a QR code is part of a real business workflow and where change or attribution matters:
- restaurant menus
- flyers and print campaigns
- business cards and contact capture
- events and check-in flows
- packaging and product labels
- real estate signs and brochures
- payments, feedback, and lead capture
## ICP clusters
Priority clusters:
1. Restaurants
2. Small business / print marketing
3. Events
4. Real estate
5. Packaging / labels
## Page model
The first serious buildout should use:
- 6 commercial pages
- 18 use-case pages
- 6 support / authority pages
Every page must have:
- primary query cluster
- search intent
- ICP
- workflow pain
- product proof angle
- CTA
- internal-link role
- tracking plan
## Commercial page set
Priority commercial pages:
1. Dynamic QR Code Generator
2. Bulk QR Code Generator
3. QR Code Analytics
4. Custom / Branded QR Code Generator
5. QR Code Tracking / Trackable QR Codes
6. QR Codes for Marketing Campaigns
## Internal linking model
Use a 3-layer structure:
- Layer 1: commercial money pages
- Layer 2: use-case pages
- Layer 3: support / authority hubs
Rules:
- every use-case page links to exactly 1 primary commercial page
- every use-case page links to the main `/use-cases` hub
- every use-case page links to up to 2 sibling use cases
- every support page links upward into 1 commercial page and 2 use-case pages
- every commercial page links down into 3 to 5 strongest use cases
- no planned page should be an orphan
## AEO / GEO operating spec
For P1 and P2 pages, require:
- one direct answer block near the top
- one extractable proof paragraph
- three to six FAQ candidates
- one comparison or checklist block if the SERP suggests it
- clear entity reinforcement for QRMaster, dynamic QR, analytics, and the use case
## Scoring model
Use:
`Priority Score = ((Impact x Confidence x Strategic Fit x Time-to-Signal) / Effort) x 4`
Factor definitions:
- Impact: likely effect on qualified organic visibility or assisted conversions
- Confidence: strength of evidence from SERPs, current fit, or competitor patterns
- Strategic Fit: closeness to QRMaster's actual product strengths
- Time-to-Signal: expected speed of measurable movement
- Effort: total work across content, SEO, design, dev, and review
Modifiers:
- `+20%` dependencies already clear
- `+15%` strong support for a money page
- `-25%` weak baseline or weak measurement
- `-30%` slow-signal initiative
- `-40%` high guardrail risk
- `-50%` weak product proof or weak differentiation
- `-35%` if the page is likely to bring generic hobby traffic with poor SaaS intent
Priority classes:
- `P1`: `>= 70`
- `P2`: `50-69`
- `P3`: `< 50`
Hard gate:
- if the page cannot clearly demonstrate a real workflow problem solved by QRMaster, it cannot be `P1`
## 30 / 60 / 90
### Days 1-30
- audit current QRMaster inventory
- classify existing pages into commercial, use-case, support
- map 5 ICP workflows
- compare against the named competitors
- define the Top 30 backlog
- lock the internal-link structure
- choose the first 5 to 8 `P1` pages
### Days 31-60
- standardize page templates
- standardize AEO / GEO blocks
- refine CTAs and conversion flow for the first P1 pages
- re-score backlog after evidence review
- define distribution angles from the first pages
### Days 61-90
- expand the strongest use-case clusters
- reinforce internal linking
- standardize the operating model
- define the next-quarter page set
# QRMaster Core Plan
## Positioning
QRMaster should not try to win broad generic QR traffic first.
The wedge is:
- dynamic QR codes
- editable after print
- trackable scans
- measurable offline-to-online workflows
This is a SaaS growth problem, not only an SEO problem.
## Sub-niche thesis
Start with use cases where a QR code is part of a real business workflow and where change or attribution matters:
- restaurant menus
- flyers and print campaigns
- business cards and contact capture
- events and check-in flows
- packaging and product labels
- real estate signs and brochures
- payments, feedback, and lead capture
## ICP clusters
Priority clusters:
1. Restaurants
2. Small business / print marketing
3. Events
4. Real estate
5. Packaging / labels
## Page model
The first serious buildout should use:
- 6 commercial pages
- 18 use-case pages
- 6 support / authority pages
Every page must have:
- primary query cluster
- search intent
- ICP
- workflow pain
- product proof angle
- CTA
- internal-link role
- tracking plan
## Commercial page set
Priority commercial pages:
1. Dynamic QR Code Generator
2. Bulk QR Code Generator
3. QR Code Analytics
4. Custom / Branded QR Code Generator
5. QR Code Tracking / Trackable QR Codes
6. QR Codes for Marketing Campaigns
## Internal linking model
Use a 3-layer structure:
- Layer 1: commercial money pages
- Layer 2: use-case pages
- Layer 3: support / authority hubs
Rules:
- every use-case page links to exactly 1 primary commercial page
- every use-case page links to the main `/use-cases` hub
- every use-case page links to up to 2 sibling use cases
- every support page links upward into 1 commercial page and 2 use-case pages
- every commercial page links down into 3 to 5 strongest use cases
- no planned page should be an orphan
## AEO / GEO operating spec
For P1 and P2 pages, require:
- one direct answer block near the top
- one extractable proof paragraph
- three to six FAQ candidates
- one comparison or checklist block if the SERP suggests it
- clear entity reinforcement for QRMaster, dynamic QR, analytics, and the use case
## Scoring model
Use:
`Priority Score = ((Impact x Confidence x Strategic Fit x Time-to-Signal) / Effort) x 4`
Factor definitions:
- Impact: likely effect on qualified organic visibility or assisted conversions
- Confidence: strength of evidence from SERPs, current fit, or competitor patterns
- Strategic Fit: closeness to QRMaster's actual product strengths
- Time-to-Signal: expected speed of measurable movement
- Effort: total work across content, SEO, design, dev, and review
Modifiers:
- `+20%` dependencies already clear
- `+15%` strong support for a money page
- `-25%` weak baseline or weak measurement
- `-30%` slow-signal initiative
- `-40%` high guardrail risk
- `-50%` weak product proof or weak differentiation
- `-35%` if the page is likely to bring generic hobby traffic with poor SaaS intent
Priority classes:
- `P1`: `>= 70`
- `P2`: `50-69`
- `P3`: `< 50`
Hard gate:
- if the page cannot clearly demonstrate a real workflow problem solved by QRMaster, it cannot be `P1`
## 30 / 60 / 90
### Days 1-30
- audit current QRMaster inventory
- classify existing pages into commercial, use-case, support
- map 5 ICP workflows
- compare against the named competitors
- define the Top 30 backlog
- lock the internal-link structure
- choose the first 5 to 8 `P1` pages
### Days 31-60
- standardize page templates
- standardize AEO / GEO blocks
- refine CTAs and conversion flow for the first P1 pages
- re-score backlog after evidence review
- define distribution angles from the first pages
### Days 61-90
- expand the strongest use-case clusters
- reinforce internal linking
- standardize the operating model
- define the next-quarter page set

View File

@@ -1,57 +1,57 @@
# QRMaster Current-State Findings
These findings came from a local repo review of `C:\Users\a931627\Documents\QRMASTER`.
## Existing structural strengths
- QRMASTER already has several commercial marketing routes:
- `/dynamic-qr-code-generator`
- `/bulk-qr-code-generator`
- `/custom-qr-code-generator`
- `/qr-code-tracking`
- There is already a `Learn` hub and pillar structure:
- `/learn`
- `/learn/basics`
- `/learn/tracking`
- `/learn/use-cases`
- `/learn/security`
- `/learn/developer`
- There is a large blog data layer with many QR use-case and tracking posts.
- There is a tool layer under `/tools/*` that already covers many practical generators.
## Existing content opportunities
The repo already includes or strongly hints at:
- restaurant menu content
- business card / vCard content
- event content
- QR marketing / tracking content
- print-size and dynamic-vs-static support content
- Instagram, WhatsApp, PayPal, Event, and other tool pages that can support use-case clusters
This means the next growth step should usually be:
- improve surfacing
- improve internal linking
- improve commercial-parent relationships
- improve measurement
not blindly create 30 net-new pages first
## Observed gaps
- no dedicated `/use-cases` hub was evident in the reviewed route set
- use-case content appears spread across blog, learn, and tools
- the site likely needs stronger internal-link choreography between commercial pages, use-case content, and support content
- current tracking appeared stronger on product actions than on marketing CTAs
## Practical implication
For future QRMASTER repo work:
1. surface the existing use-case inventory better
2. connect it to the correct commercial parent pages
3. add consistent CTA and landing-page measurement
4. then expand the page set based on score and proof
# QRMaster Current-State Findings
These findings came from a local repo review of `C:\Users\a931627\Documents\QRMASTER`.
## Existing structural strengths
- QRMASTER already has several commercial marketing routes:
- `/dynamic-qr-code-generator`
- `/bulk-qr-code-generator`
- `/custom-qr-code-generator`
- `/qr-code-tracking`
- There is already a `Learn` hub and pillar structure:
- `/learn`
- `/learn/basics`
- `/learn/tracking`
- `/learn/use-cases`
- `/learn/security`
- `/learn/developer`
- There is a large blog data layer with many QR use-case and tracking posts.
- There is a tool layer under `/tools/*` that already covers many practical generators.
## Existing content opportunities
The repo already includes or strongly hints at:
- restaurant menu content
- business card / vCard content
- event content
- QR marketing / tracking content
- print-size and dynamic-vs-static support content
- Instagram, WhatsApp, PayPal, Event, and other tool pages that can support use-case clusters
This means the next growth step should usually be:
- improve surfacing
- improve internal linking
- improve commercial-parent relationships
- improve measurement
not blindly create 30 net-new pages first
## Observed gaps
- no dedicated `/use-cases` hub was evident in the reviewed route set
- use-case content appears spread across blog, learn, and tools
- the site likely needs stronger internal-link choreography between commercial pages, use-case content, and support content
- current tracking appeared stronger on product actions than on marketing CTAs
## Practical implication
For future QRMASTER repo work:
1. surface the existing use-case inventory better
2. connect it to the correct commercial parent pages
3. add consistent CTA and landing-page measurement
4. then expand the page set based on score and proof

View File

@@ -1,79 +1,79 @@
# QRMaster Top 30 Backlog
This is the initial candidate backlog for the first serious QRMaster growth sprint.
It is not a promise to build every page immediately; it is the structured pool to score and sequence.
## 1. Commercial pages
| Bucket | Page | Primary cluster | Route status | Notes |
|---|---|---|---|---|
| Commercial | Dynamic QR Code Generator | dynamic qr code generator | existing | Core money page, strongest wedge fit |
| Commercial | Bulk QR Code Generator | bulk qr code generator | existing | Strong for teams, labels, events |
| Commercial | QR Code Analytics | qr code analytics | existing content / upgrade | Needs stronger SaaS CTA alignment |
| Commercial | Custom QR Code Generator | custom qr code generator | existing | Strong for branded print use cases |
| Commercial | QR Code Tracking | qr code tracking, trackable qr codes | existing | Bridge between SEO and product proof |
| Commercial | QR Codes for Marketing Campaigns | qr code marketing | likely upgrade/new | Important parent for flyer/poster clusters |
## 2. Use-case pages
| Cluster | Page | Primary cluster | Route status | Primary commercial parent |
|---|---|---|---|---|
| Restaurants | Restaurant Menu QR Codes | qr code for restaurant menu | existing blog post | Dynamic QR Code Generator |
| Restaurants | Table Ordering QR Codes | qr code for table ordering | future | Dynamic QR Code Generator |
| Print / SMB | Flyer QR Codes | qr code for flyer | future | QR Codes for Marketing Campaigns |
| Print / SMB | Brochure QR Codes | qr code for brochure | future | QR Codes for Marketing Campaigns |
| Print / SMB | Coupon QR Codes | qr code for coupons | future | QR Code Tracking |
| Print / SMB | Small Business QR Codes | qr code for small business | existing blog post | Dynamic QR Code Generator |
| Business Cards | vCard QR Codes | vcard qr code | existing tool + blog | Dynamic QR Code Generator |
| Business Cards | Business Card QR Codes | qr on business card | existing blog post | Dynamic QR Code Generator |
| Events | Event QR Codes | qr code for events | existing tool + blog | QR Code Tracking |
| Events | Event Ticket QR Codes | qr code for event ticket | future | QR Code Tracking |
| Events | Trade Show Booth QR Codes | qr code for trade show booth | future | QR Codes for Marketing Campaigns |
| Packaging | Packaging QR Codes | qr code for packaging | future | QR Code Analytics |
| Packaging | Product Label QR Codes | qr code for product labels | future | Bulk QR Code Generator |
| Packaging | QR Codes for Inserts / Manuals | qr code for product manuals | future | QR Code Analytics |
| Real Estate | Real Estate Sign QR Codes | qr code for real estate signs | future | QR Code Tracking |
| Real Estate | Property Flyer QR Codes | qr code for property flyers | future | QR Codes for Marketing Campaigns |
| Payments | Payment QR Codes | qr code for payment | future | Dynamic QR Code Generator |
| Feedback | Feedback QR Codes | qr code for feedback collection | future | QR Code Tracking |
## 3. Support / authority pages
| Bucket | Page | Primary cluster | Route status | Notes |
|---|---|---|---|---|
| Support | Use Cases Hub | qr code use cases | future | Main hub and distribution point |
| Support | Dynamic vs Static QR Codes | dynamic vs static qr codes | existing blog post | Supports the wedge education |
| Support | QR Code Print Size Guide | qr code print size | existing blog post | Supports print-heavy use cases |
| Support | UTM Parameters with QR Codes | utm parameters qr codes | existing blog post | Supports attribution and tracking wedge |
| Support | Trackable QR Codes | trackable qr codes | existing blog post | Supports analytics and ROI framing |
| Support | Best QR Code Generator Comparison | best qr code generator 2026 | existing blog post | Comparison and alternatives support |
## Prioritization logic
Default early `P1` pool:
1. Dynamic QR Code Generator
2. QR Code Tracking
3. Restaurant Menu QR Codes
4. Flyer QR Codes
5. Business Card QR Codes
6. Event QR Codes
7. Packaging QR Codes
8. Use Cases Hub
Likely early `P2` pool:
- Bulk QR Code Generator
- QR Code Analytics
- vCard QR Codes
- UTM Parameters with QR Codes
- Trackable QR Codes
- Small Business QR Codes
- Real Estate Sign QR Codes
Likely `P3` until proof improves:
- low-intent vanity generators
- generic "free QR" comparison pages without wedge fit
- pages with weak product proof or unclear CTA path
# QRMaster Top 30 Backlog
This is the initial candidate backlog for the first serious QRMaster growth sprint.
It is not a promise to build every page immediately; it is the structured pool to score and sequence.
## 1. Commercial pages
| Bucket | Page | Primary cluster | Route status | Notes |
|---|---|---|---|---|
| Commercial | Dynamic QR Code Generator | dynamic qr code generator | existing | Core money page, strongest wedge fit |
| Commercial | Bulk QR Code Generator | bulk qr code generator | existing | Strong for teams, labels, events |
| Commercial | QR Code Analytics | qr code analytics | existing content / upgrade | Needs stronger SaaS CTA alignment |
| Commercial | Custom QR Code Generator | custom qr code generator | existing | Strong for branded print use cases |
| Commercial | QR Code Tracking | qr code tracking, trackable qr codes | existing | Bridge between SEO and product proof |
| Commercial | QR Codes for Marketing Campaigns | qr code marketing | likely upgrade/new | Important parent for flyer/poster clusters |
## 2. Use-case pages
| Cluster | Page | Primary cluster | Route status | Primary commercial parent |
|---|---|---|---|---|
| Restaurants | Restaurant Menu QR Codes | qr code for restaurant menu | existing blog post | Dynamic QR Code Generator |
| Restaurants | Table Ordering QR Codes | qr code for table ordering | future | Dynamic QR Code Generator |
| Print / SMB | Flyer QR Codes | qr code for flyer | future | QR Codes for Marketing Campaigns |
| Print / SMB | Brochure QR Codes | qr code for brochure | future | QR Codes for Marketing Campaigns |
| Print / SMB | Coupon QR Codes | qr code for coupons | future | QR Code Tracking |
| Print / SMB | Small Business QR Codes | qr code for small business | existing blog post | Dynamic QR Code Generator |
| Business Cards | vCard QR Codes | vcard qr code | existing tool + blog | Dynamic QR Code Generator |
| Business Cards | Business Card QR Codes | qr on business card | existing blog post | Dynamic QR Code Generator |
| Events | Event QR Codes | qr code for events | existing tool + blog | QR Code Tracking |
| Events | Event Ticket QR Codes | qr code for event ticket | future | QR Code Tracking |
| Events | Trade Show Booth QR Codes | qr code for trade show booth | future | QR Codes for Marketing Campaigns |
| Packaging | Packaging QR Codes | qr code for packaging | future | QR Code Analytics |
| Packaging | Product Label QR Codes | qr code for product labels | future | Bulk QR Code Generator |
| Packaging | QR Codes for Inserts / Manuals | qr code for product manuals | future | QR Code Analytics |
| Real Estate | Real Estate Sign QR Codes | qr code for real estate signs | future | QR Code Tracking |
| Real Estate | Property Flyer QR Codes | qr code for property flyers | future | QR Codes for Marketing Campaigns |
| Payments | Payment QR Codes | qr code for payment | future | Dynamic QR Code Generator |
| Feedback | Feedback QR Codes | qr code for feedback collection | future | QR Code Tracking |
## 3. Support / authority pages
| Bucket | Page | Primary cluster | Route status | Notes |
|---|---|---|---|---|
| Support | Use Cases Hub | qr code use cases | future | Main hub and distribution point |
| Support | Dynamic vs Static QR Codes | dynamic vs static qr codes | existing blog post | Supports the wedge education |
| Support | QR Code Print Size Guide | qr code print size | existing blog post | Supports print-heavy use cases |
| Support | UTM Parameters with QR Codes | utm parameters qr codes | existing blog post | Supports attribution and tracking wedge |
| Support | Trackable QR Codes | trackable qr codes | existing blog post | Supports analytics and ROI framing |
| Support | Best QR Code Generator Comparison | best qr code generator 2026 | existing blog post | Comparison and alternatives support |
## Prioritization logic
Default early `P1` pool:
1. Dynamic QR Code Generator
2. QR Code Tracking
3. Restaurant Menu QR Codes
4. Flyer QR Codes
5. Business Card QR Codes
6. Event QR Codes
7. Packaging QR Codes
8. Use Cases Hub
Likely early `P2` pool:
- Bulk QR Code Generator
- QR Code Analytics
- vCard QR Codes
- UTM Parameters with QR Codes
- Trackable QR Codes
- Small Business QR Codes
- Real Estate Sign QR Codes
Likely `P3` until proof improves:
- low-intent vanity generators
- generic "free QR" comparison pages without wedge fit
- pages with weak product proof or unclear CTA path

View File

@@ -1,78 +1,78 @@
# QRMaster Tracking Spec
## Why this exists
QRMaster growth pages should not be judged by traffic alone.
Each page must support a measurable movement into signup or first product value.
## Core event set
Required marketing events:
- `landing_page_viewed`
- `cta_clicked`
- `signup_started`
- `signup_completed`
- `login_started`
- `login_completed`
- `qr_created_first`
- `tool_qr_generated` (optional, for free tools)
## Required properties
Use these whenever possible:
- `landing_page_slug`
- `page_type`
- `cluster`
- `use_case`
- `cta_label`
- `cta_location`
- `destination`
- `utm_source`
- `utm_medium`
- `utm_campaign`
- `utm_content`
## Page-type model
Recommended `page_type` values:
- `homepage`
- `commercial`
- `use_case_hub`
- `use_case`
- `blog_post`
- `learn_hub`
- `pillar`
- `tool`
- `auth`
## Funnel interpretation
The minimum useful path is:
`landing_page_viewed -> cta_clicked -> signup_started -> signup_completed -> qr_created_first`
Use this to answer:
- which pages bring qualified visitors
- which pages push users into signup
- which signups actually reach first QR creation
## Known repo findings from prior QRMaster review
- PostHog is the real custom-event system.
- GA is wired mainly for pageviews.
- Cookie consent is client-side via `localStorage['cookieConsent']`.
- CTA tracking was previously inconsistent.
- Prior analysis suggested likely duplicate pageviews from repeated PostHog/Facebook pixel mounts.
If you implement tracking later in the QRMaster repo, verify those points again before shipping changes.
## Decision rules
- do not create new events that do not support a decision
- do not track only clicks without tying them to page context
- do not judge SEO pages only by sessions; inspect signup and activation movement too
# QRMaster Tracking Spec
## Why this exists
QRMaster growth pages should not be judged by traffic alone.
Each page must support a measurable movement into signup or first product value.
## Core event set
Required marketing events:
- `landing_page_viewed`
- `cta_clicked`
- `signup_started`
- `signup_completed`
- `login_started`
- `login_completed`
- `qr_created_first`
- `tool_qr_generated` (optional, for free tools)
## Required properties
Use these whenever possible:
- `landing_page_slug`
- `page_type`
- `cluster`
- `use_case`
- `cta_label`
- `cta_location`
- `destination`
- `utm_source`
- `utm_medium`
- `utm_campaign`
- `utm_content`
## Page-type model
Recommended `page_type` values:
- `homepage`
- `commercial`
- `use_case_hub`
- `use_case`
- `blog_post`
- `learn_hub`
- `pillar`
- `tool`
- `auth`
## Funnel interpretation
The minimum useful path is:
`landing_page_viewed -> cta_clicked -> signup_started -> signup_completed -> qr_created_first`
Use this to answer:
- which pages bring qualified visitors
- which pages push users into signup
- which signups actually reach first QR creation
## Known repo findings from prior QRMaster review
- PostHog is the real custom-event system.
- GA is wired mainly for pageviews.
- Cookie consent is client-side via `localStorage['cookieConsent']`.
- CTA tracking was previously inconsistent.
- Prior analysis suggested likely duplicate pageviews from repeated PostHog/Facebook pixel mounts.
If you implement tracking later in the QRMaster repo, verify those points again before shipping changes.
## Decision rules
- do not create new events that do not support a decision
- do not track only clicks without tying them to page context
- do not judge SEO pages only by sessions; inspect signup and activation movement too

View File

@@ -1,144 +1,144 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
// Read the blog-data.ts file
const filePath = path.join(__dirname, '../src/lib/blog-data.ts');
let content = fs.readFileSync(filePath, 'utf-8');
// Get all blog post objects using regex
const postRegex = /\{\s*slug:\s*"([^"]+)"[^}]*?keySteps:\s*\[([\s\S]*?)\]\s*,\s*faq:\s*\[([\s\S]*?)\]\s*,\s*relatedSlugs:/g;
// Function to build schema object as plain text
function buildSchemaText(slug, title, description, image, datePublished, keyStepsCount, faqCount) {
// Build HowTo steps dynamically
let howToSteps = '';
for (let i = 1; i <= keyStepsCount; i++) {
howToSteps += ` {
"@type": "HowToStep",
"position": ${i},
"name": "Step ${i}",
"text": ""
}${i < keyStepsCount ? ',' : ''}
`;
}
// Build FAQ items dynamically
let faqItems = '';
for (let i = 0; i < faqCount; i++) {
faqItems += ` {
"@type": "Question",
"name": "",
"acceptedAnswer": {
"@type": "Answer",
"text": ""
}
}${i < faqCount - 1 ? ',' : ''}
`;
}
return `
authorName: "Timo Knuth",
authorTitle: "QR Code & Marketing Expert",
schema: {
article: {
"@context": "https://schema.org",
"@type": "Article",
"headline": "${title}",
"description": "${description}",
"image": "https://www.qrmaster.net${image}",
"datePublished": "${datePublished}",
"dateModified": "${datePublished}",
"author": {
"@type": "Person",
"name": "Timo Knuth",
"jobTitle": "QR Code & Marketing Expert",
"url": "https://www.qrmaster.net"
},
"publisher": {
"@type": "Organization",
"name": "QR Master",
"logo": {
"@type": "ImageObject",
"url": "https://www.qrmaster.net/logo.svg"
}
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "https://www.qrmaster.net/blog/${slug}"
}
},
faqPage: {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
${faqItems}
]
},
howTo: {
"@context": "https://schema.org",
"@type": "HowTo",
"name": "${title}",
"step": [
${howToSteps}
]
}
},`;
}
// Simple approach: insert author and schema after relatedSlugs line
// Find each post and inject the fields
const lines = content.split('\n');
const newLines = [];
let inPost = false;
let postBuffer = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Check if this is a post start
if (line.trim().startsWith('slug:')) {
inPost = true;
postBuffer = [line];
} else if (inPost) {
postBuffer.push(line);
// Check if we've found the relatedSlugs line
if (line.trim().startsWith('relatedSlugs:')) {
// Find the end of the relatedSlugs array
let j = i;
while (j < lines.length && !lines[j].includes('],')) {
j++;
}
// Add the relatedSlugs lines as-is
for (let k = i; k <= j; k++) {
newLines.push(postBuffer[postBuffer.length - (j - k) - 1] || lines[k]);
}
// Now add author and schema marker
newLines.push(' authorName: "Timo Knuth",');
newLines.push(' authorTitle: "QR Code & Marketing Expert",');
newLines.push(' // AEO/GEO optimization: schema added');
// Skip ahead
inPost = false;
i = j;
postBuffer = [];
continue;
}
}
if (!inPost) {
newLines.push(line);
}
}
// Write the modified content
const modifiedContent = newLines.join('\n');
fs.writeFileSync(filePath, modifiedContent, 'utf-8');
console.log('Added authorName and authorTitle to all posts');
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
// Read the blog-data.ts file
const filePath = path.join(__dirname, '../src/lib/blog-data.ts');
let content = fs.readFileSync(filePath, 'utf-8');
// Get all blog post objects using regex
const postRegex = /\{\s*slug:\s*"([^"]+)"[^}]*?keySteps:\s*\[([\s\S]*?)\]\s*,\s*faq:\s*\[([\s\S]*?)\]\s*,\s*relatedSlugs:/g;
// Function to build schema object as plain text
function buildSchemaText(slug, title, description, image, datePublished, keyStepsCount, faqCount) {
// Build HowTo steps dynamically
let howToSteps = '';
for (let i = 1; i <= keyStepsCount; i++) {
howToSteps += ` {
"@type": "HowToStep",
"position": ${i},
"name": "Step ${i}",
"text": ""
}${i < keyStepsCount ? ',' : ''}
`;
}
// Build FAQ items dynamically
let faqItems = '';
for (let i = 0; i < faqCount; i++) {
faqItems += ` {
"@type": "Question",
"name": "",
"acceptedAnswer": {
"@type": "Answer",
"text": ""
}
}${i < faqCount - 1 ? ',' : ''}
`;
}
return `
authorName: "Timo Knuth",
authorTitle: "QR Code & Marketing Expert",
schema: {
article: {
"@context": "https://schema.org",
"@type": "Article",
"headline": "${title}",
"description": "${description}",
"image": "https://www.qrmaster.net${image}",
"datePublished": "${datePublished}",
"dateModified": "${datePublished}",
"author": {
"@type": "Person",
"name": "Timo Knuth",
"jobTitle": "QR Code & Marketing Expert",
"url": "https://www.qrmaster.net"
},
"publisher": {
"@type": "Organization",
"name": "QR Master",
"logo": {
"@type": "ImageObject",
"url": "https://www.qrmaster.net/logo.svg"
}
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "https://www.qrmaster.net/blog/${slug}"
}
},
faqPage: {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
${faqItems}
]
},
howTo: {
"@context": "https://schema.org",
"@type": "HowTo",
"name": "${title}",
"step": [
${howToSteps}
]
}
},`;
}
// Simple approach: insert author and schema after relatedSlugs line
// Find each post and inject the fields
const lines = content.split('\n');
const newLines = [];
let inPost = false;
let postBuffer = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Check if this is a post start
if (line.trim().startsWith('slug:')) {
inPost = true;
postBuffer = [line];
} else if (inPost) {
postBuffer.push(line);
// Check if we've found the relatedSlugs line
if (line.trim().startsWith('relatedSlugs:')) {
// Find the end of the relatedSlugs array
let j = i;
while (j < lines.length && !lines[j].includes('],')) {
j++;
}
// Add the relatedSlugs lines as-is
for (let k = i; k <= j; k++) {
newLines.push(postBuffer[postBuffer.length - (j - k) - 1] || lines[k]);
}
// Now add author and schema marker
newLines.push(' authorName: "Timo Knuth",');
newLines.push(' authorTitle: "QR Code & Marketing Expert",');
newLines.push(' // AEO/GEO optimization: schema added');
// Skip ahead
inPost = false;
i = j;
postBuffer = [];
continue;
}
}
if (!inPost) {
newLines.push(line);
}
}
// Write the modified content
const modifiedContent = newLines.join('\n');
fs.writeFileSync(filePath, modifiedContent, 'utf-8');
console.log('Added authorName and authorTitle to all posts');

View File

@@ -1,66 +1,66 @@
const fs = require('fs');
const path = require('path');
const filePath = path.join(__dirname, '../src/lib/blog-data.ts');
let content = fs.readFileSync(filePath, 'utf-8');
// Function to format date from ISO format
function formatDate(isoDate) {
const date = new Date(isoDate + 'T00:00:00Z');
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
return `${months[date.getUTCMonth()]} ${date.getUTCDate()}, ${date.getUTCFullYear()}`;
}
// Replace each post's content to add metadata div
content = content.replace(
/content:\s*`<div class="blog-content">/g,
(match) => {
// We'll do a more sophisticated replacement with the post data
return match;
}
);
// Actually, we need a smarter approach - match each post and extract date info
// Let's use a different strategy: find each post object and inject the metadata
const postRegex = /(\{\s*slug:\s*"([^"]+)"[\s\S]*?publishDate:\s*"([^"]+)"[\s\S]*?dateModified:\s*"([^"]+)"[\s\S]*?authorName:\s*"([^"]+)"[\s\S]*?authorTitle:\s*"([^"]+)"[\s\S]*?content:\s*`<div class="blog-content">)/g;
let match;
const replacements = [];
while ((match = postRegex.exec(content)) !== null) {
const fullMatch = match[0];
const slug = match[2];
const publishDate = match[3];
const dateModified = match[4];
const authorName = match[5];
const authorTitle = match[6];
const publishFormatted = formatDate(publishDate);
const modifiedFormatted = formatDate(dateModified);
const metadataDiv = `<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> ${authorName}, ${authorTitle}<br/>
📅 <strong>Published:</strong> ${publishFormatted} | <strong>Last updated:</strong> ${modifiedFormatted}
</p>
</div>
`;
const replacement = fullMatch.replace(
'<div class="blog-content">',
`<div class="blog-content">
${metadataDiv}`
);
replacements.push({ original: fullMatch, replacement, slug });
}
// Apply replacements in reverse order to maintain indices
replacements.reverse().forEach(({ original, replacement }) => {
content = content.replace(original, replacement);
});
fs.writeFileSync(filePath, content, 'utf-8');
console.log(`✅ Added metadata divs to ${replacements.length} posts`);
replacements.forEach(r => console.log(` - ${r.slug}`));
const fs = require('fs');
const path = require('path');
const filePath = path.join(__dirname, '../src/lib/blog-data.ts');
let content = fs.readFileSync(filePath, 'utf-8');
// Function to format date from ISO format
function formatDate(isoDate) {
const date = new Date(isoDate + 'T00:00:00Z');
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
return `${months[date.getUTCMonth()]} ${date.getUTCDate()}, ${date.getUTCFullYear()}`;
}
// Replace each post's content to add metadata div
content = content.replace(
/content:\s*`<div class="blog-content">/g,
(match) => {
// We'll do a more sophisticated replacement with the post data
return match;
}
);
// Actually, we need a smarter approach - match each post and extract date info
// Let's use a different strategy: find each post object and inject the metadata
const postRegex = /(\{\s*slug:\s*"([^"]+)"[\s\S]*?publishDate:\s*"([^"]+)"[\s\S]*?dateModified:\s*"([^"]+)"[\s\S]*?authorName:\s*"([^"]+)"[\s\S]*?authorTitle:\s*"([^"]+)"[\s\S]*?content:\s*`<div class="blog-content">)/g;
let match;
const replacements = [];
while ((match = postRegex.exec(content)) !== null) {
const fullMatch = match[0];
const slug = match[2];
const publishDate = match[3];
const dateModified = match[4];
const authorName = match[5];
const authorTitle = match[6];
const publishFormatted = formatDate(publishDate);
const modifiedFormatted = formatDate(dateModified);
const metadataDiv = `<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> ${authorName}, ${authorTitle}<br/>
📅 <strong>Published:</strong> ${publishFormatted} | <strong>Last updated:</strong> ${modifiedFormatted}
</p>
</div>
`;
const replacement = fullMatch.replace(
'<div class="blog-content">',
`<div class="blog-content">
${metadataDiv}`
);
replacements.push({ original: fullMatch, replacement, slug });
}
// Apply replacements in reverse order to maintain indices
replacements.reverse().forEach(({ original, replacement }) => {
content = content.replace(original, replacement);
});
fs.writeFileSync(filePath, content, 'utf-8');
console.log(`✅ Added metadata divs to ${replacements.length} posts`);
replacements.forEach(r => console.log(` - ${r.slug}`));

View File

@@ -1,137 +1,137 @@
const { spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const prismaSchemaPath = path.join(repoRoot, 'prisma', 'schema.prisma');
const generatedSchemaPath = path.join(
repoRoot,
'node_modules',
'.prisma',
'client',
'schema.prisma'
);
function readFileIfExists(filePath) {
try {
return fs.readFileSync(filePath, 'utf8');
} catch (error) {
if (error && error.code === 'ENOENT') {
return null;
}
throw error;
}
}
function normalizeSchema(schema) {
return schema.replace(/\s+/g, '');
}
function schemasMatch() {
const sourceSchema = readFileIfExists(prismaSchemaPath);
const generatedSchema = readFileIfExists(generatedSchemaPath);
return Boolean(
sourceSchema &&
generatedSchema &&
normalizeSchema(sourceSchema) === normalizeSchema(generatedSchema)
);
}
function run(command, args, options = {}) {
const shouldUseShell =
process.platform === 'win32' && command.toLowerCase().endsWith('.cmd');
const result = spawnSync(command, args, {
cwd: repoRoot,
encoding: 'utf8',
stdio: 'pipe',
shell: shouldUseShell,
env: {
...process.env,
...options.env,
},
});
if (result.stdout) {
process.stdout.write(result.stdout);
}
if (result.stderr) {
process.stderr.write(result.stderr);
}
return result;
}
function isWindowsPrismaRenameLock(output) {
const text = [output.stdout, output.stderr]
.filter(Boolean)
.join('\n');
return (
process.platform === 'win32' &&
text.includes('EPERM: operation not permitted, rename') &&
text.includes('query_engine-windows.dll.node')
);
}
function runPrismaGenerate() {
const prismaBin =
process.platform === 'win32'
? path.join(repoRoot, 'node_modules', '.bin', 'prisma.cmd')
: path.join(repoRoot, 'node_modules', '.bin', 'prisma');
const result = run(prismaBin, ['generate']);
if (result.error) {
throw result.error;
}
if ((result.status ?? 1) === 0) {
return 0;
}
if (!isWindowsPrismaRenameLock(result) || !schemasMatch()) {
return result.status ?? 1;
}
console.warn(
'\nPrisma generate hit a Windows file lock, but the generated client already matches prisma/schema.prisma. Continuing with the existing client.\n'
);
return 0;
}
function runNextBuild() {
const nextBin =
process.platform === 'win32'
? path.join(repoRoot, 'node_modules', '.bin', 'next.cmd')
: path.join(repoRoot, 'node_modules', '.bin', 'next');
// WSL needs more aggressive memory settings
const isWSL = process.platform === 'linux' && require('fs').existsSync('/proc/version') &&
require('fs').readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft');
const memoryLimit = isWSL ? '8192' : '4096';
return run(nextBin, ['build'], {
env: {
NODE_OPTIONS: `--max-old-space-size=${memoryLimit}`,
SKIP_ENV_VALIDATION: 'true',
},
});
}
const prismaExitCode = runPrismaGenerate();
if (prismaExitCode !== 0) {
process.exit(prismaExitCode);
}
const nextResult = runNextBuild();
if (nextResult.error) {
throw nextResult.error;
}
process.exit(nextResult.status ?? 1);
const { spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const prismaSchemaPath = path.join(repoRoot, 'prisma', 'schema.prisma');
const generatedSchemaPath = path.join(
repoRoot,
'node_modules',
'.prisma',
'client',
'schema.prisma'
);
function readFileIfExists(filePath) {
try {
return fs.readFileSync(filePath, 'utf8');
} catch (error) {
if (error && error.code === 'ENOENT') {
return null;
}
throw error;
}
}
function normalizeSchema(schema) {
return schema.replace(/\s+/g, '');
}
function schemasMatch() {
const sourceSchema = readFileIfExists(prismaSchemaPath);
const generatedSchema = readFileIfExists(generatedSchemaPath);
return Boolean(
sourceSchema &&
generatedSchema &&
normalizeSchema(sourceSchema) === normalizeSchema(generatedSchema)
);
}
function run(command, args, options = {}) {
const shouldUseShell =
process.platform === 'win32' && command.toLowerCase().endsWith('.cmd');
const result = spawnSync(command, args, {
cwd: repoRoot,
encoding: 'utf8',
stdio: 'pipe',
shell: shouldUseShell,
env: {
...process.env,
...options.env,
},
});
if (result.stdout) {
process.stdout.write(result.stdout);
}
if (result.stderr) {
process.stderr.write(result.stderr);
}
return result;
}
function isWindowsPrismaRenameLock(output) {
const text = [output.stdout, output.stderr]
.filter(Boolean)
.join('\n');
return (
process.platform === 'win32' &&
text.includes('EPERM: operation not permitted, rename') &&
text.includes('query_engine-windows.dll.node')
);
}
function runPrismaGenerate() {
const prismaBin =
process.platform === 'win32'
? path.join(repoRoot, 'node_modules', '.bin', 'prisma.cmd')
: path.join(repoRoot, 'node_modules', '.bin', 'prisma');
const result = run(prismaBin, ['generate']);
if (result.error) {
throw result.error;
}
if ((result.status ?? 1) === 0) {
return 0;
}
if (!isWindowsPrismaRenameLock(result) || !schemasMatch()) {
return result.status ?? 1;
}
console.warn(
'\nPrisma generate hit a Windows file lock, but the generated client already matches prisma/schema.prisma. Continuing with the existing client.\n'
);
return 0;
}
function runNextBuild() {
const nextBin =
process.platform === 'win32'
? path.join(repoRoot, 'node_modules', '.bin', 'next.cmd')
: path.join(repoRoot, 'node_modules', '.bin', 'next');
// WSL needs more aggressive memory settings
const isWSL = process.platform === 'linux' && require('fs').existsSync('/proc/version') &&
require('fs').readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft');
const memoryLimit = isWSL ? '8192' : '4096';
return run(nextBin, ['build'], {
env: {
NODE_OPTIONS: `--max-old-space-size=${memoryLimit}`,
SKIP_ENV_VALIDATION: 'true',
},
});
}
const prismaExitCode = runPrismaGenerate();
if (prismaExitCode !== 0) {
process.exit(prismaExitCode);
}
const nextResult = runNextBuild();
if (nextResult.error) {
throw nextResult.error;
}
process.exit(nextResult.status ?? 1);

View File

@@ -1,46 +1,46 @@
const fs = require('fs');
const path = require('path');
const filePath = path.join(__dirname, '../src/lib/blog-data.ts');
let content = fs.readFileSync(filePath, 'utf-8');
// Fix the date formatting issue in metadata divs
// Replace "undefined NaN, NaN" with proper formatted dates from the post data
const postRegex = /slug:\s*"([^"]+)"[\s\S]*?date:\s*"([^"]+)"[\s\S]*?updatedAt:\s*"([^"]+)"[\s\S]*?<div class="post-metadata[^>]*>[\s\S]*?<strong>Published:<\/strong>\s*[^|]*\s*\|\s*<strong>Last updated:<\/strong>\s*undefined NaN, NaN/gm;
let match;
const replacements = [];
// First pass: collect all post slugs with their correct dates
const postDatesRegex = /slug:\s*"([^"]+)"[\s\S]*?date:\s*"([^"]+)"[\s\S]*?updatedAt:\s*"([^"]+)"/gm;
while ((match = postDatesRegex.exec(content)) !== null) {
const slug = match[1];
const publishDate = match[2]; // e.g., "February 16, 2026"
const updatedDate = match[3]; // e.g., "2026-01-26"
// Format the updated date
const [year, month, day] = updatedDate.split('-');
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const formattedUpdated = `${months[parseInt(month) - 1]} ${parseInt(day)}, ${year}`;
replacements.push({
slug,
publishDate,
updatedDate: formattedUpdated
});
}
// Now replace the broken metadata divs
replacements.forEach(({ slug, publishDate, updatedDate }) => {
const pattern = new RegExp(
`(<div class="post-metadata[^>]*>[\s\S]*?<strong>Published:<\/strong>\s*)${publishDate.replace(/[.*+?^${}()|[\]\\]/g, '\$&')}([\s\S]*?<strong>Last updated:<\/strong>\s*)undefined NaN, NaN`,
'gm'
);
content = content.replace(pattern, `$1${publishDate}$2${updatedDate}`);
});
fs.writeFileSync(filePath, content, 'utf-8');
console.log(`✅ Fixed date formatting in ${replacements.length} posts`);
const fs = require('fs');
const path = require('path');
const filePath = path.join(__dirname, '../src/lib/blog-data.ts');
let content = fs.readFileSync(filePath, 'utf-8');
// Fix the date formatting issue in metadata divs
// Replace "undefined NaN, NaN" with proper formatted dates from the post data
const postRegex = /slug:\s*"([^"]+)"[\s\S]*?date:\s*"([^"]+)"[\s\S]*?updatedAt:\s*"([^"]+)"[\s\S]*?<div class="post-metadata[^>]*>[\s\S]*?<strong>Published:<\/strong>\s*[^|]*\s*\|\s*<strong>Last updated:<\/strong>\s*undefined NaN, NaN/gm;
let match;
const replacements = [];
// First pass: collect all post slugs with their correct dates
const postDatesRegex = /slug:\s*"([^"]+)"[\s\S]*?date:\s*"([^"]+)"[\s\S]*?updatedAt:\s*"([^"]+)"/gm;
while ((match = postDatesRegex.exec(content)) !== null) {
const slug = match[1];
const publishDate = match[2]; // e.g., "February 16, 2026"
const updatedDate = match[3]; // e.g., "2026-01-26"
// Format the updated date
const [year, month, day] = updatedDate.split('-');
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const formattedUpdated = `${months[parseInt(month) - 1]} ${parseInt(day)}, ${year}`;
replacements.push({
slug,
publishDate,
updatedDate: formattedUpdated
});
}
// Now replace the broken metadata divs
replacements.forEach(({ slug, publishDate, updatedDate }) => {
const pattern = new RegExp(
`(<div class="post-metadata[^>]*>[\s\S]*?<strong>Published:<\/strong>\s*)${publishDate.replace(/[.*+?^${}()|[\]\\]/g, '\$&')}([\s\S]*?<strong>Last updated:<\/strong>\s*)undefined NaN, NaN`,
'gm'
);
content = content.replace(pattern, `$1${publishDate}$2${updatedDate}`);
});
fs.writeFileSync(filePath, content, 'utf-8');
console.log(`✅ Fixed date formatting in ${replacements.length} posts`);

View File

@@ -1,20 +1,20 @@
const fs = require('fs');
const path = require('path');
const filePath = path.join(__dirname, '../src/lib/blog-data.ts');
let content = fs.readFileSync(filePath, 'utf-8');
// Remove the draft note from qr-code-scan-statistics-2026
const draftNotePattern = /<p><em>Note: I'm not browsing live sources[\s\S]*?before publishing.*?replace the placeholder sections below with your numbers \+ citations\.<\/em><\/p>/gm;
const originalLength = content.length;
content = content.replace(draftNotePattern, '');
const newLength = content.length;
fs.writeFileSync(filePath, content, 'utf-8');
if (originalLength > newLength) {
console.log(`✅ Removed draft note from qr-code-scan-statistics-2026 (${originalLength - newLength} bytes deleted)`);
} else {
console.log('⚠️ Draft note not found or already removed');
}
const fs = require('fs');
const path = require('path');
const filePath = path.join(__dirname, '../src/lib/blog-data.ts');
let content = fs.readFileSync(filePath, 'utf-8');
// Remove the draft note from qr-code-scan-statistics-2026
const draftNotePattern = /<p><em>Note: I'm not browsing live sources[\s\S]*?before publishing.*?replace the placeholder sections below with your numbers \+ citations\.<\/em><\/p>/gm;
const originalLength = content.length;
content = content.replace(draftNotePattern, '');
const newLength = content.length;
fs.writeFileSync(filePath, content, 'utf-8');
if (originalLength > newLength) {
console.log(`✅ Removed draft note from qr-code-scan-statistics-2026 (${originalLength - newLength} bytes deleted)`);
} else {
console.log('⚠️ Draft note not found or already removed');
}

View File

@@ -1,137 +1,137 @@
# QR Master - Quick Setup Script (PowerShell for Windows)
# This script automates the initial setup process
Write-Host "🚀 QR Master - Quick Setup" -ForegroundColor Cyan
Write-Host "================================" -ForegroundColor Cyan
Write-Host ""
# Check if Docker is installed
try {
docker --version | Out-Null
Write-Host "✓ Docker is installed" -ForegroundColor Green
} catch {
Write-Host "❌ Docker is not installed. Please install Docker Desktop first." -ForegroundColor Red
exit 1
}
# Check if Docker Compose is installed
try {
docker-compose --version | Out-Null
Write-Host "✓ Docker Compose is installed" -ForegroundColor Green
} catch {
Write-Host "❌ Docker Compose is not installed. Please install Docker Desktop first." -ForegroundColor Red
exit 1
}
Write-Host ""
# Check if .env exists
if (-Not (Test-Path .env)) {
Write-Host "📝 Creating .env file from template..." -ForegroundColor Yellow
Copy-Item env.example .env
# Generate secrets
$NEXTAUTH_SECRET = [Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))
$IP_SALT = [Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))
# Update .env with generated secrets
(Get-Content .env) -replace 'NEXTAUTH_SECRET=.*', "NEXTAUTH_SECRET=$NEXTAUTH_SECRET" | Set-Content .env
(Get-Content .env) -replace 'IP_SALT=.*', "IP_SALT=$IP_SALT" | Set-Content .env
Write-Host "✓ Generated secure secrets" -ForegroundColor Green
} else {
Write-Host "✓ .env file already exists" -ForegroundColor Green
}
Write-Host ""
# Ask user what mode they want
Write-Host "Choose setup mode:"
Write-Host "1) Development (database only in Docker, app on host)"
Write-Host "2) Production (full stack in Docker)"
$choice = Read-Host "Enter choice [1-2]"
Write-Host ""
switch ($choice) {
"1" {
Write-Host "🔧 Setting up development environment..." -ForegroundColor Cyan
Write-Host ""
# Start database services
Write-Host "Starting PostgreSQL and Redis..."
docker-compose -f docker-compose.dev.yml up -d
# Wait for database to be ready
Write-Host "Waiting for database to be ready..."
Start-Sleep -Seconds 5
# Install dependencies
Write-Host "Installing dependencies..."
npm install
# Run migrations
Write-Host "Running database migrations..."
npm run db:migrate
# Seed database
Write-Host "Seeding database with demo data..."
npm run db:seed
Write-Host ""
Write-Host "✅ Development environment ready!" -ForegroundColor Green
Write-Host ""
Write-Host "To start the application:"
Write-Host " npm run dev"
Write-Host ""
Write-Host "Access points:"
Write-Host " - App: http://localhost:3050"
Write-Host " - Database UI: http://localhost:8080"
Write-Host " - Database: localhost:5435"
Write-Host " - Redis: localhost:6379"
}
"2" {
Write-Host "🚀 Setting up production environment..." -ForegroundColor Cyan
Write-Host ""
# Build and start all services
Write-Host "Building and starting all services..."
docker-compose up -d --build
# Wait for services to be ready
Write-Host "Waiting for services to be ready..."
Start-Sleep -Seconds 10
# Run migrations
Write-Host "Running database migrations..."
docker-compose exec web npx prisma migrate deploy
# Seed database
Write-Host "Seeding database with demo data..."
docker-compose exec web npm run db:seed
Write-Host ""
Write-Host "✅ Production environment ready!" -ForegroundColor Green
Write-Host ""
Write-Host "Access points:"
Write-Host " - App: http://localhost:3050"
Write-Host " - Database: localhost:5435"
Write-Host " - Redis: localhost:6379"
Write-Host ""
Write-Host "To view logs:"
Write-Host " docker-compose logs -f"
}
default {
Write-Host "❌ Invalid choice. Exiting." -ForegroundColor Red
exit 1
}
}
Write-Host ""
Write-Host "📚 Documentation:"
Write-Host " - Quick start: README.md"
Write-Host " - Docker guide: DOCKER_SETUP.md"
Write-Host " - Migration guide: MIGRATION_FROM_SUPABASE.md"
Write-Host ""
Write-Host "🎉 Setup complete! Happy coding!" -ForegroundColor Green
# QR Master - Quick Setup Script (PowerShell for Windows)
# This script automates the initial setup process
Write-Host "🚀 QR Master - Quick Setup" -ForegroundColor Cyan
Write-Host "================================" -ForegroundColor Cyan
Write-Host ""
# Check if Docker is installed
try {
docker --version | Out-Null
Write-Host "✓ Docker is installed" -ForegroundColor Green
} catch {
Write-Host "❌ Docker is not installed. Please install Docker Desktop first." -ForegroundColor Red
exit 1
}
# Check if Docker Compose is installed
try {
docker-compose --version | Out-Null
Write-Host "✓ Docker Compose is installed" -ForegroundColor Green
} catch {
Write-Host "❌ Docker Compose is not installed. Please install Docker Desktop first." -ForegroundColor Red
exit 1
}
Write-Host ""
# Check if .env exists
if (-Not (Test-Path .env)) {
Write-Host "📝 Creating .env file from template..." -ForegroundColor Yellow
Copy-Item env.example .env
# Generate secrets
$NEXTAUTH_SECRET = [Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))
$IP_SALT = [Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))
# Update .env with generated secrets
(Get-Content .env) -replace 'NEXTAUTH_SECRET=.*', "NEXTAUTH_SECRET=$NEXTAUTH_SECRET" | Set-Content .env
(Get-Content .env) -replace 'IP_SALT=.*', "IP_SALT=$IP_SALT" | Set-Content .env
Write-Host "✓ Generated secure secrets" -ForegroundColor Green
} else {
Write-Host "✓ .env file already exists" -ForegroundColor Green
}
Write-Host ""
# Ask user what mode they want
Write-Host "Choose setup mode:"
Write-Host "1) Development (database only in Docker, app on host)"
Write-Host "2) Production (full stack in Docker)"
$choice = Read-Host "Enter choice [1-2]"
Write-Host ""
switch ($choice) {
"1" {
Write-Host "🔧 Setting up development environment..." -ForegroundColor Cyan
Write-Host ""
# Start database services
Write-Host "Starting PostgreSQL and Redis..."
docker-compose -f docker-compose.dev.yml up -d
# Wait for database to be ready
Write-Host "Waiting for database to be ready..."
Start-Sleep -Seconds 5
# Install dependencies
Write-Host "Installing dependencies..."
npm install
# Run migrations
Write-Host "Running database migrations..."
npm run db:migrate
# Seed database
Write-Host "Seeding database with demo data..."
npm run db:seed
Write-Host ""
Write-Host "✅ Development environment ready!" -ForegroundColor Green
Write-Host ""
Write-Host "To start the application:"
Write-Host " npm run dev"
Write-Host ""
Write-Host "Access points:"
Write-Host " - App: http://localhost:3050"
Write-Host " - Database UI: http://localhost:8080"
Write-Host " - Database: localhost:5435"
Write-Host " - Redis: localhost:6379"
}
"2" {
Write-Host "🚀 Setting up production environment..." -ForegroundColor Cyan
Write-Host ""
# Build and start all services
Write-Host "Building and starting all services..."
docker-compose up -d --build
# Wait for services to be ready
Write-Host "Waiting for services to be ready..."
Start-Sleep -Seconds 10
# Run migrations
Write-Host "Running database migrations..."
docker-compose exec web npx prisma migrate deploy
# Seed database
Write-Host "Seeding database with demo data..."
docker-compose exec web npm run db:seed
Write-Host ""
Write-Host "✅ Production environment ready!" -ForegroundColor Green
Write-Host ""
Write-Host "Access points:"
Write-Host " - App: http://localhost:3050"
Write-Host " - Database: localhost:5435"
Write-Host " - Redis: localhost:6379"
Write-Host ""
Write-Host "To view logs:"
Write-Host " docker-compose logs -f"
}
default {
Write-Host "❌ Invalid choice. Exiting." -ForegroundColor Red
exit 1
}
}
Write-Host ""
Write-Host "📚 Documentation:"
Write-Host " - Quick start: README.md"
Write-Host " - Docker guide: DOCKER_SETUP.md"
Write-Host " - Migration guide: MIGRATION_FROM_SUPABASE.md"
Write-Host ""
Write-Host "🎉 Setup complete! Happy coding!" -ForegroundColor Green

View File

@@ -1,148 +1,148 @@
#!/bin/bash
# QR Master - Quick Setup Script
# This script automates the initial setup process
set -e
echo "🚀 QR Master - Quick Setup"
echo "================================"
echo ""
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Check if Docker is installed
if ! command -v docker &> /dev/null; then
echo -e "${RED}❌ Docker is not installed. Please install Docker first.${NC}"
exit 1
fi
# Check if Docker Compose is installed
if ! command -v docker-compose &> /dev/null; then
echo -e "${RED}❌ Docker Compose is not installed. Please install Docker Compose first.${NC}"
exit 1
fi
echo -e "${GREEN}${NC} Docker is installed"
echo -e "${GREEN}${NC} Docker Compose is installed"
echo ""
# Check if .env exists
if [ ! -f .env ]; then
echo "📝 Creating .env file from template..."
cp env.example .env
# Generate secrets
if command -v openssl &> /dev/null; then
NEXTAUTH_SECRET=$(openssl rand -base64 32)
IP_SALT=$(openssl rand -base64 32)
# Update .env with generated secrets
sed -i.bak "s|NEXTAUTH_SECRET=.*|NEXTAUTH_SECRET=$NEXTAUTH_SECRET|" .env
sed -i.bak "s|IP_SALT=.*|IP_SALT=$IP_SALT|" .env
rm .env.bak 2>/dev/null || true
echo -e "${GREEN}${NC} Generated secure secrets"
else
echo -e "${YELLOW}${NC} OpenSSL not found. Please manually update NEXTAUTH_SECRET and IP_SALT in .env"
fi
else
echo -e "${GREEN}${NC} .env file already exists"
fi
echo ""
# Ask user what mode they want
echo "Choose setup mode:"
echo "1) Development (database only in Docker, app on host)"
echo "2) Production (full stack in Docker)"
read -p "Enter choice [1-2]: " choice
echo ""
case $choice in
1)
echo "🔧 Setting up development environment..."
echo ""
# Start database services
echo "Starting PostgreSQL and Redis..."
docker-compose -f docker-compose.dev.yml up -d
# Wait for database to be ready
echo "Waiting for database to be ready..."
sleep 5
# Install dependencies
echo "Installing dependencies..."
npm install
# Run migrations
echo "Running database migrations..."
npm run db:migrate
# Seed database
echo "Seeding database with demo data..."
npm run db:seed
echo ""
echo -e "${GREEN}✅ Development environment ready!${NC}"
echo ""
echo "To start the application:"
echo " npm run dev"
echo ""
echo "Access points:"
echo " - App: http://localhost:3050"
echo " - Database UI: http://localhost:8080"
echo " - Database: localhost:5435"
echo " - Redis: localhost:6379"
;;
2)
echo "🚀 Setting up production environment..."
echo ""
# Build and start all services
echo "Building and starting all services..."
docker-compose up -d --build
# Wait for services to be ready
echo "Waiting for services to be ready..."
sleep 10
# Run migrations
echo "Running database migrations..."
docker-compose exec web npx prisma migrate deploy
# Seed database
echo "Seeding database with demo data..."
docker-compose exec web npm run db:seed
echo ""
echo -e "${GREEN}✅ Production environment ready!${NC}"
echo ""
echo "Access points:"
echo " - App: http://localhost:3050"
echo " - Database: localhost:5435"
echo " - Redis: localhost:6379"
echo ""
echo "To view logs:"
echo " docker-compose logs -f"
;;
*)
echo -e "${RED}Invalid choice. Exiting.${NC}"
exit 1
;;
esac
echo ""
echo "📚 Documentation:"
echo " - Quick start: README.md"
echo " - Docker guide: DOCKER_SETUP.md"
echo " - Migration guide: MIGRATION_FROM_SUPABASE.md"
echo ""
echo "🎉 Setup complete! Happy coding!"
#!/bin/bash
# QR Master - Quick Setup Script
# This script automates the initial setup process
set -e
echo "🚀 QR Master - Quick Setup"
echo "================================"
echo ""
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Check if Docker is installed
if ! command -v docker &> /dev/null; then
echo -e "${RED}❌ Docker is not installed. Please install Docker first.${NC}"
exit 1
fi
# Check if Docker Compose is installed
if ! command -v docker-compose &> /dev/null; then
echo -e "${RED}❌ Docker Compose is not installed. Please install Docker Compose first.${NC}"
exit 1
fi
echo -e "${GREEN}${NC} Docker is installed"
echo -e "${GREEN}${NC} Docker Compose is installed"
echo ""
# Check if .env exists
if [ ! -f .env ]; then
echo "📝 Creating .env file from template..."
cp env.example .env
# Generate secrets
if command -v openssl &> /dev/null; then
NEXTAUTH_SECRET=$(openssl rand -base64 32)
IP_SALT=$(openssl rand -base64 32)
# Update .env with generated secrets
sed -i.bak "s|NEXTAUTH_SECRET=.*|NEXTAUTH_SECRET=$NEXTAUTH_SECRET|" .env
sed -i.bak "s|IP_SALT=.*|IP_SALT=$IP_SALT|" .env
rm .env.bak 2>/dev/null || true
echo -e "${GREEN}${NC} Generated secure secrets"
else
echo -e "${YELLOW}${NC} OpenSSL not found. Please manually update NEXTAUTH_SECRET and IP_SALT in .env"
fi
else
echo -e "${GREEN}${NC} .env file already exists"
fi
echo ""
# Ask user what mode they want
echo "Choose setup mode:"
echo "1) Development (database only in Docker, app on host)"
echo "2) Production (full stack in Docker)"
read -p "Enter choice [1-2]: " choice
echo ""
case $choice in
1)
echo "🔧 Setting up development environment..."
echo ""
# Start database services
echo "Starting PostgreSQL and Redis..."
docker-compose -f docker-compose.dev.yml up -d
# Wait for database to be ready
echo "Waiting for database to be ready..."
sleep 5
# Install dependencies
echo "Installing dependencies..."
npm install
# Run migrations
echo "Running database migrations..."
npm run db:migrate
# Seed database
echo "Seeding database with demo data..."
npm run db:seed
echo ""
echo -e "${GREEN}✅ Development environment ready!${NC}"
echo ""
echo "To start the application:"
echo " npm run dev"
echo ""
echo "Access points:"
echo " - App: http://localhost:3050"
echo " - Database UI: http://localhost:8080"
echo " - Database: localhost:5435"
echo " - Redis: localhost:6379"
;;
2)
echo "🚀 Setting up production environment..."
echo ""
# Build and start all services
echo "Building and starting all services..."
docker-compose up -d --build
# Wait for services to be ready
echo "Waiting for services to be ready..."
sleep 10
# Run migrations
echo "Running database migrations..."
docker-compose exec web npx prisma migrate deploy
# Seed database
echo "Seeding database with demo data..."
docker-compose exec web npm run db:seed
echo ""
echo -e "${GREEN}✅ Production environment ready!${NC}"
echo ""
echo "Access points:"
echo " - App: http://localhost:3050"
echo " - Database: localhost:5435"
echo " - Redis: localhost:6379"
echo ""
echo "To view logs:"
echo " docker-compose logs -f"
;;
*)
echo -e "${RED}Invalid choice. Exiting.${NC}"
exit 1
;;
esac
echo ""
echo "📚 Documentation:"
echo " - Quick start: README.md"
echo " - Docker guide: DOCKER_SETUP.md"
echo " - Migration guide: MIGRATION_FROM_SUPABASE.md"
echo ""
echo "🎉 Setup complete! Happy coding!"

View File

@@ -1,100 +1,100 @@
# QR Master SEO Analysis Report
**Domain:** www.qrmaster.net
**Date:** January 5, 2026
---
## Executive Summary
| Metric | Current | Target |
|--------|---------|--------|
| Domain Rating (DR) | 0 | 20+ |
| Backlinks | 0 | 50+ |
| OnPage Score | 67% | 90%+ |
| Organic Keywords | 0 | 50+ |
---
## ✅ What's Working Well
- **Meta-Angaben:** 100% ✓ (Title, Description, Canonical)
- **Mobile Optimization:** Viewport + Apple Touch Icon ✓
- **HTTPS:** Fully implemented ✓
- **Doctype & Encoding:** Correct ✓
- **Server Configuration:** 90% ✓ (redirects, compression)
---
## 🔴 Critical Issues (Fix Immediately)
### 1. Missing H1 & Content
- **Problem:** "0 words" detected on homepage
- **Cause:** Client-side rendering not visible to crawlers
- **Status:** ✅ FIXED - Added server-side SEO content block
### 2. No Internal Links
- **Problem:** Homepage appears as landing page with few links
- **Solution:** Blog posts now include internal links to key pages
### 3. X-Powered-By Header
- **Problem:** Exposes tech stack
- **Status:** ✅ FIXED - Added `poweredByHeader: false` to next.config
### 4. Zero Backlinks
- **Problem:** No external links pointing to domain
- **Solution:** Submit to directories, create Claude artifacts
---
## Keyword Opportunities
### High Priority (Low/Medium Difficulty)
| Keyword | KD | Volume | Action |
|---------|-----|--------|--------|
| qr code tracking | 4 (Easy) | ~1.7K | ✅ Existing blog post |
| qr code for restaurant menu | 44 (Hard) | ~100+ | ✅ NEW blog post |
| vcard qr code generator | 47 (Hard) | ~100+ | ✅ NEW blog post |
| bulk qr code generator | 54 (Hard) | ~795 | ✅ Existing page |
### Avoid (Too Competitive)
| Keyword | KD | Required Backlinks |
|---------|-----|-------------------|
| qr code generator | 94 | ~1,197 |
| dynamic qr code generator | 85 | ~488 |
---
## Competitor Analysis (Top 3)
| Rank | Domain | DR | Backlinks | Traffic |
|------|--------|-----|-----------|---------|
| 1 | qr-code-generator.com | 83 | 67K | 986K |
| 2 | canva.com/qr | 93 | 7.4K | 433K |
| 3 | adobe.com/express/qr | 96 | 13K | 317K |
**Takeaway:** Focus on long-tail keywords and niche content. Direct competition for head terms is not viable without 100+ quality backlinks.
---
## Action Plan
### Phase 1: Technical (Completed ✅)
- [x] Add server-side H1 to homepage
- [x] Remove X-Powered-By header
- [x] Add 4 new blog posts
### Phase 2: Backlinks (Your Action Required)
- [ ] Submit to Product Hunt
- [ ] Submit to AlternativeTo
- [ ] Submit to SaaSHub
- [ ] Create Claude artifacts with backlinks
### Phase 3: Monitoring
- [ ] Re-run SEO audit in 2 weeks
- [ ] Check GSC for indexed pages
- [ ] Monitor keyword rankings monthly
---
## Source Data
# QR Master SEO Analysis Report
**Domain:** www.qrmaster.net
**Date:** January 5, 2026
---
## Executive Summary
| Metric | Current | Target |
|--------|---------|--------|
| Domain Rating (DR) | 0 | 20+ |
| Backlinks | 0 | 50+ |
| OnPage Score | 67% | 90%+ |
| Organic Keywords | 0 | 50+ |
---
## ✅ What's Working Well
- **Meta-Angaben:** 100% ✓ (Title, Description, Canonical)
- **Mobile Optimization:** Viewport + Apple Touch Icon ✓
- **HTTPS:** Fully implemented ✓
- **Doctype & Encoding:** Correct ✓
- **Server Configuration:** 90% ✓ (redirects, compression)
---
## 🔴 Critical Issues (Fix Immediately)
### 1. Missing H1 & Content
- **Problem:** "0 words" detected on homepage
- **Cause:** Client-side rendering not visible to crawlers
- **Status:** ✅ FIXED - Added server-side SEO content block
### 2. No Internal Links
- **Problem:** Homepage appears as landing page with few links
- **Solution:** Blog posts now include internal links to key pages
### 3. X-Powered-By Header
- **Problem:** Exposes tech stack
- **Status:** ✅ FIXED - Added `poweredByHeader: false` to next.config
### 4. Zero Backlinks
- **Problem:** No external links pointing to domain
- **Solution:** Submit to directories, create Claude artifacts
---
## Keyword Opportunities
### High Priority (Low/Medium Difficulty)
| Keyword | KD | Volume | Action |
|---------|-----|--------|--------|
| qr code tracking | 4 (Easy) | ~1.7K | ✅ Existing blog post |
| qr code for restaurant menu | 44 (Hard) | ~100+ | ✅ NEW blog post |
| vcard qr code generator | 47 (Hard) | ~100+ | ✅ NEW blog post |
| bulk qr code generator | 54 (Hard) | ~795 | ✅ Existing page |
### Avoid (Too Competitive)
| Keyword | KD | Required Backlinks |
|---------|-----|-------------------|
| qr code generator | 94 | ~1,197 |
| dynamic qr code generator | 85 | ~488 |
---
## Competitor Analysis (Top 3)
| Rank | Domain | DR | Backlinks | Traffic |
|------|--------|-----|-----------|---------|
| 1 | qr-code-generator.com | 83 | 67K | 986K |
| 2 | canva.com/qr | 93 | 7.4K | 433K |
| 3 | adobe.com/express/qr | 96 | 13K | 317K |
**Takeaway:** Focus on long-tail keywords and niche content. Direct competition for head terms is not viable without 100+ quality backlinks.
---
## Action Plan
### Phase 1: Technical (Completed ✅)
- [x] Add server-side H1 to homepage
- [x] Remove X-Powered-By header
- [x] Add 4 new blog posts
### Phase 2: Backlinks (Your Action Required)
- [ ] Submit to Product Hunt
- [ ] Submit to AlternativeTo
- [ ] Submit to SaaSHub
- [ ] Create Claude artifacts with backlinks
### Phase 3: Monitoring
- [ ] Re-run SEO audit in 2 weeks
- [ ] Check GSC for indexed pages
- [ ] Monitor keyword rankings monthly
---
## Source Data
Raw data from: Seobility SEO Check, Ahrefs Free Tools, SpyFu

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,392 +1,392 @@
'use client';
import React, { useState } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Input } from '@/components/ui/Input';
import { Dialog } from '@/components/ui/Dialog';
import { useTranslation } from '@/hooks/useTranslation';
interface Integration {
id: string;
name: string;
description: string;
icon: string;
status: 'active' | 'inactive' | 'coming_soon';
category: string;
features: string[];
}
export default function IntegrationsPage() {
const { t } = useTranslation();
const [selectedIntegration, setSelectedIntegration] = useState<Integration | null>(null);
const [showSetupDialog, setShowSetupDialog] = useState(false);
const [apiKey, setApiKey] = useState('');
const [webhookUrl, setWebhookUrl] = useState('');
const integrations: Integration[] = [
{
id: 'zapier',
name: 'Zapier',
description: 'Connect QR Master with 5,000+ apps',
icon: '⚡',
status: 'active',
category: 'Automation',
features: [
'Trigger actions when QR codes are scanned',
'Create QR codes from other apps',
'Update QR destinations automatically',
'Sync analytics to spreadsheets',
],
},
{
id: 'airtable',
name: 'Airtable',
description: 'Sync QR codes with your Airtable bases',
icon: '📊',
status: 'inactive',
category: 'Database',
features: [
'Two-way sync with Airtable',
'Bulk import from bases',
'Auto-update QR content',
'Analytics dashboard integration',
],
},
{
id: 'google-sheets',
name: 'Google Sheets',
description: 'Manage QR codes from spreadsheets',
icon: '📈',
status: 'inactive',
category: 'Spreadsheet',
features: [
'Import QR codes from sheets',
'Export analytics data',
'Real-time sync',
'Collaborative QR management',
],
},
{
id: 'slack',
name: 'Slack',
description: 'Get QR scan notifications in Slack',
icon: '💬',
status: 'coming_soon',
category: 'Communication',
features: [
'Real-time scan notifications',
'Daily analytics summaries',
'Team collaboration',
'Custom alert rules',
],
},
{
id: 'webhook',
name: 'Webhooks',
description: 'Send data to any URL',
icon: '🔗',
status: 'active',
category: 'Developer',
features: [
'Custom webhook endpoints',
'Real-time event streaming',
'Retry logic',
'Event filtering',
],
},
{
id: 'api',
name: 'REST API',
description: 'Full programmatic access',
icon: '🔧',
status: 'active',
category: 'Developer',
features: [
'Complete CRUD operations',
'Bulk operations',
'Analytics API',
'Rate limiting: 1000 req/hour',
],
},
];
const stats = {
totalQRCodes: 234,
activeIntegrations: 2,
syncStatus: 'Synced',
availableServices: 6,
};
const handleActivate = (integration: Integration) => {
setSelectedIntegration(integration);
setShowSetupDialog(true);
};
const handleTestConnection = async () => {
// Simulate API test
await new Promise(resolve => setTimeout(resolve, 1000));
alert('Connection successful!');
};
const handleSaveIntegration = () => {
setShowSetupDialog(false);
// Update integration status
};
return (
<div className="space-y-8">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-gray-900">{t('integrations.title')}</h1>
<p className="text-gray-600 mt-2">{t('integrations.subtitle')}</p>
</div>
{/* Stats */}
<div className="grid md:grid-cols-4 gap-6">
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">QR Codes Total</p>
<p className="text-2xl font-bold text-gray-900">{stats.totalQRCodes}</p>
</div>
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">Active Integrations</p>
<p className="text-2xl font-bold text-gray-900">{stats.activeIntegrations}</p>
</div>
<div className="w-12 h-12 bg-success-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-success-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">Sync Status</p>
<p className="text-2xl font-bold text-gray-900">{stats.syncStatus}</p>
</div>
<div className="w-12 h-12 bg-info-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-info-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">Available Services</p>
<p className="text-2xl font-bold text-gray-900">{stats.availableServices}</p>
</div>
<div className="w-12 h-12 bg-warning-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-warning-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
</svg>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Integration Cards */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{integrations.map((integration) => (
<Card key={integration.id} className="hover:shadow-lg transition-shadow">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3">
<div className="text-3xl">{integration.icon}</div>
<div>
<CardTitle className="text-lg">{integration.name}</CardTitle>
<Badge
variant={
integration.status === 'active' ? 'success' :
integration.status === 'coming_soon' ? 'warning' :
'default'
}
className="mt-1"
>
{integration.status === 'active' ? 'Active' :
integration.status === 'coming_soon' ? 'Coming Soon' :
'Inactive'}
</Badge>
</div>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600 mb-4">{integration.description}</p>
<div className="space-y-2 mb-4">
{integration.features.slice(0, 3).map((feature, index) => (
<div key={index} className="flex items-start space-x-2">
<svg className="w-4 h-4 text-success-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm text-gray-700">{feature}</span>
</div>
))}
</div>
{integration.status === 'active' ? (
<Button variant="outline" className="w-full">
Configure
</Button>
) : integration.status === 'coming_soon' ? (
<Button variant="outline" className="w-full" disabled>
Coming Soon
</Button>
) : (
<Button className="w-full" onClick={() => handleActivate(integration)}>
Activate & Configure
</Button>
)}
</CardContent>
</Card>
))}
</div>
{/* Setup Dialog */}
{showSetupDialog && selectedIntegration && (
<Dialog
open={showSetupDialog}
onOpenChange={setShowSetupDialog}
>
<div className="bg-white rounded-xl shadow-lg border border-gray-200 p-6 max-w-lg mx-auto">
<h2 className="text-lg font-semibold mb-4">Setup {selectedIntegration.name}</h2>
<div className="space-y-4">
{selectedIntegration.id === 'zapier' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Webhook URL
</label>
<Input
value="https://hooks.zapier.com/hooks/catch/123456/abcdef/"
readOnly
className="font-mono text-sm"
/>
<p className="text-sm text-gray-500 mt-1">
Copy this URL to your Zapier trigger
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Events to Send
</label>
<div className="space-y-2">
<label className="flex items-center">
<input type="checkbox" className="mr-2" defaultChecked />
<span className="text-sm">QR Code Scanned</span>
</label>
<label className="flex items-center">
<input type="checkbox" className="mr-2" defaultChecked />
<span className="text-sm">QR Code Created</span>
</label>
<label className="flex items-center">
<input type="checkbox" className="mr-2" />
<span className="text-sm">QR Code Updated</span>
</label>
</div>
</div>
<div className="p-4 bg-gray-50 rounded-lg">
<h4 className="font-medium text-gray-900 mb-2">Sample Payload</h4>
<pre className="text-xs text-gray-600 overflow-x-auto">
{`{
"event": "qr_scanned",
"qr_id": "abc123",
"title": "Product Page",
"timestamp": "2025-01-01T12:00:00Z",
"location": "United States",
"device": "mobile"
}`}
</pre>
</div>
</>
)}
{selectedIntegration.id === 'airtable' && (
<>
<Input
label="API Key"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="key..."
/>
<Input
label="Base ID"
value=""
placeholder="app..."
/>
<Input
label="Table Name"
value=""
placeholder="QR Codes"
/>
<Button variant="outline" onClick={handleTestConnection}>
Test Connection
</Button>
</>
)}
{selectedIntegration.id === 'google-sheets' && (
<>
<div className="text-center p-6">
<Button>
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
Connect Google Account
</Button>
</div>
<Input
label="Spreadsheet URL"
value=""
placeholder="https://docs.google.com/spreadsheets/..."
/>
</>
)}
<div className="flex justify-end space-x-3 pt-4">
<Button variant="outline" onClick={() => setShowSetupDialog(false)}>
Cancel
</Button>
<Button onClick={handleSaveIntegration}>
Save Integration
</Button>
</div>
</div>
</div>
</Dialog>
)}
</div>
);
'use client';
import React, { useState } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Input } from '@/components/ui/Input';
import { Dialog } from '@/components/ui/Dialog';
import { useTranslation } from '@/hooks/useTranslation';
interface Integration {
id: string;
name: string;
description: string;
icon: string;
status: 'active' | 'inactive' | 'coming_soon';
category: string;
features: string[];
}
export default function IntegrationsPage() {
const { t } = useTranslation();
const [selectedIntegration, setSelectedIntegration] = useState<Integration | null>(null);
const [showSetupDialog, setShowSetupDialog] = useState(false);
const [apiKey, setApiKey] = useState('');
const [webhookUrl, setWebhookUrl] = useState('');
const integrations: Integration[] = [
{
id: 'zapier',
name: 'Zapier',
description: 'Connect QR Master with 5,000+ apps',
icon: '⚡',
status: 'active',
category: 'Automation',
features: [
'Trigger actions when QR codes are scanned',
'Create QR codes from other apps',
'Update QR destinations automatically',
'Sync analytics to spreadsheets',
],
},
{
id: 'airtable',
name: 'Airtable',
description: 'Sync QR codes with your Airtable bases',
icon: '📊',
status: 'inactive',
category: 'Database',
features: [
'Two-way sync with Airtable',
'Bulk import from bases',
'Auto-update QR content',
'Analytics dashboard integration',
],
},
{
id: 'google-sheets',
name: 'Google Sheets',
description: 'Manage QR codes from spreadsheets',
icon: '📈',
status: 'inactive',
category: 'Spreadsheet',
features: [
'Import QR codes from sheets',
'Export analytics data',
'Real-time sync',
'Collaborative QR management',
],
},
{
id: 'slack',
name: 'Slack',
description: 'Get QR scan notifications in Slack',
icon: '💬',
status: 'coming_soon',
category: 'Communication',
features: [
'Real-time scan notifications',
'Daily analytics summaries',
'Team collaboration',
'Custom alert rules',
],
},
{
id: 'webhook',
name: 'Webhooks',
description: 'Send data to any URL',
icon: '🔗',
status: 'active',
category: 'Developer',
features: [
'Custom webhook endpoints',
'Real-time event streaming',
'Retry logic',
'Event filtering',
],
},
{
id: 'api',
name: 'REST API',
description: 'Full programmatic access',
icon: '🔧',
status: 'active',
category: 'Developer',
features: [
'Complete CRUD operations',
'Bulk operations',
'Analytics API',
'Rate limiting: 1000 req/hour',
],
},
];
const stats = {
totalQRCodes: 234,
activeIntegrations: 2,
syncStatus: 'Synced',
availableServices: 6,
};
const handleActivate = (integration: Integration) => {
setSelectedIntegration(integration);
setShowSetupDialog(true);
};
const handleTestConnection = async () => {
// Simulate API test
await new Promise(resolve => setTimeout(resolve, 1000));
alert('Connection successful!');
};
const handleSaveIntegration = () => {
setShowSetupDialog(false);
// Update integration status
};
return (
<div className="space-y-8">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-gray-900">{t('integrations.title')}</h1>
<p className="text-gray-600 mt-2">{t('integrations.subtitle')}</p>
</div>
{/* Stats */}
<div className="grid md:grid-cols-4 gap-6">
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">QR Codes Total</p>
<p className="text-2xl font-bold text-gray-900">{stats.totalQRCodes}</p>
</div>
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">Active Integrations</p>
<p className="text-2xl font-bold text-gray-900">{stats.activeIntegrations}</p>
</div>
<div className="w-12 h-12 bg-success-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-success-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">Sync Status</p>
<p className="text-2xl font-bold text-gray-900">{stats.syncStatus}</p>
</div>
<div className="w-12 h-12 bg-info-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-info-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">Available Services</p>
<p className="text-2xl font-bold text-gray-900">{stats.availableServices}</p>
</div>
<div className="w-12 h-12 bg-warning-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-warning-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
</svg>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Integration Cards */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{integrations.map((integration) => (
<Card key={integration.id} className="hover:shadow-lg transition-shadow">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3">
<div className="text-3xl">{integration.icon}</div>
<div>
<CardTitle className="text-lg">{integration.name}</CardTitle>
<Badge
variant={
integration.status === 'active' ? 'success' :
integration.status === 'coming_soon' ? 'warning' :
'default'
}
className="mt-1"
>
{integration.status === 'active' ? 'Active' :
integration.status === 'coming_soon' ? 'Coming Soon' :
'Inactive'}
</Badge>
</div>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600 mb-4">{integration.description}</p>
<div className="space-y-2 mb-4">
{integration.features.slice(0, 3).map((feature, index) => (
<div key={index} className="flex items-start space-x-2">
<svg className="w-4 h-4 text-success-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm text-gray-700">{feature}</span>
</div>
))}
</div>
{integration.status === 'active' ? (
<Button variant="outline" className="w-full">
Configure
</Button>
) : integration.status === 'coming_soon' ? (
<Button variant="outline" className="w-full" disabled>
Coming Soon
</Button>
) : (
<Button className="w-full" onClick={() => handleActivate(integration)}>
Activate & Configure
</Button>
)}
</CardContent>
</Card>
))}
</div>
{/* Setup Dialog */}
{showSetupDialog && selectedIntegration && (
<Dialog
open={showSetupDialog}
onOpenChange={setShowSetupDialog}
>
<div className="bg-white rounded-xl shadow-lg border border-gray-200 p-6 max-w-lg mx-auto">
<h2 className="text-lg font-semibold mb-4">Setup {selectedIntegration.name}</h2>
<div className="space-y-4">
{selectedIntegration.id === 'zapier' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Webhook URL
</label>
<Input
value="https://hooks.zapier.com/hooks/catch/123456/abcdef/"
readOnly
className="font-mono text-sm"
/>
<p className="text-sm text-gray-500 mt-1">
Copy this URL to your Zapier trigger
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Events to Send
</label>
<div className="space-y-2">
<label className="flex items-center">
<input type="checkbox" className="mr-2" defaultChecked />
<span className="text-sm">QR Code Scanned</span>
</label>
<label className="flex items-center">
<input type="checkbox" className="mr-2" defaultChecked />
<span className="text-sm">QR Code Created</span>
</label>
<label className="flex items-center">
<input type="checkbox" className="mr-2" />
<span className="text-sm">QR Code Updated</span>
</label>
</div>
</div>
<div className="p-4 bg-gray-50 rounded-lg">
<h4 className="font-medium text-gray-900 mb-2">Sample Payload</h4>
<pre className="text-xs text-gray-600 overflow-x-auto">
{`{
"event": "qr_scanned",
"qr_id": "abc123",
"title": "Product Page",
"timestamp": "2025-01-01T12:00:00Z",
"location": "United States",
"device": "mobile"
}`}
</pre>
</div>
</>
)}
{selectedIntegration.id === 'airtable' && (
<>
<Input
label="API Key"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="key..."
/>
<Input
label="Base ID"
value=""
placeholder="app..."
/>
<Input
label="Table Name"
value=""
placeholder="QR Codes"
/>
<Button variant="outline" onClick={handleTestConnection}>
Test Connection
</Button>
</>
)}
{selectedIntegration.id === 'google-sheets' && (
<>
<div className="text-center p-6">
<Button>
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
Connect Google Account
</Button>
</div>
<Input
label="Spreadsheet URL"
value=""
placeholder="https://docs.google.com/spreadsheets/..."
/>
</>
)}
<div className="flex justify-end space-x-3 pt-4">
<Button variant="outline" onClick={() => setShowSetupDialog(false)}>
Cancel
</Button>
<Button onClick={handleSaveIntegration}>
Save Integration
</Button>
</div>
</div>
</div>
</Dialog>
)}
</div>
);
}

View File

@@ -1,386 +1,386 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { useCsrf } from '@/hooks/useCsrf';
import { showToast } from '@/components/ui/Toast';
import ChangePasswordModal from '@/components/settings/ChangePasswordModal';
type TabType = 'profile' | 'subscription';
export default function SettingsPage() {
const { fetchWithCsrf } = useCsrf();
const [activeTab, setActiveTab] = useState<TabType>('profile');
const [loading, setLoading] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false);
// Profile states
const [name, setName] = useState('');
const [email, setEmail] = useState('');
// Subscription states
const [plan, setPlan] = useState('FREE');
const [usageStats, setUsageStats] = useState({
dynamicUsed: 0,
dynamicLimit: 3,
staticUsed: 0,
});
// Load user data
useEffect(() => {
const fetchUserData = async () => {
try {
// Load from localStorage
const userStr = localStorage.getItem('user');
if (userStr) {
const user = JSON.parse(userStr);
setName(user.name || '');
setEmail(user.email || '');
}
// Fetch plan from API
const planResponse = await fetch('/api/user/plan');
if (planResponse.ok) {
const data = await planResponse.json();
setPlan(data.plan || 'FREE');
}
// Fetch usage stats from API
const statsResponse = await fetch('/api/user/stats');
if (statsResponse.ok) {
const data = await statsResponse.json();
setUsageStats(data);
}
} catch (e) {
console.error('Failed to load user data:', e);
}
};
fetchUserData();
}, []);
const handleSaveProfile = async () => {
setLoading(true);
try {
// Save to backend API
const response = await fetchWithCsrf('/api/user/profile', {
method: 'PATCH',
body: JSON.stringify({ name }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to update profile');
}
// Update user data in localStorage
const userStr = localStorage.getItem('user');
if (userStr) {
const user = JSON.parse(userStr);
user.name = name;
localStorage.setItem('user', JSON.stringify(user));
}
showToast('Profile updated successfully!', 'success');
} catch (error: any) {
console.error('Error saving profile:', error);
showToast(error.message || 'Failed to update profile', 'error');
} finally {
setLoading(false);
}
};
const handleManageSubscription = async () => {
setLoading(true);
try {
const response = await fetchWithCsrf('/api/stripe/portal', {
method: 'POST',
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to open subscription management');
}
// Redirect to Stripe Customer Portal
window.location.href = data.url;
} catch (error: any) {
console.error('Error opening portal:', error);
showToast(error.message || 'Failed to open subscription management', 'error');
setLoading(false);
}
};
const handleDeleteAccount = async () => {
const confirmed = window.confirm(
'Are you sure you want to delete your account? This will permanently delete all your data, including all QR codes and analytics. This action cannot be undone.'
);
if (!confirmed) return;
// Double confirmation for safety
const doubleConfirmed = window.confirm(
'This is your last warning. Are you absolutely sure you want to permanently delete your account?'
);
if (!doubleConfirmed) return;
setLoading(true);
try {
const response = await fetchWithCsrf('/api/user/delete', {
method: 'DELETE',
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to delete account');
}
// Clear local storage and redirect to login
localStorage.clear();
showToast('Account deleted successfully', 'success');
// Redirect to home page after a short delay
setTimeout(() => {
window.location.href = '/';
}, 1500);
} catch (error: any) {
console.error('Error deleting account:', error);
showToast(error.message || 'Failed to delete account', 'error');
setLoading(false);
}
};
const getPlanLimits = () => {
switch (plan) {
case 'PRO':
return { dynamic: 50, price: '€9', period: 'per month' };
case 'BUSINESS':
return { dynamic: 500, price: '€29', period: 'per month' };
default:
return { dynamic: 3, price: '€0', period: 'forever' };
}
};
const planLimits = getPlanLimits();
const usagePercentage = (usageStats.dynamicUsed / usageStats.dynamicLimit) * 100;
return (
<div className="max-w-6xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Settings</h1>
<p className="text-gray-600 mt-2">Manage your account settings and preferences</p>
</div>
{/* Tabs */}
<div className="border-b border-gray-200 mb-6">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('profile')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'profile'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Profile
</button>
<button
onClick={() => setActiveTab('subscription')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'subscription'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Subscription
</button>
</nav>
</div>
{/* Tab Content */}
{activeTab === 'profile' && (
<div className="space-y-6">
{/* Profile Information */}
<Card>
<CardHeader>
<CardTitle>Profile Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="Enter your name"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Email
</label>
<input
type="email"
value={email}
disabled
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed"
/>
<p className="text-xs text-gray-500 mt-1">
Email cannot be changed
</p>
</div>
</CardContent>
</Card>
{/* Security */}
<Card>
<CardHeader>
<CardTitle>Security</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-900">Password</h3>
<p className="text-sm text-gray-500 mt-1">
Update your password to keep your account secure
</p>
</div>
<Button
variant="outline"
onClick={() => setShowPasswordModal(true)}
>
Change Password
</Button>
</div>
</CardContent>
</Card>
{/* Account Deletion */}
<Card>
<CardHeader>
<CardTitle className="text-red-600">Delete Account</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-900">Delete your account</h3>
<p className="text-sm text-gray-500 mt-1">
Permanently delete your account and all data. This action cannot be undone.
</p>
</div>
<Button
variant="outline"
className="border-red-600 text-red-600 hover:bg-red-50"
onClick={handleDeleteAccount}
>
Delete Account
</Button>
</div>
</CardContent>
</Card>
{/* Save Button */}
<div className="flex justify-end">
<Button
onClick={handleSaveProfile}
disabled={loading}
size="lg"
variant="primary"
>
{loading ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
)}
{activeTab === 'subscription' && (
<div className="space-y-6">
{/* Current Plan */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Current Plan</CardTitle>
<Badge variant={plan === 'FREE' ? 'default' : plan === 'PRO' ? 'info' : 'warning'}>
{plan}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-baseline">
<span className="text-4xl font-bold">{planLimits.price}</span>
<span className="text-gray-600 ml-2">{planLimits.period}</span>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">Dynamic QR Codes</span>
<span className="font-medium">
{usageStats.dynamicUsed} of {usageStats.dynamicLimit} used
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-primary-600 h-2 rounded-full transition-all"
style={{ width: `${Math.min(usagePercentage, 100)}%` }}
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">Static QR Codes</span>
<span className="font-medium">Unlimited </span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div className="bg-success-600 h-2 rounded-full" style={{ width: '100%' }} />
</div>
</div>
{plan !== 'FREE' && (
<div className="pt-4 border-t">
<Button
variant="outline"
className="w-full"
onClick={() => window.location.href = '/pricing'}
>
Manage Subscription
</Button>
</div>
)}
{plan === 'FREE' && (
<div className="pt-4 border-t">
<Button variant="primary" className="w-full" onClick={() => window.location.href = '/pricing'}>
Upgrade Plan
</Button>
</div>
)}
</CardContent>
</Card>
</div>
)}
{/* Change Password Modal */}
<ChangePasswordModal
isOpen={showPasswordModal}
onClose={() => setShowPasswordModal(false)}
onSuccess={() => {
setShowPasswordModal(false);
}}
/>
</div>
);
}
'use client';
import React, { useState, useEffect } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { useCsrf } from '@/hooks/useCsrf';
import { showToast } from '@/components/ui/Toast';
import ChangePasswordModal from '@/components/settings/ChangePasswordModal';
type TabType = 'profile' | 'subscription';
export default function SettingsPage() {
const { fetchWithCsrf } = useCsrf();
const [activeTab, setActiveTab] = useState<TabType>('profile');
const [loading, setLoading] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false);
// Profile states
const [name, setName] = useState('');
const [email, setEmail] = useState('');
// Subscription states
const [plan, setPlan] = useState('FREE');
const [usageStats, setUsageStats] = useState({
dynamicUsed: 0,
dynamicLimit: 3,
staticUsed: 0,
});
// Load user data
useEffect(() => {
const fetchUserData = async () => {
try {
// Load from localStorage
const userStr = localStorage.getItem('user');
if (userStr) {
const user = JSON.parse(userStr);
setName(user.name || '');
setEmail(user.email || '');
}
// Fetch plan from API
const planResponse = await fetch('/api/user/plan');
if (planResponse.ok) {
const data = await planResponse.json();
setPlan(data.plan || 'FREE');
}
// Fetch usage stats from API
const statsResponse = await fetch('/api/user/stats');
if (statsResponse.ok) {
const data = await statsResponse.json();
setUsageStats(data);
}
} catch (e) {
console.error('Failed to load user data:', e);
}
};
fetchUserData();
}, []);
const handleSaveProfile = async () => {
setLoading(true);
try {
// Save to backend API
const response = await fetchWithCsrf('/api/user/profile', {
method: 'PATCH',
body: JSON.stringify({ name }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to update profile');
}
// Update user data in localStorage
const userStr = localStorage.getItem('user');
if (userStr) {
const user = JSON.parse(userStr);
user.name = name;
localStorage.setItem('user', JSON.stringify(user));
}
showToast('Profile updated successfully!', 'success');
} catch (error: any) {
console.error('Error saving profile:', error);
showToast(error.message || 'Failed to update profile', 'error');
} finally {
setLoading(false);
}
};
const handleManageSubscription = async () => {
setLoading(true);
try {
const response = await fetchWithCsrf('/api/stripe/portal', {
method: 'POST',
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to open subscription management');
}
// Redirect to Stripe Customer Portal
window.location.href = data.url;
} catch (error: any) {
console.error('Error opening portal:', error);
showToast(error.message || 'Failed to open subscription management', 'error');
setLoading(false);
}
};
const handleDeleteAccount = async () => {
const confirmed = window.confirm(
'Are you sure you want to delete your account? This will permanently delete all your data, including all QR codes and analytics. This action cannot be undone.'
);
if (!confirmed) return;
// Double confirmation for safety
const doubleConfirmed = window.confirm(
'This is your last warning. Are you absolutely sure you want to permanently delete your account?'
);
if (!doubleConfirmed) return;
setLoading(true);
try {
const response = await fetchWithCsrf('/api/user/delete', {
method: 'DELETE',
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to delete account');
}
// Clear local storage and redirect to login
localStorage.clear();
showToast('Account deleted successfully', 'success');
// Redirect to home page after a short delay
setTimeout(() => {
window.location.href = '/';
}, 1500);
} catch (error: any) {
console.error('Error deleting account:', error);
showToast(error.message || 'Failed to delete account', 'error');
setLoading(false);
}
};
const getPlanLimits = () => {
switch (plan) {
case 'PRO':
return { dynamic: 50, price: '€9', period: 'per month' };
case 'BUSINESS':
return { dynamic: 500, price: '€29', period: 'per month' };
default:
return { dynamic: 3, price: '€0', period: 'forever' };
}
};
const planLimits = getPlanLimits();
const usagePercentage = (usageStats.dynamicUsed / usageStats.dynamicLimit) * 100;
return (
<div className="max-w-6xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Settings</h1>
<p className="text-gray-600 mt-2">Manage your account settings and preferences</p>
</div>
{/* Tabs */}
<div className="border-b border-gray-200 mb-6">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('profile')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'profile'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Profile
</button>
<button
onClick={() => setActiveTab('subscription')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'subscription'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Subscription
</button>
</nav>
</div>
{/* Tab Content */}
{activeTab === 'profile' && (
<div className="space-y-6">
{/* Profile Information */}
<Card>
<CardHeader>
<CardTitle>Profile Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="Enter your name"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Email
</label>
<input
type="email"
value={email}
disabled
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed"
/>
<p className="text-xs text-gray-500 mt-1">
Email cannot be changed
</p>
</div>
</CardContent>
</Card>
{/* Security */}
<Card>
<CardHeader>
<CardTitle>Security</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-900">Password</h3>
<p className="text-sm text-gray-500 mt-1">
Update your password to keep your account secure
</p>
</div>
<Button
variant="outline"
onClick={() => setShowPasswordModal(true)}
>
Change Password
</Button>
</div>
</CardContent>
</Card>
{/* Account Deletion */}
<Card>
<CardHeader>
<CardTitle className="text-red-600">Delete Account</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-900">Delete your account</h3>
<p className="text-sm text-gray-500 mt-1">
Permanently delete your account and all data. This action cannot be undone.
</p>
</div>
<Button
variant="outline"
className="border-red-600 text-red-600 hover:bg-red-50"
onClick={handleDeleteAccount}
>
Delete Account
</Button>
</div>
</CardContent>
</Card>
{/* Save Button */}
<div className="flex justify-end">
<Button
onClick={handleSaveProfile}
disabled={loading}
size="lg"
variant="primary"
>
{loading ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
)}
{activeTab === 'subscription' && (
<div className="space-y-6">
{/* Current Plan */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Current Plan</CardTitle>
<Badge variant={plan === 'FREE' ? 'default' : plan === 'PRO' ? 'info' : 'warning'}>
{plan}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-baseline">
<span className="text-4xl font-bold">{planLimits.price}</span>
<span className="text-gray-600 ml-2">{planLimits.period}</span>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">Dynamic QR Codes</span>
<span className="font-medium">
{usageStats.dynamicUsed} of {usageStats.dynamicLimit} used
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-primary-600 h-2 rounded-full transition-all"
style={{ width: `${Math.min(usagePercentage, 100)}%` }}
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">Static QR Codes</span>
<span className="font-medium">Unlimited </span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div className="bg-success-600 h-2 rounded-full" style={{ width: '100%' }} />
</div>
</div>
{plan !== 'FREE' && (
<div className="pt-4 border-t">
<Button
variant="outline"
className="w-full"
onClick={() => window.location.href = '/pricing'}
>
Manage Subscription
</Button>
</div>
)}
{plan === 'FREE' && (
<div className="pt-4 border-t">
<Button variant="primary" className="w-full" onClick={() => window.location.href = '/pricing'}>
Upgrade Plan
</Button>
</div>
)}
</CardContent>
</Card>
</div>
)}
{/* Change Password Modal */}
<ChangePasswordModal
isOpen={showPasswordModal}
onClose={() => setShowPasswordModal(false)}
onSuccess={() => {
setShowPasswordModal(false);
}}
/>
</div>
);
}

View File

@@ -1,158 +1,158 @@
'use client';
import React, { useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { Button } from '@/components/ui/Button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
export default function TestPage() {
const [testResults, setTestResults] = useState<any>({});
const [loading, setLoading] = useState(false);
const runTest = async () => {
setLoading(true);
const results: any = {};
try {
// Step 1: Create a STATIC QR code
console.log('Creating STATIC QR code...');
const createResponse = await fetch('/api/qrs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Test Static QR',
contentType: 'URL',
content: { url: 'https://google.com' },
isStatic: true,
tags: [],
style: {
foregroundColor: '#000000',
backgroundColor: '#FFFFFF',
cornerStyle: 'square',
size: 200,
},
}),
});
const createdQR = await createResponse.json();
results.created = createdQR;
console.log('Created QR:', createdQR);
// Step 2: Fetch all QR codes
console.log('Fetching QR codes...');
const fetchResponse = await fetch('/api/qrs');
const allQRs = await fetchResponse.json();
results.fetched = allQRs;
console.log('Fetched QRs:', allQRs);
// Step 3: Check debug endpoint
console.log('Checking debug endpoint...');
const debugResponse = await fetch('/api/debug');
const debugData = await debugResponse.json();
results.debug = debugData;
console.log('Debug data:', debugData);
} catch (error) {
results.error = String(error);
console.error('Test error:', error);
}
setTestResults(results);
setLoading(false);
};
const getQRValue = (qr: any) => {
// Check for qrContent field
if (qr?.content?.qrContent) {
return qr.content.qrContent;
}
// Check for direct URL
if (qr?.content?.url) {
return qr.content.url;
}
// Fallback to redirect
return `http://localhost:3001/r/${qr?.slug || 'unknown'}`;
};
return (
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">QR Code Test Page</h1>
<Card className="mb-6">
<CardHeader>
<CardTitle>Test Static QR Code Creation</CardTitle>
</CardHeader>
<CardContent>
<Button onClick={runTest} loading={loading}>
Run Test
</Button>
{testResults.created && (
<div className="mt-6">
<h3 className="font-semibold mb-2">Created QR Code:</h3>
<pre className="bg-gray-100 p-3 rounded text-xs overflow-auto">
{JSON.stringify(testResults.created, null, 2)}
</pre>
<div className="mt-4">
<h4 className="font-semibold mb-2">QR Code Preview:</h4>
<div className="bg-gray-50 p-4 rounded">
<QRCodeSVG
value={getQRValue(testResults.created)}
size={200}
/>
<p className="mt-2 text-sm text-gray-600">
QR Value: {getQRValue(testResults.created)}
</p>
</div>
</div>
</div>
)}
{testResults.fetched && (
<div className="mt-6">
<h3 className="font-semibold mb-2">All QR Codes:</h3>
<pre className="bg-gray-100 p-3 rounded text-xs overflow-auto max-h-64">
{JSON.stringify(testResults.fetched, null, 2)}
</pre>
</div>
)}
{testResults.debug && (
<div className="mt-6">
<h3 className="font-semibold mb-2">Debug Data:</h3>
<pre className="bg-gray-100 p-3 rounded text-xs overflow-auto max-h-64">
{JSON.stringify(testResults.debug, null, 2)}
</pre>
</div>
)}
{testResults.error && (
<div className="mt-6 p-4 bg-red-50 text-red-600 rounded">
Error: {testResults.error}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Manual QR Tests</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h3 className="font-semibold mb-2">Direct URL QR (Should go to Google):</h3>
<QRCodeSVG value="https://google.com" size={150} />
<p className="text-sm text-gray-600 mt-1">Value: https://google.com</p>
</div>
<div>
<h3 className="font-semibold mb-2">Redirect QR (Goes through localhost):</h3>
<QRCodeSVG value="http://localhost:3001/r/test-slug" size={150} />
<p className="text-sm text-gray-600 mt-1">Value: http://localhost:3001/r/test-slug</p>
</div>
</CardContent>
</Card>
</div>
);
'use client';
import React, { useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { Button } from '@/components/ui/Button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
export default function TestPage() {
const [testResults, setTestResults] = useState<any>({});
const [loading, setLoading] = useState(false);
const runTest = async () => {
setLoading(true);
const results: any = {};
try {
// Step 1: Create a STATIC QR code
console.log('Creating STATIC QR code...');
const createResponse = await fetch('/api/qrs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Test Static QR',
contentType: 'URL',
content: { url: 'https://google.com' },
isStatic: true,
tags: [],
style: {
foregroundColor: '#000000',
backgroundColor: '#FFFFFF',
cornerStyle: 'square',
size: 200,
},
}),
});
const createdQR = await createResponse.json();
results.created = createdQR;
console.log('Created QR:', createdQR);
// Step 2: Fetch all QR codes
console.log('Fetching QR codes...');
const fetchResponse = await fetch('/api/qrs');
const allQRs = await fetchResponse.json();
results.fetched = allQRs;
console.log('Fetched QRs:', allQRs);
// Step 3: Check debug endpoint
console.log('Checking debug endpoint...');
const debugResponse = await fetch('/api/debug');
const debugData = await debugResponse.json();
results.debug = debugData;
console.log('Debug data:', debugData);
} catch (error) {
results.error = String(error);
console.error('Test error:', error);
}
setTestResults(results);
setLoading(false);
};
const getQRValue = (qr: any) => {
// Check for qrContent field
if (qr?.content?.qrContent) {
return qr.content.qrContent;
}
// Check for direct URL
if (qr?.content?.url) {
return qr.content.url;
}
// Fallback to redirect
return `http://localhost:3001/r/${qr?.slug || 'unknown'}`;
};
return (
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">QR Code Test Page</h1>
<Card className="mb-6">
<CardHeader>
<CardTitle>Test Static QR Code Creation</CardTitle>
</CardHeader>
<CardContent>
<Button onClick={runTest} loading={loading}>
Run Test
</Button>
{testResults.created && (
<div className="mt-6">
<h3 className="font-semibold mb-2">Created QR Code:</h3>
<pre className="bg-gray-100 p-3 rounded text-xs overflow-auto">
{JSON.stringify(testResults.created, null, 2)}
</pre>
<div className="mt-4">
<h4 className="font-semibold mb-2">QR Code Preview:</h4>
<div className="bg-gray-50 p-4 rounded">
<QRCodeSVG
value={getQRValue(testResults.created)}
size={200}
/>
<p className="mt-2 text-sm text-gray-600">
QR Value: {getQRValue(testResults.created)}
</p>
</div>
</div>
</div>
)}
{testResults.fetched && (
<div className="mt-6">
<h3 className="font-semibold mb-2">All QR Codes:</h3>
<pre className="bg-gray-100 p-3 rounded text-xs overflow-auto max-h-64">
{JSON.stringify(testResults.fetched, null, 2)}
</pre>
</div>
)}
{testResults.debug && (
<div className="mt-6">
<h3 className="font-semibold mb-2">Debug Data:</h3>
<pre className="bg-gray-100 p-3 rounded text-xs overflow-auto max-h-64">
{JSON.stringify(testResults.debug, null, 2)}
</pre>
</div>
)}
{testResults.error && (
<div className="mt-6 p-4 bg-red-50 text-red-600 rounded">
Error: {testResults.error}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Manual QR Tests</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h3 className="font-semibold mb-2">Direct URL QR (Should go to Google):</h3>
<QRCodeSVG value="https://google.com" size={150} />
<p className="text-sm text-gray-600 mt-1">Value: https://google.com</p>
</div>
<div>
<h3 className="font-semibold mb-2">Redirect QR (Goes through localhost):</h3>
<QRCodeSVG value="http://localhost:3001/r/test-slug" size={150} />
<p className="text-sm text-gray-600 mt-1">Value: http://localhost:3001/r/test-slug</p>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,155 +1,155 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { Card, CardContent } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { useCsrf } from '@/hooks/useCsrf';
export default function ForgotPasswordPage() {
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const response = await fetchWithCsrf('/api/auth/forgot-password', {
method: 'POST',
body: JSON.stringify({ email }),
});
const data = await response.json();
if (response.ok) {
setSuccess(true);
} else {
setError(data.error || 'Failed to send reset email');
}
} catch (err) {
setError('An error occurred. Please try again.');
} finally {
setLoading(false);
}
};
if (success) {
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
<span className="text-2xl font-bold text-gray-900">QR Master</span>
</Link>
<h1 className="text-3xl font-bold text-gray-900">Check Your Email</h1>
<p className="text-gray-600 mt-2">We've sent you a password reset link</p>
</div>
<Card>
<CardContent className="p-6">
<div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-gray-700 mb-4">
We've sent a password reset link to <strong>{email}</strong>
</p>
<p className="text-sm text-gray-600 mb-6">
Please check your email and click the link to reset your password. The link will expire in 1 hour.
</p>
<div className="space-y-3">
<Link href="/login" className="block">
<Button variant="primary" className="w-full">
Back to Login
</Button>
</Link>
<button
onClick={() => {
setSuccess(false);
setEmail('');
}}
className="w-full text-primary-600 hover:text-primary-700 text-sm font-medium"
>
Try a different email
</button>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
<span className="text-2xl font-bold text-gray-900">QR Master</span>
</Link>
<h1 className="text-3xl font-bold text-gray-900">Forgot Password?</h1>
<p className="text-gray-600 mt-2">No worries, we'll send you reset instructions</p>
</div>
<Card>
<CardContent className="p-6">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
{error}
</div>
)}
<Input
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
disabled={loading || csrfLoading}
/>
<Button
type="submit"
className="w-full"
loading={loading}
disabled={csrfLoading || loading}
>
{csrfLoading ? 'Loading...' : 'Send Reset Link'}
</Button>
<div className="text-center">
<Link href="/login" className="text-sm text-primary-600 hover:text-primary-700 font-medium">
← Back to Login
</Link>
</div>
</form>
</CardContent>
</Card>
<p className="text-center text-sm text-gray-500 mt-6">
Remember your password?{' '}
<Link href="/login" className="text-primary-600 hover:text-primary-700 font-medium">
Sign in
</Link>
</p>
</div>
</div>
);
}
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { Card, CardContent } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { useCsrf } from '@/hooks/useCsrf';
export default function ForgotPasswordPage() {
const { fetchWithCsrf, loading: csrfLoading } = useCsrf();
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const response = await fetchWithCsrf('/api/auth/forgot-password', {
method: 'POST',
body: JSON.stringify({ email }),
});
const data = await response.json();
if (response.ok) {
setSuccess(true);
} else {
setError(data.error || 'Failed to send reset email');
}
} catch (err) {
setError('An error occurred. Please try again.');
} finally {
setLoading(false);
}
};
if (success) {
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
<span className="text-2xl font-bold text-gray-900">QR Master</span>
</Link>
<h1 className="text-3xl font-bold text-gray-900">Check Your Email</h1>
<p className="text-gray-600 mt-2">We've sent you a password reset link</p>
</div>
<Card>
<CardContent className="p-6">
<div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-gray-700 mb-4">
We've sent a password reset link to <strong>{email}</strong>
</p>
<p className="text-sm text-gray-600 mb-6">
Please check your email and click the link to reset your password. The link will expire in 1 hour.
</p>
<div className="space-y-3">
<Link href="/login" className="block">
<Button variant="primary" className="w-full">
Back to Login
</Button>
</Link>
<button
onClick={() => {
setSuccess(false);
setEmail('');
}}
className="w-full text-primary-600 hover:text-primary-700 text-sm font-medium"
>
Try a different email
</button>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
<span className="text-2xl font-bold text-gray-900">QR Master</span>
</Link>
<h1 className="text-3xl font-bold text-gray-900">Forgot Password?</h1>
<p className="text-gray-600 mt-2">No worries, we'll send you reset instructions</p>
</div>
<Card>
<CardContent className="p-6">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
{error}
</div>
)}
<Input
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
disabled={loading || csrfLoading}
/>
<Button
type="submit"
className="w-full"
loading={loading}
disabled={csrfLoading || loading}
>
{csrfLoading ? 'Loading...' : 'Send Reset Link'}
</Button>
<div className="text-center">
<Link href="/login" className="text-sm text-primary-600 hover:text-primary-700 font-medium">
← Back to Login
</Link>
</div>
</form>
</CardContent>
</Card>
<p className="text-center text-sm text-gray-500 mt-6">
Remember your password?{' '}
<Link href="/login" className="text-primary-600 hover:text-primary-700 font-medium">
Sign in
</Link>
</p>
</div>
</div>
);
}

View File

@@ -1,145 +1,145 @@
import { getPublishedPostBySlug } from '@/lib/content';
import sanitizeHtml from 'sanitize-html';
const RAW_ENABLED_SLUGS = new Set([
'dynamic-vs-static-qr-codes',
'qr-code-small-business',
'qr-code-tracking-guide-2025',
'utm-parameter-qr-codes',
'trackable-qr-codes',
]);
function decodeHtmlEntities(text: string): string {
return text
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&mdash;/g, '--')
.replace(/&ndash;/g, '-')
.replace(/&hellip;/g, '...')
.replace(/&#x27;/g, "'")
.replace(/&#x2F;/g, '/')
.replace(/&#(\d+);/g, (_, code) => {
const value = Number.parseInt(code, 10);
return Number.isNaN(value) ? '' : String.fromCharCode(value);
});
}
function cleanHtmlToText(html: string): string {
const normalized = html
.replace(/<div\b[^>]*class=(['"])[^'"]*post-metadata[^'"]*\1[^>]*>[\s\S]*?<\/div>/gi, '')
.replace(/<div\b[^>]*class=(['"])[^'"]*blog-content[^'"]*\1[^>]*>/gi, '')
.replace(/<\/div>\s*$/i, '');
const withLinks = normalized.replace(
/<a\b[^>]*href=(['"])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi,
(_, __, href: string, text: string) => `[${cleanHtmlToText(text)}](${href})`,
);
const structured = withLinks
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<li\b[^>]*>/gi, '- ')
.replace(/<\/li>/gi, '\n')
.replace(/<h([1-6])\b[^>]*>/gi, (_, level: string) => `${'#'.repeat(Number.parseInt(level, 10))} `)
.replace(/<\/h[1-6]>/gi, '\n\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<\/div>/gi, '\n\n')
.replace(/<\/section>/gi, '\n\n')
.replace(/<\/ul>/gi, '\n')
.replace(/<\/ol>/gi, '\n');
const stripped = sanitizeHtml(structured, {
allowedTags: [],
allowedAttributes: {},
});
return decodeHtmlEntities(stripped)
.replace(/\r\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n[ \t]+/g, '\n')
.trim();
}
function renderRawPost(slug: string): string | null {
if (!RAW_ENABLED_SLUGS.has(slug)) {
return null;
}
const post = getPublishedPostBySlug(slug);
if (!post) {
return null;
}
const sections: string[] = [
`# ${post.title}`,
'',
post.description,
'',
`Canonical URL: https://www.qrmaster.net/blog/${post.slug}`,
`Published: ${post.datePublished}`,
`Updated: ${post.dateModified || post.updatedAt || post.datePublished}`,
];
if (post.quickAnswer) {
sections.push('', '## Quick Answer', '', cleanHtmlToText(post.quickAnswer));
}
if (post.keySteps?.length) {
sections.push('', '## Steps', '', ...post.keySteps.map((step, index) => `${index + 1}. ${step}`));
}
const mainText = cleanHtmlToText(post.content);
if (mainText) {
sections.push('', '## Article', '', mainText);
}
if (post.faq?.length) {
sections.push('', '## FAQ', '');
for (const item of post.faq) {
sections.push(`Q: ${cleanHtmlToText(item.question)}`);
sections.push(`A: ${cleanHtmlToText(item.answer)}`, '');
}
if (sections[sections.length - 1] === '') {
sections.pop();
}
}
if (post.sources?.length) {
sections.push('', '## Sources', '');
for (const source of post.sources) {
const accessDate = source.accessDate ? ` (accessed ${source.accessDate})` : '';
sections.push(`- ${source.name}: ${source.url}${accessDate}`);
}
}
return `${sections.join('\n').trim()}\n`;
}
export async function GET(
_request: Request,
{ params }: { params: { slug: string } },
) {
const content = renderRawPost(params.slug);
if (!content) {
return new Response('Not Found', {
status: 404,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'X-Robots-Tag': 'noindex, nofollow',
},
});
}
return new Response(content, {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'X-Robots-Tag': 'noindex, nofollow',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
},
});
}
import { getPublishedPostBySlug } from '@/lib/content';
import sanitizeHtml from 'sanitize-html';
const RAW_ENABLED_SLUGS = new Set([
'dynamic-vs-static-qr-codes',
'qr-code-small-business',
'qr-code-tracking-guide-2025',
'utm-parameter-qr-codes',
'trackable-qr-codes',
]);
function decodeHtmlEntities(text: string): string {
return text
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&mdash;/g, '--')
.replace(/&ndash;/g, '-')
.replace(/&hellip;/g, '...')
.replace(/&#x27;/g, "'")
.replace(/&#x2F;/g, '/')
.replace(/&#(\d+);/g, (_, code) => {
const value = Number.parseInt(code, 10);
return Number.isNaN(value) ? '' : String.fromCharCode(value);
});
}
function cleanHtmlToText(html: string): string {
const normalized = html
.replace(/<div\b[^>]*class=(['"])[^'"]*post-metadata[^'"]*\1[^>]*>[\s\S]*?<\/div>/gi, '')
.replace(/<div\b[^>]*class=(['"])[^'"]*blog-content[^'"]*\1[^>]*>/gi, '')
.replace(/<\/div>\s*$/i, '');
const withLinks = normalized.replace(
/<a\b[^>]*href=(['"])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi,
(_, __, href: string, text: string) => `[${cleanHtmlToText(text)}](${href})`,
);
const structured = withLinks
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<li\b[^>]*>/gi, '- ')
.replace(/<\/li>/gi, '\n')
.replace(/<h([1-6])\b[^>]*>/gi, (_, level: string) => `${'#'.repeat(Number.parseInt(level, 10))} `)
.replace(/<\/h[1-6]>/gi, '\n\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<\/div>/gi, '\n\n')
.replace(/<\/section>/gi, '\n\n')
.replace(/<\/ul>/gi, '\n')
.replace(/<\/ol>/gi, '\n');
const stripped = sanitizeHtml(structured, {
allowedTags: [],
allowedAttributes: {},
});
return decodeHtmlEntities(stripped)
.replace(/\r\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n[ \t]+/g, '\n')
.trim();
}
function renderRawPost(slug: string): string | null {
if (!RAW_ENABLED_SLUGS.has(slug)) {
return null;
}
const post = getPublishedPostBySlug(slug);
if (!post) {
return null;
}
const sections: string[] = [
`# ${post.title}`,
'',
post.description,
'',
`Canonical URL: https://www.qrmaster.net/blog/${post.slug}`,
`Published: ${post.datePublished}`,
`Updated: ${post.dateModified || post.updatedAt || post.datePublished}`,
];
if (post.quickAnswer) {
sections.push('', '## Quick Answer', '', cleanHtmlToText(post.quickAnswer));
}
if (post.keySteps?.length) {
sections.push('', '## Steps', '', ...post.keySteps.map((step, index) => `${index + 1}. ${step}`));
}
const mainText = cleanHtmlToText(post.content);
if (mainText) {
sections.push('', '## Article', '', mainText);
}
if (post.faq?.length) {
sections.push('', '## FAQ', '');
for (const item of post.faq) {
sections.push(`Q: ${cleanHtmlToText(item.question)}`);
sections.push(`A: ${cleanHtmlToText(item.answer)}`, '');
}
if (sections[sections.length - 1] === '') {
sections.pop();
}
}
if (post.sources?.length) {
sections.push('', '## Sources', '');
for (const source of post.sources) {
const accessDate = source.accessDate ? ` (accessed ${source.accessDate})` : '';
sections.push(`- ${source.name}: ${source.url}${accessDate}`);
}
}
return `${sections.join('\n').trim()}\n`;
}
export async function GET(
_request: Request,
{ params }: { params: { slug: string } },
) {
const content = renderRawPost(params.slug);
if (!content) {
return new Response('Not Found', {
status: 404,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'X-Robots-Tag': 'noindex, nofollow',
},
});
}
return new Response(content, {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'X-Robots-Tag': 'noindex, nofollow',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
},
});
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,243 +1,243 @@
import React from 'react';
import type { Metadata } from 'next';
import Link from 'next/link';
import SeoJsonLd from '@/components/SeoJsonLd';
import { faqPageSchema } from '@/lib/schema';
import { Card, CardContent } from '@/components/ui/Card';
import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto';
function truncateAtWord(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
const truncated = text.slice(0, maxLength);
const lastSpace = truncated.lastIndexOf(' ');
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
}
export async function generateMetadata(): Promise<Metadata> {
const title = truncateAtWord('QR Master FAQ: Dynamic, Tracking, Bulk, and Print', 60);
const description = truncateAtWord(
'Answers about dynamic QR codes, scan tracking, privacy, bulk creation, and print setup.',
160
);
return {
title,
description,
alternates: {
canonical: 'https://www.qrmaster.net/faq',
languages: {
'x-default': 'https://www.qrmaster.net/faq',
en: 'https://www.qrmaster.net/faq',
},
},
openGraph: {
title,
description,
url: 'https://www.qrmaster.net/faq',
type: 'website',
images: [
{
url: 'https://www.qrmaster.net/og-image.png',
width: 1200,
height: 630,
alt: 'QR Master FAQ',
},
],
},
twitter: {
title,
description,
},
};
}
type FAQItemWithRichText = {
question: string;
answer: string;
answerRich?: React.ReactNode;
};
const faqs: FAQItemWithRichText[] = [
{
question: 'What is a dynamic QR code?',
answer:
'A dynamic QR code points to a redirect URL, so you can change the final destination later without replacing the printed QR image.',
answerRich: (
<>
A dynamic QR code points to a redirect URL, so you can change the final destination later without replacing the printed QR image.
<br />
<br />
<strong>Why teams use it:</strong>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>Update the destination after print</li>
<li>Review scan analytics later</li>
<li>Keep one printed QR in use across changing campaigns or content</li>
</ul>
</>
),
},
{
question: 'How do I track QR scans?',
answer:
'QR Master tracks scans through the dynamic QR redirect step. The analytics views can report time, device, location context, and total or unique scan activity.',
answerRich: (
<>
QR Master tracks scans through the dynamic QR redirect step.
<br />
<br />
<strong>Current analytics context:</strong>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>Total and unique scan reporting</li>
<li>Device type</li>
<li>Location context</li>
<li>Time-based scan activity</li>
</ul>
<br />
<Link href="/qr-code-tracking" className="font-medium text-blue-600 hover:underline">
Learn more about tracking
</Link>
</>
),
},
{
question: 'What security measures are in place?',
answer:
'QR Master uses HTTPS/TLS, CSRF protection for relevant write actions, and rate limiting on API routes.',
answerRich: (
<>
QR Master uses standard protective controls that are visible in the current codebase.
<br />
<br />
<strong>Security-related controls:</strong>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>HTTPS/TLS encryption for all connections</li>
<li>CSRF protection for relevant write actions</li>
<li>Rate limiting on API routes</li>
</ul>
</>
),
},
{
question: 'How does bulk QR creation work today?',
answer:
'QR Master currently supports bulk QR creation through spreadsheet upload in the Business plan. The flow accepts CSV, XLS, and XLSX files, supports up to 1,000 rows per upload, and generates static QR codes.',
answerRich: (
<>
QR Master currently supports bulk QR creation through spreadsheet upload in the Business plan.
<br />
<br />
<strong>Current bulk flow facts:</strong>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>CSV, XLS, and XLSX uploads are supported</li>
<li>Up to 1,000 rows per upload</li>
<li>Output is static QR codes, not dynamic tracking batches</li>
</ul>
<br />
<Link href="/bulk-qr-code-generator" className="font-medium text-blue-600 hover:underline">
See the bulk QR workflow
</Link>
</>
),
},
{
question: 'What are the best practices for printing QR codes?',
answer:
'For reliable scanning, keep the QR code at least 2x2 cm for close-range use, maintain strong contrast, leave a quiet zone around the code, and use SVG or a high-resolution PNG for output.',
answerRich: (
<>
For reliable scanning, follow these print-first basics:
<br />
<br />
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>Minimum size around 2x2 cm for close-range scans</li>
<li>Dark foreground on a light background</li>
<li>Leave a quiet zone around the QR code</li>
<li>Use SVG or a high-resolution PNG depending on the print workflow</li>
</ul>
</>
),
},
{
question: 'Is the service privacy-conscious?',
answer:
'QR Master minimizes scanner data collection. Privacy-related measures visible in the product context include hashed or anonymized IP handling and no scanner PII storage.',
answerRich: (
<>
QR Master is built around minimal scanner data collection.
<br />
<br />
<strong>Privacy-related measures:</strong>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>IP addresses are anonymized or hashed</li>
<li>No scanner PII storage</li>
</ul>
<br />
<Link href="/privacy" className="font-medium text-blue-600 hover:underline">
Read the privacy policy
</Link>
</>
),
},
{
question: 'What is the difference between static and dynamic QR codes?',
answer:
'Static QR codes store the destination directly in the image and stay fixed. Dynamic QR codes route through QR Master so the destination can be changed later and scan analytics can be reviewed.',
answerRich: (
<>
Static QR codes store the destination directly in the image and stay fixed.
Dynamic QR codes route through QR Master so the destination can be changed later and scan analytics can be reviewed.
<br />
<br />
<Link href="/dynamic-qr-code-generator" className="font-medium text-blue-600 hover:underline">
Create a dynamic QR code
</Link>
</>
),
},
];
export default function FAQPage() {
return (
<>
<SeoJsonLd data={faqPageSchema(faqs.map(({ question, answer }) => ({ question, answer })))} />
<div className="bg-gradient-to-b from-gray-50 to-white py-20">
<div className="container mx-auto px-4">
<div className="mx-auto max-w-4xl">
<div className="mb-16 text-center">
<h1 className="mb-6 text-4xl font-bold text-gray-900 lg:text-5xl">
Frequently Asked Questions
</h1>
<p className="mb-4 text-xl text-gray-600">
Answers about dynamic QR codes, scan tracking, privacy, bulk creation, and print setup.
</p>
<p className="text-sm text-gray-500">Last updated: March 12, 2026</p>
</div>
<div className="space-y-6">
{faqs.map((faq) => (
<Card key={faq.question} className="border-l-4 border-blue-500">
<CardContent className="p-8">
<h2 className="mb-4 text-2xl font-semibold text-gray-900">{faq.question}</h2>
<div className="text-lg leading-relaxed text-gray-700">{faq.answerRich || faq.answer}</div>
</CardContent>
</Card>
))}
</div>
<div className="mt-16 rounded-r-lg border-l-4 border-blue-500 bg-blue-50 p-8">
<h2 className="mb-4 text-2xl font-bold text-gray-900">Still have questions?</h2>
<p className="text-lg leading-relaxed text-gray-700">
Our support team is here to help. Contact us at{' '}
<ObfuscatedMailto
email="support@qrmaster.net"
className="font-semibold text-blue-600 hover:text-blue-700"
/>{' '}
and include the workflow you are trying to build.
</p>
</div>
</div>
</div>
</div>
</>
);
}
import React from 'react';
import type { Metadata } from 'next';
import Link from 'next/link';
import SeoJsonLd from '@/components/SeoJsonLd';
import { faqPageSchema } from '@/lib/schema';
import { Card, CardContent } from '@/components/ui/Card';
import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto';
function truncateAtWord(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
const truncated = text.slice(0, maxLength);
const lastSpace = truncated.lastIndexOf(' ');
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
}
export async function generateMetadata(): Promise<Metadata> {
const title = truncateAtWord('QR Master FAQ: Dynamic, Tracking, Bulk, and Print', 60);
const description = truncateAtWord(
'Answers about dynamic QR codes, scan tracking, privacy, bulk creation, and print setup.',
160
);
return {
title,
description,
alternates: {
canonical: 'https://www.qrmaster.net/faq',
languages: {
'x-default': 'https://www.qrmaster.net/faq',
en: 'https://www.qrmaster.net/faq',
},
},
openGraph: {
title,
description,
url: 'https://www.qrmaster.net/faq',
type: 'website',
images: [
{
url: 'https://www.qrmaster.net/og-image.png',
width: 1200,
height: 630,
alt: 'QR Master FAQ',
},
],
},
twitter: {
title,
description,
},
};
}
type FAQItemWithRichText = {
question: string;
answer: string;
answerRich?: React.ReactNode;
};
const faqs: FAQItemWithRichText[] = [
{
question: 'What is a dynamic QR code?',
answer:
'A dynamic QR code points to a redirect URL, so you can change the final destination later without replacing the printed QR image.',
answerRich: (
<>
A dynamic QR code points to a redirect URL, so you can change the final destination later without replacing the printed QR image.
<br />
<br />
<strong>Why teams use it:</strong>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>Update the destination after print</li>
<li>Review scan analytics later</li>
<li>Keep one printed QR in use across changing campaigns or content</li>
</ul>
</>
),
},
{
question: 'How do I track QR scans?',
answer:
'QR Master tracks scans through the dynamic QR redirect step. The analytics views can report time, device, location context, and total or unique scan activity.',
answerRich: (
<>
QR Master tracks scans through the dynamic QR redirect step.
<br />
<br />
<strong>Current analytics context:</strong>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>Total and unique scan reporting</li>
<li>Device type</li>
<li>Location context</li>
<li>Time-based scan activity</li>
</ul>
<br />
<Link href="/qr-code-tracking" className="font-medium text-blue-600 hover:underline">
Learn more about tracking
</Link>
</>
),
},
{
question: 'What security measures are in place?',
answer:
'QR Master uses HTTPS/TLS, CSRF protection for relevant write actions, and rate limiting on API routes.',
answerRich: (
<>
QR Master uses standard protective controls that are visible in the current codebase.
<br />
<br />
<strong>Security-related controls:</strong>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>HTTPS/TLS encryption for all connections</li>
<li>CSRF protection for relevant write actions</li>
<li>Rate limiting on API routes</li>
</ul>
</>
),
},
{
question: 'How does bulk QR creation work today?',
answer:
'QR Master currently supports bulk QR creation through spreadsheet upload in the Business plan. The flow accepts CSV, XLS, and XLSX files, supports up to 1,000 rows per upload, and generates static QR codes.',
answerRich: (
<>
QR Master currently supports bulk QR creation through spreadsheet upload in the Business plan.
<br />
<br />
<strong>Current bulk flow facts:</strong>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>CSV, XLS, and XLSX uploads are supported</li>
<li>Up to 1,000 rows per upload</li>
<li>Output is static QR codes, not dynamic tracking batches</li>
</ul>
<br />
<Link href="/bulk-qr-code-generator" className="font-medium text-blue-600 hover:underline">
See the bulk QR workflow
</Link>
</>
),
},
{
question: 'What are the best practices for printing QR codes?',
answer:
'For reliable scanning, keep the QR code at least 2x2 cm for close-range use, maintain strong contrast, leave a quiet zone around the code, and use SVG or a high-resolution PNG for output.',
answerRich: (
<>
For reliable scanning, follow these print-first basics:
<br />
<br />
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>Minimum size around 2x2 cm for close-range scans</li>
<li>Dark foreground on a light background</li>
<li>Leave a quiet zone around the QR code</li>
<li>Use SVG or a high-resolution PNG depending on the print workflow</li>
</ul>
</>
),
},
{
question: 'Is the service privacy-conscious?',
answer:
'QR Master minimizes scanner data collection. Privacy-related measures visible in the product context include hashed or anonymized IP handling and no scanner PII storage.',
answerRich: (
<>
QR Master is built around minimal scanner data collection.
<br />
<br />
<strong>Privacy-related measures:</strong>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>IP addresses are anonymized or hashed</li>
<li>No scanner PII storage</li>
</ul>
<br />
<Link href="/privacy" className="font-medium text-blue-600 hover:underline">
Read the privacy policy
</Link>
</>
),
},
{
question: 'What is the difference between static and dynamic QR codes?',
answer:
'Static QR codes store the destination directly in the image and stay fixed. Dynamic QR codes route through QR Master so the destination can be changed later and scan analytics can be reviewed.',
answerRich: (
<>
Static QR codes store the destination directly in the image and stay fixed.
Dynamic QR codes route through QR Master so the destination can be changed later and scan analytics can be reviewed.
<br />
<br />
<Link href="/dynamic-qr-code-generator" className="font-medium text-blue-600 hover:underline">
Create a dynamic QR code
</Link>
</>
),
},
];
export default function FAQPage() {
return (
<>
<SeoJsonLd data={faqPageSchema(faqs.map(({ question, answer }) => ({ question, answer })))} />
<div className="bg-gradient-to-b from-gray-50 to-white py-20">
<div className="container mx-auto px-4">
<div className="mx-auto max-w-4xl">
<div className="mb-16 text-center">
<h1 className="mb-6 text-4xl font-bold text-gray-900 lg:text-5xl">
Frequently Asked Questions
</h1>
<p className="mb-4 text-xl text-gray-600">
Answers about dynamic QR codes, scan tracking, privacy, bulk creation, and print setup.
</p>
<p className="text-sm text-gray-500">Last updated: March 12, 2026</p>
</div>
<div className="space-y-6">
{faqs.map((faq) => (
<Card key={faq.question} className="border-l-4 border-blue-500">
<CardContent className="p-8">
<h2 className="mb-4 text-2xl font-semibold text-gray-900">{faq.question}</h2>
<div className="text-lg leading-relaxed text-gray-700">{faq.answerRich || faq.answer}</div>
</CardContent>
</Card>
))}
</div>
<div className="mt-16 rounded-r-lg border-l-4 border-blue-500 bg-blue-50 p-8">
<h2 className="mb-4 text-2xl font-bold text-gray-900">Still have questions?</h2>
<p className="text-lg leading-relaxed text-gray-700">
Our support team is here to help. Contact us at{' '}
<ObfuscatedMailto
email="support@qrmaster.net"
className="font-semibold text-blue-600 hover:text-blue-700"
/>{' '}
and include the workflow you are trying to build.
</p>
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -1,389 +1,389 @@
import React from 'react';
import type { Metadata } from 'next';
import Link from 'next/link';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import SeoJsonLd from '@/components/SeoJsonLd';
import Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
import { breadcrumbSchema, faqPageSchema } from '@/lib/schema';
import { AnswerFirstBlock } from '@/components/marketing/AnswerFirstBlock';
import { FAQSection } from '@/components/aeo/FAQSection';
export const metadata: Metadata = {
title: {
absolute: 'Manage QR Codes - Dashboard, Edits, and Analytics',
},
description:
'Manage QR codes in one dashboard. Review active codes, edit dynamic destinations, see scan totals and unique scans, and work within current Free, Pro, or Business limits.',
keywords: [
'manage qr codes',
'qr code dashboard',
'edit dynamic qr codes',
'qr code analytics dashboard',
'qr code management',
],
alternates: {
canonical: 'https://www.qrmaster.net/manage-qr-codes',
languages: {
'x-default': 'https://www.qrmaster.net/manage-qr-codes',
en: 'https://www.qrmaster.net/manage-qr-codes',
},
},
openGraph: {
title: 'Manage QR Codes - Dashboard, Edits, and Analytics',
description:
'Use one dashboard to review QR codes, edit dynamic destinations, and check scan totals and unique scans.',
url: 'https://www.qrmaster.net/manage-qr-codes',
type: 'website',
images: [
{
url: '/images/og/og-manage-qr-codes.png',
width: 1200,
height: 630,
},
],
},
twitter: {
title: 'Manage QR Codes - Dashboard, Edits, and Analytics',
description:
'Use one dashboard to review QR codes, edit dynamic destinations, and check scan totals and unique scans.',
},
};
const verifiedCapabilities = [
{
title: 'Central dashboard',
description:
'The dashboard lists your QR codes in one place instead of forcing you to manage separate files or links manually.',
},
{
title: 'Dynamic destination edits',
description:
'Dynamic QR codes can be edited after print. Static QR codes remain fixed.',
},
{
title: 'Scan reporting',
description:
'The current dashboard reports total scans, active codes, and unique scans, with analytics pages adding more context.',
},
{
title: 'Plan-based limits',
description:
'Free includes 3 dynamic QR codes, Pro includes 50, and Business includes 500. Static QR codes remain unlimited.',
},
{
title: 'Tags and status',
description:
'QR code records support tags and active status, which helps keep batches and single-code workflows easier to review.',
},
{
title: 'Download and delete actions',
description:
'Each QR code card supports view, download, edit for dynamic QR codes, and delete actions from the dashboard surface.',
},
];
const operationalUseCases = [
{
title: 'Marketing campaigns',
description:
'Review active dynamic QR codes, compare scan totals, and update destinations when campaigns or landing pages change.',
points: ['One list of active codes', 'Scan totals and unique scans', 'Edit dynamic destinations'],
},
{
title: 'Restaurants and hospitality',
description:
'Keep menu or table-card QR codes current from the dashboard instead of reprinting every time the destination changes.',
points: ['Update menu destinations', 'Monitor scan activity', 'Keep print assets in use longer'],
},
{
title: 'Product and packaging workflows',
description:
'Track which QR codes are active, save batches to the dashboard, and separate static bulk output from dynamic campaign codes.',
points: ['Save generated QR codes', 'Review active status', 'Manage static and dynamic codes separately'],
},
{
title: 'Small team or solo workflows',
description:
'Use one account to keep QR code creation, edits, downloads, and analytics in one operational place.',
points: ['Single dashboard view', 'No spreadsheet-only workflow', 'Clear plan limits'],
},
];
const faqItems = [
{
question: 'What does it mean to manage QR codes?',
answer:
'In QR Master, managing QR codes means using one dashboard to review your QR codes, edit dynamic destinations, download files, and check scan activity instead of tracking everything manually.',
},
{
question: 'Can I edit a QR code after printing it?',
answer:
'Yes, if it is a dynamic QR code. Static QR codes stay fixed after creation.',
},
{
question: 'How many dynamic QR codes can I manage?',
answer:
'Free includes 3 dynamic QR codes, Pro includes 50, and Business includes 500. Static QR codes are unlimited.',
},
{
question: 'What analytics are visible today?',
answer:
'The current dashboard shows total scans, active QR codes, and unique scans. Additional analytics views add more scan context such as time, device, and location.',
},
{
question: 'Does the current product include team roles or API-based QR management?',
answer:
'This page only reflects the verified current surface: dashboard management, dynamic edits, analytics, downloads, tags, and plan limits. It does not claim team roles or public API-based QR management as current verified capabilities.',
},
];
const softwareSchema = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
'@id': 'https://www.qrmaster.net/manage-qr-codes#software',
name: 'QR Master - QR Code Management Dashboard',
applicationCategory: 'BusinessApplication',
offers: {
'@type': 'AggregateOffer',
lowPrice: '0',
highPrice: '29',
priceCurrency: 'EUR',
},
featureList: [
'Central QR code dashboard',
'Edit dynamic QR code destinations',
'Review total and unique scan counts',
'Download QR codes as PNG or SVG',
'Tag QR code records and review active status',
'Manage current plan limits for dynamic QR codes',
],
};
const breadcrumbItems: BreadcrumbItem[] = [
{ name: 'Home', url: '/' },
{ name: 'Manage QR Codes', url: '/manage-qr-codes' },
];
export default function ManageQRCodesPage() {
return (
<>
<SeoJsonLd
data={[
softwareSchema,
breadcrumbSchema(breadcrumbItems),
faqPageSchema(faqItems),
]}
/>
<div className="min-h-screen bg-white">
<section className="relative overflow-hidden bg-gradient-to-br from-green-50 via-white to-blue-50 py-20">
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<Breadcrumbs items={breadcrumbItems} />
<div className="mt-8 grid items-center gap-12 lg:grid-cols-2">
<div className="space-y-8">
<div className="inline-flex items-center rounded-full bg-green-100 px-4 py-2 text-sm font-semibold text-green-800">
Dashboard-first QR management
</div>
<div className="space-y-5">
<h1 className="text-5xl font-bold leading-tight text-gray-900 lg:text-6xl">
Manage QR Codes from one dashboard
</h1>
<p className="text-xl leading-relaxed text-gray-600">
Review active QR codes, edit dynamic destinations, download files, and
monitor scan totals from one place instead of managing printed QR workflows manually.
</p>
</div>
<div className="space-y-3">
{[
'See QR codes in one dashboard',
'Edit dynamic destinations after print',
'Review total scans, active codes, and unique scans',
'Work within the current Free, Pro, and Business dynamic QR limits',
].map((feature) => (
<div key={feature} className="flex items-center gap-3">
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-green-500">
<svg className="h-3 w-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
<span className="text-gray-700">{feature}</span>
</div>
))}
</div>
<div className="flex flex-col gap-4 sm:flex-row">
<Link href="/signup">
<Button size="lg" className="w-full px-8 py-4 text-lg sm:w-auto">
Get Started Free
</Button>
</Link>
<Link href="/pricing">
<Button variant="outline" size="lg" className="w-full px-8 py-4 text-lg sm:w-auto">
View Pricing
</Button>
</Link>
</div>
</div>
<div className="relative">
<Card className="p-6 shadow-2xl">
<h3 className="mb-4 text-lg font-semibold">Dashboard snapshot</h3>
<div className="space-y-3">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-semibold text-gray-700">Active QR codes</span>
<span className="text-2xl font-bold text-blue-600">3 / 50 / 500</span>
</div>
<div className="text-xs text-gray-600">Plan-based dynamic QR capacity</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border border-green-200 bg-green-50 p-3">
<div className="mb-1 text-xs text-gray-600">Total scans</div>
<div className="text-xl font-bold text-green-600">Dashboard metric</div>
</div>
<div className="rounded-lg border border-purple-200 bg-purple-50 p-3">
<div className="mb-1 text-xs text-gray-600">Unique scans</div>
<div className="text-xl font-bold text-purple-600">Dashboard metric</div>
</div>
</div>
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
<div className="mb-2 text-xs text-gray-600">Available actions</div>
<div className="flex flex-wrap gap-2 text-xs text-gray-700">
<span className="rounded-full bg-white px-3 py-1">View details</span>
<span className="rounded-full bg-white px-3 py-1">Download PNG</span>
<span className="rounded-full bg-white px-3 py-1">Download SVG</span>
<span className="rounded-full bg-white px-3 py-1">Edit dynamic QR</span>
<span className="rounded-full bg-white px-3 py-1">Delete</span>
</div>
</div>
</div>
</Card>
</div>
</div>
</div>
</section>
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<AnswerFirstBlock
whatIsIt="QR Master management is a dashboard workflow for reviewing QR codes, editing dynamic destinations, downloading files, and checking scan activity from one place. It is most useful when your QR program is active enough that single-file handling becomes messy."
whenToUse={[
'You need one place to review active QR codes and their scan totals',
'You want to edit dynamic QR destinations after print without replacing the QR image',
'You need to keep static and dynamic QR workflows organized around current plan limits',
]}
comparison={{
leftTitle: 'Manual handling',
rightTitle: 'Dashboard management',
items: [
{ label: 'See active QR codes in one place', value: true, text: 'Scattered across files or exports' },
{ label: 'Edit dynamic destinations later', value: true, text: 'Not possible outside dynamic QR management' },
{ label: 'Review scan totals and unique scans', value: true, text: 'No unified dashboard view' },
],
}}
howTo={{
steps: [
'Create or save QR codes into your QR Master account',
'Open the dashboard to review active codes, scans, and available actions',
'Edit dynamic destinations or download the QR files you need for the next workflow',
],
}}
/>
</div>
<div className="container mx-auto max-w-5xl px-4 pb-8 sm:px-6 lg:px-8">
<FAQSection items={faqItems} title="QR management questions" />
</div>
<section className="bg-gray-50 py-20">
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="mb-16 text-center">
<h2 className="mb-4 text-4xl font-bold text-gray-900">What the current dashboard supports</h2>
<p className="mx-auto max-w-3xl text-xl text-gray-600">
These capabilities are tied to the present product surface rather than future or inferred roadmap features.
</p>
</div>
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{verifiedCapabilities.map((feature) => (
<Card key={feature.title} className="p-6 transition-shadow hover:shadow-lg">
<h3 className="mb-2 text-xl font-semibold text-gray-900">{feature.title}</h3>
<p className="text-gray-600">{feature.description}</p>
</Card>
))}
</div>
</div>
</section>
<section className="py-20">
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="mb-16 text-center">
<h2 className="mb-4 text-4xl font-bold text-gray-900">Where QR management is most useful</h2>
<p className="mx-auto max-w-3xl text-xl text-gray-600">
Use the dashboard when your QR workflows need ongoing edits, downloads, and visibility instead of one-off creation.
</p>
</div>
<div className="grid gap-8 md:grid-cols-2">
{operationalUseCases.map((useCase) => (
<Card key={useCase.title} className="p-8">
<h3 className="mb-3 text-2xl font-bold text-gray-900">{useCase.title}</h3>
<p className="mb-6 text-gray-600">{useCase.description}</p>
<ul className="space-y-2">
{useCase.points.map((point) => (
<li key={point} className="flex items-center gap-2">
<svg className="h-5 w-5 flex-shrink-0 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-gray-700">{point}</span>
</li>
))}
</ul>
</Card>
))}
</div>
</div>
</section>
<section className="bg-gradient-to-r from-green-600 to-blue-600 py-20 text-white">
<div className="container mx-auto max-w-5xl px-4 text-center sm:px-6 lg:px-8">
<h2 className="mb-6 text-4xl font-bold">Start managing your QR codes with one account</h2>
<p className="mb-8 text-xl text-green-100">
Keep dynamic updates, downloads, and scan reporting in one dashboard instead of spreading the workflow across files and ad hoc links.
</p>
<div className="flex flex-col justify-center gap-4 sm:flex-row">
<Link href="/signup">
<Button
size="lg"
variant="secondary"
className="w-full bg-white px-8 py-4 text-lg text-green-600 hover:bg-gray-100 sm:w-auto"
>
Get Started Free
</Button>
</Link>
<Link href="/pricing">
<Button
size="lg"
variant="outline"
className="w-full border-white px-8 py-4 text-lg text-white hover:bg-white/10 sm:w-auto"
>
View Pricing
</Button>
</Link>
</div>
</div>
</section>
</div>
</>
);
}
import React from 'react';
import type { Metadata } from 'next';
import Link from 'next/link';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import SeoJsonLd from '@/components/SeoJsonLd';
import Breadcrumbs, { BreadcrumbItem } from '@/components/Breadcrumbs';
import { breadcrumbSchema, faqPageSchema } from '@/lib/schema';
import { AnswerFirstBlock } from '@/components/marketing/AnswerFirstBlock';
import { FAQSection } from '@/components/aeo/FAQSection';
export const metadata: Metadata = {
title: {
absolute: 'Manage QR Codes - Dashboard, Edits, and Analytics',
},
description:
'Manage QR codes in one dashboard. Review active codes, edit dynamic destinations, see scan totals and unique scans, and work within current Free, Pro, or Business limits.',
keywords: [
'manage qr codes',
'qr code dashboard',
'edit dynamic qr codes',
'qr code analytics dashboard',
'qr code management',
],
alternates: {
canonical: 'https://www.qrmaster.net/manage-qr-codes',
languages: {
'x-default': 'https://www.qrmaster.net/manage-qr-codes',
en: 'https://www.qrmaster.net/manage-qr-codes',
},
},
openGraph: {
title: 'Manage QR Codes - Dashboard, Edits, and Analytics',
description:
'Use one dashboard to review QR codes, edit dynamic destinations, and check scan totals and unique scans.',
url: 'https://www.qrmaster.net/manage-qr-codes',
type: 'website',
images: [
{
url: '/images/og/og-manage-qr-codes.png',
width: 1200,
height: 630,
},
],
},
twitter: {
title: 'Manage QR Codes - Dashboard, Edits, and Analytics',
description:
'Use one dashboard to review QR codes, edit dynamic destinations, and check scan totals and unique scans.',
},
};
const verifiedCapabilities = [
{
title: 'Central dashboard',
description:
'The dashboard lists your QR codes in one place instead of forcing you to manage separate files or links manually.',
},
{
title: 'Dynamic destination edits',
description:
'Dynamic QR codes can be edited after print. Static QR codes remain fixed.',
},
{
title: 'Scan reporting',
description:
'The current dashboard reports total scans, active codes, and unique scans, with analytics pages adding more context.',
},
{
title: 'Plan-based limits',
description:
'Free includes 3 dynamic QR codes, Pro includes 50, and Business includes 500. Static QR codes remain unlimited.',
},
{
title: 'Tags and status',
description:
'QR code records support tags and active status, which helps keep batches and single-code workflows easier to review.',
},
{
title: 'Download and delete actions',
description:
'Each QR code card supports view, download, edit for dynamic QR codes, and delete actions from the dashboard surface.',
},
];
const operationalUseCases = [
{
title: 'Marketing campaigns',
description:
'Review active dynamic QR codes, compare scan totals, and update destinations when campaigns or landing pages change.',
points: ['One list of active codes', 'Scan totals and unique scans', 'Edit dynamic destinations'],
},
{
title: 'Restaurants and hospitality',
description:
'Keep menu or table-card QR codes current from the dashboard instead of reprinting every time the destination changes.',
points: ['Update menu destinations', 'Monitor scan activity', 'Keep print assets in use longer'],
},
{
title: 'Product and packaging workflows',
description:
'Track which QR codes are active, save batches to the dashboard, and separate static bulk output from dynamic campaign codes.',
points: ['Save generated QR codes', 'Review active status', 'Manage static and dynamic codes separately'],
},
{
title: 'Small team or solo workflows',
description:
'Use one account to keep QR code creation, edits, downloads, and analytics in one operational place.',
points: ['Single dashboard view', 'No spreadsheet-only workflow', 'Clear plan limits'],
},
];
const faqItems = [
{
question: 'What does it mean to manage QR codes?',
answer:
'In QR Master, managing QR codes means using one dashboard to review your QR codes, edit dynamic destinations, download files, and check scan activity instead of tracking everything manually.',
},
{
question: 'Can I edit a QR code after printing it?',
answer:
'Yes, if it is a dynamic QR code. Static QR codes stay fixed after creation.',
},
{
question: 'How many dynamic QR codes can I manage?',
answer:
'Free includes 3 dynamic QR codes, Pro includes 50, and Business includes 500. Static QR codes are unlimited.',
},
{
question: 'What analytics are visible today?',
answer:
'The current dashboard shows total scans, active QR codes, and unique scans. Additional analytics views add more scan context such as time, device, and location.',
},
{
question: 'Does the current product include team roles or API-based QR management?',
answer:
'This page only reflects the verified current surface: dashboard management, dynamic edits, analytics, downloads, tags, and plan limits. It does not claim team roles or public API-based QR management as current verified capabilities.',
},
];
const softwareSchema = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
'@id': 'https://www.qrmaster.net/manage-qr-codes#software',
name: 'QR Master - QR Code Management Dashboard',
applicationCategory: 'BusinessApplication',
offers: {
'@type': 'AggregateOffer',
lowPrice: '0',
highPrice: '29',
priceCurrency: 'EUR',
},
featureList: [
'Central QR code dashboard',
'Edit dynamic QR code destinations',
'Review total and unique scan counts',
'Download QR codes as PNG or SVG',
'Tag QR code records and review active status',
'Manage current plan limits for dynamic QR codes',
],
};
const breadcrumbItems: BreadcrumbItem[] = [
{ name: 'Home', url: '/' },
{ name: 'Manage QR Codes', url: '/manage-qr-codes' },
];
export default function ManageQRCodesPage() {
return (
<>
<SeoJsonLd
data={[
softwareSchema,
breadcrumbSchema(breadcrumbItems),
faqPageSchema(faqItems),
]}
/>
<div className="min-h-screen bg-white">
<section className="relative overflow-hidden bg-gradient-to-br from-green-50 via-white to-blue-50 py-20">
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<Breadcrumbs items={breadcrumbItems} />
<div className="mt-8 grid items-center gap-12 lg:grid-cols-2">
<div className="space-y-8">
<div className="inline-flex items-center rounded-full bg-green-100 px-4 py-2 text-sm font-semibold text-green-800">
Dashboard-first QR management
</div>
<div className="space-y-5">
<h1 className="text-5xl font-bold leading-tight text-gray-900 lg:text-6xl">
Manage QR Codes from one dashboard
</h1>
<p className="text-xl leading-relaxed text-gray-600">
Review active QR codes, edit dynamic destinations, download files, and
monitor scan totals from one place instead of managing printed QR workflows manually.
</p>
</div>
<div className="space-y-3">
{[
'See QR codes in one dashboard',
'Edit dynamic destinations after print',
'Review total scans, active codes, and unique scans',
'Work within the current Free, Pro, and Business dynamic QR limits',
].map((feature) => (
<div key={feature} className="flex items-center gap-3">
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-green-500">
<svg className="h-3 w-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
<span className="text-gray-700">{feature}</span>
</div>
))}
</div>
<div className="flex flex-col gap-4 sm:flex-row">
<Link href="/signup">
<Button size="lg" className="w-full px-8 py-4 text-lg sm:w-auto">
Get Started Free
</Button>
</Link>
<Link href="/pricing">
<Button variant="outline" size="lg" className="w-full px-8 py-4 text-lg sm:w-auto">
View Pricing
</Button>
</Link>
</div>
</div>
<div className="relative">
<Card className="p-6 shadow-2xl">
<h3 className="mb-4 text-lg font-semibold">Dashboard snapshot</h3>
<div className="space-y-3">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-semibold text-gray-700">Active QR codes</span>
<span className="text-2xl font-bold text-blue-600">3 / 50 / 500</span>
</div>
<div className="text-xs text-gray-600">Plan-based dynamic QR capacity</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border border-green-200 bg-green-50 p-3">
<div className="mb-1 text-xs text-gray-600">Total scans</div>
<div className="text-xl font-bold text-green-600">Dashboard metric</div>
</div>
<div className="rounded-lg border border-purple-200 bg-purple-50 p-3">
<div className="mb-1 text-xs text-gray-600">Unique scans</div>
<div className="text-xl font-bold text-purple-600">Dashboard metric</div>
</div>
</div>
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
<div className="mb-2 text-xs text-gray-600">Available actions</div>
<div className="flex flex-wrap gap-2 text-xs text-gray-700">
<span className="rounded-full bg-white px-3 py-1">View details</span>
<span className="rounded-full bg-white px-3 py-1">Download PNG</span>
<span className="rounded-full bg-white px-3 py-1">Download SVG</span>
<span className="rounded-full bg-white px-3 py-1">Edit dynamic QR</span>
<span className="rounded-full bg-white px-3 py-1">Delete</span>
</div>
</div>
</div>
</Card>
</div>
</div>
</div>
</section>
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<AnswerFirstBlock
whatIsIt="QR Master management is a dashboard workflow for reviewing QR codes, editing dynamic destinations, downloading files, and checking scan activity from one place. It is most useful when your QR program is active enough that single-file handling becomes messy."
whenToUse={[
'You need one place to review active QR codes and their scan totals',
'You want to edit dynamic QR destinations after print without replacing the QR image',
'You need to keep static and dynamic QR workflows organized around current plan limits',
]}
comparison={{
leftTitle: 'Manual handling',
rightTitle: 'Dashboard management',
items: [
{ label: 'See active QR codes in one place', value: true, text: 'Scattered across files or exports' },
{ label: 'Edit dynamic destinations later', value: true, text: 'Not possible outside dynamic QR management' },
{ label: 'Review scan totals and unique scans', value: true, text: 'No unified dashboard view' },
],
}}
howTo={{
steps: [
'Create or save QR codes into your QR Master account',
'Open the dashboard to review active codes, scans, and available actions',
'Edit dynamic destinations or download the QR files you need for the next workflow',
],
}}
/>
</div>
<div className="container mx-auto max-w-5xl px-4 pb-8 sm:px-6 lg:px-8">
<FAQSection items={faqItems} title="QR management questions" />
</div>
<section className="bg-gray-50 py-20">
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="mb-16 text-center">
<h2 className="mb-4 text-4xl font-bold text-gray-900">What the current dashboard supports</h2>
<p className="mx-auto max-w-3xl text-xl text-gray-600">
These capabilities are tied to the present product surface rather than future or inferred roadmap features.
</p>
</div>
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{verifiedCapabilities.map((feature) => (
<Card key={feature.title} className="p-6 transition-shadow hover:shadow-lg">
<h3 className="mb-2 text-xl font-semibold text-gray-900">{feature.title}</h3>
<p className="text-gray-600">{feature.description}</p>
</Card>
))}
</div>
</div>
</section>
<section className="py-20">
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="mb-16 text-center">
<h2 className="mb-4 text-4xl font-bold text-gray-900">Where QR management is most useful</h2>
<p className="mx-auto max-w-3xl text-xl text-gray-600">
Use the dashboard when your QR workflows need ongoing edits, downloads, and visibility instead of one-off creation.
</p>
</div>
<div className="grid gap-8 md:grid-cols-2">
{operationalUseCases.map((useCase) => (
<Card key={useCase.title} className="p-8">
<h3 className="mb-3 text-2xl font-bold text-gray-900">{useCase.title}</h3>
<p className="mb-6 text-gray-600">{useCase.description}</p>
<ul className="space-y-2">
{useCase.points.map((point) => (
<li key={point} className="flex items-center gap-2">
<svg className="h-5 w-5 flex-shrink-0 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-gray-700">{point}</span>
</li>
))}
</ul>
</Card>
))}
</div>
</div>
</section>
<section className="bg-gradient-to-r from-green-600 to-blue-600 py-20 text-white">
<div className="container mx-auto max-w-5xl px-4 text-center sm:px-6 lg:px-8">
<h2 className="mb-6 text-4xl font-bold">Start managing your QR codes with one account</h2>
<p className="mb-8 text-xl text-green-100">
Keep dynamic updates, downloads, and scan reporting in one dashboard instead of spreading the workflow across files and ad hoc links.
</p>
<div className="flex flex-col justify-center gap-4 sm:flex-row">
<Link href="/signup">
<Button
size="lg"
variant="secondary"
className="w-full bg-white px-8 py-4 text-lg text-green-600 hover:bg-gray-100 sm:w-auto"
>
Get Started Free
</Button>
</Link>
<Link href="/pricing">
<Button
size="lg"
variant="outline"
className="w-full border-white px-8 py-4 text-lg text-white hover:bg-white/10 sm:w-auto"
>
View Pricing
</Button>
</Link>
</div>
</div>
</section>
</div>
</>
);
}

View File

@@ -1,118 +1,118 @@
import React from 'react';
import type { Metadata } from 'next';
import PricingClient from './PricingClient';
import SeoJsonLd from '@/components/SeoJsonLd';
import { AnswerFirstBlock } from '@/components/marketing/AnswerFirstBlock';
import { FAQSection } from '@/components/aeo/FAQSection';
import { breadcrumbSchema, faqPageSchema } from '@/lib/schema';
export const metadata: Metadata = {
title: {
absolute: 'Pricing Plans | QR Master',
},
description:
'Compare QR Master pricing plans. Free includes 3 active dynamic QR codes, Pro includes 50, and Business includes 500 plus bulk QR creation.',
alternates: {
canonical: 'https://www.qrmaster.net/pricing',
},
robots: {
index: true,
follow: true,
},
openGraph: {
title: 'Pricing Plans | QR Master',
description:
'Compare QR Master pricing plans. Free includes 3 active dynamic QR codes, Pro includes 50, and Business includes 500 plus bulk QR creation.',
url: 'https://www.qrmaster.net/pricing',
type: 'website',
images: [
{
url: 'https://www.qrmaster.net/og-image.png',
width: 1200,
height: 630,
alt: 'QR Master Pricing Plans',
},
],
},
};
const faqItems = [
{
question: 'How many dynamic QR codes are included in each plan?',
answer:
'Free includes 3 active dynamic QR codes. Pro includes 50 dynamic QR codes. Business includes 500 dynamic QR codes.',
},
{
question: 'Do all plans include static QR codes?',
answer:
'Yes. All plans include unlimited static QR codes.',
},
{
question: 'Which plan includes bulk QR creation?',
answer:
'Bulk QR creation is included in the Business plan.',
},
{
question: 'Which plans include analytics and branding?',
answer:
'The Free plan includes basic scan tracking. Pro adds advanced analytics and custom branding. Business includes everything from Pro plus bulk QR creation and priority email support.',
},
];
const breadcrumbItems = [
{ name: 'Home', url: '/' },
{ name: 'Pricing', url: '/pricing' },
];
export default function PricingPage() {
return (
<>
<SeoJsonLd data={[breadcrumbSchema(breadcrumbItems), faqPageSchema(faqItems)]} />
<div className="bg-white">
<div className="container mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<AnswerFirstBlock
whatIsIt="QR Master pricing is organized around how many active dynamic QR codes you need and whether you need advanced analytics, branding, or bulk creation. Free includes 3 active dynamic QR codes, Pro includes 50, and Business includes 500 plus bulk QR creation."
whenToUse={[
'Choose Free when you need a small number of active dynamic QR codes and unlimited static QR codes',
'Choose Pro when you need more active dynamic QR codes plus advanced analytics and custom branding',
'Choose Business when you need 500 active dynamic QR codes and the bulk QR creation workflow',
]}
comparison={{
leftTitle: 'Lower plans',
rightTitle: 'Higher plan adds',
items: [
{ label: 'Dynamic QR capacity', value: true, text: '3 or 50 active codes' },
{ label: 'Bulk QR creation', value: true, text: 'Not included before Business' },
{ label: 'Advanced analytics and branding', value: true, text: 'Basic or Pro-level only' },
],
}}
howTo={{
steps: [
'Estimate how many active dynamic QR codes you need at one time',
'Decide whether you also need advanced analytics, branding, or bulk creation',
'Choose the plan that matches the current workflow instead of paying for unused capacity',
],
}}
/>
</div>
<div className="container mx-auto max-w-5xl px-4 pb-4 sm:px-6 lg:px-8">
<FAQSection items={faqItems} title="Pricing questions" />
</div>
<div className="sr-only">
<h2>Compare our plans</h2>
<p>
Free includes 3 active dynamic QR codes and unlimited static QR codes. Pro
includes 50 dynamic QR codes, advanced analytics, and custom branding.
Business includes 500 dynamic QR codes, everything from Pro, bulk QR creation
up to 1,000, and priority email support.
</p>
</div>
<PricingClient />
</div>
</>
);
}
import React from 'react';
import type { Metadata } from 'next';
import PricingClient from './PricingClient';
import SeoJsonLd from '@/components/SeoJsonLd';
import { AnswerFirstBlock } from '@/components/marketing/AnswerFirstBlock';
import { FAQSection } from '@/components/aeo/FAQSection';
import { breadcrumbSchema, faqPageSchema } from '@/lib/schema';
export const metadata: Metadata = {
title: {
absolute: 'Pricing Plans | QR Master',
},
description:
'Compare QR Master pricing plans. Free includes 3 active dynamic QR codes, Pro includes 50, and Business includes 500 plus bulk QR creation.',
alternates: {
canonical: 'https://www.qrmaster.net/pricing',
},
robots: {
index: true,
follow: true,
},
openGraph: {
title: 'Pricing Plans | QR Master',
description:
'Compare QR Master pricing plans. Free includes 3 active dynamic QR codes, Pro includes 50, and Business includes 500 plus bulk QR creation.',
url: 'https://www.qrmaster.net/pricing',
type: 'website',
images: [
{
url: 'https://www.qrmaster.net/og-image.png',
width: 1200,
height: 630,
alt: 'QR Master Pricing Plans',
},
],
},
};
const faqItems = [
{
question: 'How many dynamic QR codes are included in each plan?',
answer:
'Free includes 3 active dynamic QR codes. Pro includes 50 dynamic QR codes. Business includes 500 dynamic QR codes.',
},
{
question: 'Do all plans include static QR codes?',
answer:
'Yes. All plans include unlimited static QR codes.',
},
{
question: 'Which plan includes bulk QR creation?',
answer:
'Bulk QR creation is included in the Business plan.',
},
{
question: 'Which plans include analytics and branding?',
answer:
'The Free plan includes basic scan tracking. Pro adds advanced analytics and custom branding. Business includes everything from Pro plus bulk QR creation and priority email support.',
},
];
const breadcrumbItems = [
{ name: 'Home', url: '/' },
{ name: 'Pricing', url: '/pricing' },
];
export default function PricingPage() {
return (
<>
<SeoJsonLd data={[breadcrumbSchema(breadcrumbItems), faqPageSchema(faqItems)]} />
<div className="bg-white">
<div className="container mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<AnswerFirstBlock
whatIsIt="QR Master pricing is organized around how many active dynamic QR codes you need and whether you need advanced analytics, branding, or bulk creation. Free includes 3 active dynamic QR codes, Pro includes 50, and Business includes 500 plus bulk QR creation."
whenToUse={[
'Choose Free when you need a small number of active dynamic QR codes and unlimited static QR codes',
'Choose Pro when you need more active dynamic QR codes plus advanced analytics and custom branding',
'Choose Business when you need 500 active dynamic QR codes and the bulk QR creation workflow',
]}
comparison={{
leftTitle: 'Lower plans',
rightTitle: 'Higher plan adds',
items: [
{ label: 'Dynamic QR capacity', value: true, text: '3 or 50 active codes' },
{ label: 'Bulk QR creation', value: true, text: 'Not included before Business' },
{ label: 'Advanced analytics and branding', value: true, text: 'Basic or Pro-level only' },
],
}}
howTo={{
steps: [
'Estimate how many active dynamic QR codes you need at one time',
'Decide whether you also need advanced analytics, branding, or bulk creation',
'Choose the plan that matches the current workflow instead of paying for unused capacity',
],
}}
/>
</div>
<div className="container mx-auto max-w-5xl px-4 pb-4 sm:px-6 lg:px-8">
<FAQSection items={faqItems} title="Pricing questions" />
</div>
<div className="sr-only">
<h2>Compare our plans</h2>
<p>
Free includes 3 active dynamic QR codes and unlimited static QR codes. Pro
includes 50 dynamic QR codes, advanced analytics, and custom branding.
Business includes 500 dynamic QR codes, everything from Pro, bulk QR creation
up to 1,000, and priority email support.
</p>
</div>
<PricingClient />
</div>
</>
);
}

View File

@@ -1,143 +1,143 @@
import type { Metadata } from "next";
import {
buildUseCaseMetadata,
UseCasePageTemplate,
} from "@/components/marketing/UseCasePageTemplate";
export const metadata: Metadata = buildUseCaseMetadata({
title: "QR Code Analytics",
description:
"Measure QR code scans by placement, timing, device context, and campaign route so offline workflows become reportable.",
canonicalPath: "/qr-code-analytics",
});
const softwareSchema = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"@id": "https://www.qrmaster.net/qr-code-analytics#software",
name: "QR Master - QR Code Analytics",
applicationCategory: "BusinessApplication",
operatingSystem: "Web Browser",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
availability: "https://schema.org/InStock",
},
description:
"QR analytics software for measuring scans by placement, timing, device context, and offline campaign routing.",
featureList: [
"Placement-level scan reporting",
"Device and timing context",
"Offline-to-online campaign attribution",
"Scan visibility across print workflows",
"Destination updates without reprinting",
],
};
export default function QRCodeAnalyticsPage() {
return (
<UseCasePageTemplate
title="QR Code Analytics"
description="Measure QR code scans by placement, timing, device context, and campaign route so offline workflows become reportable."
eyebrow="Analytics"
intro="QR code analytics matters when a scan is not just a click, but evidence that a sign, flyer, package, or service prompt is doing its job in the real world."
pageType="commercial"
cluster="qr-analytics"
useCase="qr-analytics"
breadcrumbs={[
{ name: "Home", url: "/" },
{ name: "QR Code Analytics", url: "/qr-code-analytics" },
]}
answer="QR code analytics helps you understand which printed placements, campaigns, and post-scan routes generate useful activity so you can improve what gets reprinted, redistributed, or scaled next."
whenToUse={[
"You need more than raw scan counts from campaigns, packaging, or offline placements.",
"You want to compare where scans happen and which printed surfaces actually drive action.",
"You need a clearer bridge between QR scans and business outcomes such as signup, offers, or support engagement.",
]}
comparisonItems={[
{ label: "Placement visibility", text: "Usually blended", value: true },
{ label: "Post-print reporting", text: "Weak", value: true },
{ label: "Campaign comparison", text: "Manual or partial", value: true },
]}
howToSteps={[
"Create QR flows that map to real placements or workflow contexts instead of one generic code for every use case.",
"Track scans with enough context to compare signs, flyers, inserts, or support surfaces cleanly.",
"Use the reporting to decide which destinations, offers, or print placements deserve the next round of investment.",
]}
primaryCta={{
href: "/signup",
label: "Start measuring QR scans",
}}
secondaryCta={{
href: "/use-cases",
label: "Browse measured workflows",
}}
workflowTitle="What useful QR analytics should help you answer"
workflowIntro="The point of analytics is not to produce dashboards for their own sake. It is to make better decisions about what to print again, where to place it, and what happens after the scan."
workflowCards={[
{
title: "Placement comparison",
description:
"Separate flyer, packaging, sign, event, or service-surface traffic so you know which printed context actually creates useful scan activity.",
},
{
title: "Post-print flexibility",
description:
"Review performance and then improve the destination, offer, or next action without replacing every physical code already in circulation.",
},
{
title: "Operational reporting",
description:
"Give marketing, operations, or support teams a better view of what physical QR programs are doing once they are live in the field.",
},
]}
checklistTitle="QR analytics checklist"
checklist={[
"Define which placements or workflow surfaces should be compared before launching the QR program.",
"Use naming or routing that lets scans be grouped by real business context, not only by one generic campaign.",
"Make the first post-scan step relevant enough that a scan can become a useful action, not a bounce.",
"Review analytics before reprinting so the next batch reflects real-world performance.",
]}
supportLinks={[
{
href: "/use-cases/packaging-qr-codes",
title: "Use case: Packaging QR Codes",
description:
"See how packaging scans can become a measurable post-purchase signal instead of a blind spot.",
},
{
href: "/use-cases/flyer-qr-codes",
title: "Use case: Flyer QR Codes",
description:
"Useful when scan performance needs to be reviewed by distribution point or campaign wave.",
},
{
href: "/blog/trackable-qr-codes",
title: "Trackable QR Codes",
description:
"Support article for understanding what measurable QR setups should capture and why it matters.",
},
]}
faq={[
{
question: "What can QR code analytics show?",
answer:
"QR code analytics can show scan activity by placement, time, device context, and campaign route so teams can see which physical programs are actually performing.",
},
{
question: "Why are QR code analytics useful for offline campaigns?",
answer:
"They help turn offline placements such as flyers, packaging, signs, or event materials into something measurable instead of relying on assumptions about what worked.",
},
{
question: "Do I need dynamic QR codes for analytics?",
answer:
"In most cases yes, because analytics usually depends on a managed redirect or reporting layer that also lets you update destinations without reprinting.",
},
]}
schemaData={[softwareSchema]}
/>
);
}
import type { Metadata } from "next";
import {
buildUseCaseMetadata,
UseCasePageTemplate,
} from "@/components/marketing/UseCasePageTemplate";
export const metadata: Metadata = buildUseCaseMetadata({
title: "QR Code Analytics",
description:
"Measure QR code scans by placement, timing, device context, and campaign route so offline workflows become reportable.",
canonicalPath: "/qr-code-analytics",
});
const softwareSchema = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"@id": "https://www.qrmaster.net/qr-code-analytics#software",
name: "QR Master - QR Code Analytics",
applicationCategory: "BusinessApplication",
operatingSystem: "Web Browser",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
availability: "https://schema.org/InStock",
},
description:
"QR analytics software for measuring scans by placement, timing, device context, and offline campaign routing.",
featureList: [
"Placement-level scan reporting",
"Device and timing context",
"Offline-to-online campaign attribution",
"Scan visibility across print workflows",
"Destination updates without reprinting",
],
};
export default function QRCodeAnalyticsPage() {
return (
<UseCasePageTemplate
title="QR Code Analytics"
description="Measure QR code scans by placement, timing, device context, and campaign route so offline workflows become reportable."
eyebrow="Analytics"
intro="QR code analytics matters when a scan is not just a click, but evidence that a sign, flyer, package, or service prompt is doing its job in the real world."
pageType="commercial"
cluster="qr-analytics"
useCase="qr-analytics"
breadcrumbs={[
{ name: "Home", url: "/" },
{ name: "QR Code Analytics", url: "/qr-code-analytics" },
]}
answer="QR code analytics helps you understand which printed placements, campaigns, and post-scan routes generate useful activity so you can improve what gets reprinted, redistributed, or scaled next."
whenToUse={[
"You need more than raw scan counts from campaigns, packaging, or offline placements.",
"You want to compare where scans happen and which printed surfaces actually drive action.",
"You need a clearer bridge between QR scans and business outcomes such as signup, offers, or support engagement.",
]}
comparisonItems={[
{ label: "Placement visibility", text: "Usually blended", value: true },
{ label: "Post-print reporting", text: "Weak", value: true },
{ label: "Campaign comparison", text: "Manual or partial", value: true },
]}
howToSteps={[
"Create QR flows that map to real placements or workflow contexts instead of one generic code for every use case.",
"Track scans with enough context to compare signs, flyers, inserts, or support surfaces cleanly.",
"Use the reporting to decide which destinations, offers, or print placements deserve the next round of investment.",
]}
primaryCta={{
href: "/signup",
label: "Start measuring QR scans",
}}
secondaryCta={{
href: "/use-cases",
label: "Browse measured workflows",
}}
workflowTitle="What useful QR analytics should help you answer"
workflowIntro="The point of analytics is not to produce dashboards for their own sake. It is to make better decisions about what to print again, where to place it, and what happens after the scan."
workflowCards={[
{
title: "Placement comparison",
description:
"Separate flyer, packaging, sign, event, or service-surface traffic so you know which printed context actually creates useful scan activity.",
},
{
title: "Post-print flexibility",
description:
"Review performance and then improve the destination, offer, or next action without replacing every physical code already in circulation.",
},
{
title: "Operational reporting",
description:
"Give marketing, operations, or support teams a better view of what physical QR programs are doing once they are live in the field.",
},
]}
checklistTitle="QR analytics checklist"
checklist={[
"Define which placements or workflow surfaces should be compared before launching the QR program.",
"Use naming or routing that lets scans be grouped by real business context, not only by one generic campaign.",
"Make the first post-scan step relevant enough that a scan can become a useful action, not a bounce.",
"Review analytics before reprinting so the next batch reflects real-world performance.",
]}
supportLinks={[
{
href: "/use-cases/packaging-qr-codes",
title: "Use case: Packaging QR Codes",
description:
"See how packaging scans can become a measurable post-purchase signal instead of a blind spot.",
},
{
href: "/use-cases/flyer-qr-codes",
title: "Use case: Flyer QR Codes",
description:
"Useful when scan performance needs to be reviewed by distribution point or campaign wave.",
},
{
href: "/blog/trackable-qr-codes",
title: "Trackable QR Codes",
description:
"Support article for understanding what measurable QR setups should capture and why it matters.",
},
]}
faq={[
{
question: "What can QR code analytics show?",
answer:
"QR code analytics can show scan activity by placement, time, device context, and campaign route so teams can see which physical programs are actually performing.",
},
{
question: "Why are QR code analytics useful for offline campaigns?",
answer:
"They help turn offline placements such as flyers, packaging, signs, or event materials into something measurable instead of relying on assumptions about what worked.",
},
{
question: "Do I need dynamic QR codes for analytics?",
answer:
"In most cases yes, because analytics usually depends on a managed redirect or reporting layer that also lets you update destinations without reprinting.",
},
]}
schemaData={[softwareSchema]}
/>
);
}

View File

@@ -1,112 +1,112 @@
import type { Metadata } from "next";
import {
buildUseCaseMetadata,
UseCasePageTemplate,
} from "@/components/marketing/UseCasePageTemplate";
export const metadata: Metadata = buildUseCaseMetadata({
title: "QR Codes for Marketing Campaigns",
description:
"Plan QR codes for marketing campaigns around placement tracking, changing destinations, and offline-to-online attribution.",
canonicalPath: "/qr-code-for-marketing-campaigns",
});
export default function QRCodeForMarketingCampaignsPage() {
return (
<UseCasePageTemplate
title="QR Codes for Marketing Campaigns"
description="Plan QR codes for marketing campaigns around placement tracking, changing destinations, and offline-to-online attribution."
eyebrow="Campaign Workflows"
intro="Marketing campaign QR codes work best when the code on the printed asset stays stable while the destination and attribution model can evolve with the campaign."
pageType="commercial"
cluster="marketing-campaigns"
useCase="marketing-campaigns"
breadcrumbs={[
{ name: "Home", url: "/" },
{
name: "QR Codes for Marketing Campaigns",
url: "/qr-code-for-marketing-campaigns",
},
]}
answer="A campaign QR code should do more than open a page. It should help you compare placements, update the destination when the offer changes, and route offline traffic into a measurable funnel."
whenToUse={[
"You run flyers, posters, packaging inserts, or event signage with campaign-specific CTA copy.",
"You want to compare placements or creatives instead of treating every scan as generic traffic.",
"Your destination may change during the life of the printed campaign.",
]}
comparisonItems={[
{ label: "Offer updates", text: "New print required", value: true },
{ label: "Placement attribution", text: "Often manual", value: true },
{ label: "Creative testing", text: "Hard to manage", value: true },
]}
howToSteps={[
"Create campaign QR flows around one clear action and one named placement context.",
"Use dynamic destinations or tagged URLs so the print stays usable when the offer changes.",
"Measure scans with a clean CTA path into signup, lead capture, or campaign landing pages.",
]}
primaryCta={{
href: "/dynamic-qr-code-generator",
label: "Create a trackable campaign QR",
}}
secondaryCta={{
href: "/use-cases",
label: "Browse use-case workflows",
}}
workflowTitle="What strong campaign QR workflows look like"
workflowIntro="Campaign QR strategy becomes more useful when creative, placement, and destination are treated as a system rather than a single link printed everywhere."
workflowCards={[
{
title: "Placement-aware routing",
description: "Keep banner, flyer, packaging, and in-store placements comparable by using distinct destinations or campaign tags.",
},
{
title: "Post-print flexibility",
description: "Adjust the landing page, offer, or CTA destination after print when the campaign learns something or needs a fast update.",
},
{
title: "Measurement-ready handoff",
description: "Push campaign scans toward signup, booking, or lead-gen paths so the QR is tied to a business outcome instead of a vanity click.",
},
]}
checklistTitle="Campaign QR checklist"
checklist={[
"Match each QR code to one campaign purpose and one primary CTA.",
"Differentiate placements with clean naming or URL tagging before the assets go to print.",
"Use a destination you can update when the promotion, offer, or landing page changes.",
"Link the campaign flow back to a measured CTA path instead of stopping at raw scan counts.",
]}
supportLinks={[
{
href: "/qr-code-tracking",
title: "QR Code Tracking",
description: "Use when the real priority is measuring placement and scanner context.",
},
{
href: "/custom-qr-code-generator",
title: "Custom QR Code Generator",
description: "Useful when brand fit and print creative need more control.",
},
{
href: "/blog/utm-parameter-qr-codes",
title: "UTM Parameters with QR Codes",
description: "Support article for placement naming and campaign attribution strategy.",
},
]}
faq={[
{
question: "Why use QR codes in marketing campaigns?",
answer: "Campaign QR codes help move offline audiences into a measurable online path. They are most useful when the destination and tracking setup are planned before the assets go live.",
},
{
question: "Should campaign QR codes be dynamic?",
answer: "Yes, when the destination, offer, or campaign landing page may change after print. That avoids replacing materials just because the target page changes.",
},
{
question: "How do I track different QR placements in one campaign?",
answer: "Use distinct destinations or tagged URLs for each placement so flyers, posters, booth signs, and packaging inserts can be compared cleanly.",
},
]}
/>
);
}
import type { Metadata } from "next";
import {
buildUseCaseMetadata,
UseCasePageTemplate,
} from "@/components/marketing/UseCasePageTemplate";
export const metadata: Metadata = buildUseCaseMetadata({
title: "QR Codes for Marketing Campaigns",
description:
"Plan QR codes for marketing campaigns around placement tracking, changing destinations, and offline-to-online attribution.",
canonicalPath: "/qr-code-for-marketing-campaigns",
});
export default function QRCodeForMarketingCampaignsPage() {
return (
<UseCasePageTemplate
title="QR Codes for Marketing Campaigns"
description="Plan QR codes for marketing campaigns around placement tracking, changing destinations, and offline-to-online attribution."
eyebrow="Campaign Workflows"
intro="Marketing campaign QR codes work best when the code on the printed asset stays stable while the destination and attribution model can evolve with the campaign."
pageType="commercial"
cluster="marketing-campaigns"
useCase="marketing-campaigns"
breadcrumbs={[
{ name: "Home", url: "/" },
{
name: "QR Codes for Marketing Campaigns",
url: "/qr-code-for-marketing-campaigns",
},
]}
answer="A campaign QR code should do more than open a page. It should help you compare placements, update the destination when the offer changes, and route offline traffic into a measurable funnel."
whenToUse={[
"You run flyers, posters, packaging inserts, or event signage with campaign-specific CTA copy.",
"You want to compare placements or creatives instead of treating every scan as generic traffic.",
"Your destination may change during the life of the printed campaign.",
]}
comparisonItems={[
{ label: "Offer updates", text: "New print required", value: true },
{ label: "Placement attribution", text: "Often manual", value: true },
{ label: "Creative testing", text: "Hard to manage", value: true },
]}
howToSteps={[
"Create campaign QR flows around one clear action and one named placement context.",
"Use dynamic destinations or tagged URLs so the print stays usable when the offer changes.",
"Measure scans with a clean CTA path into signup, lead capture, or campaign landing pages.",
]}
primaryCta={{
href: "/dynamic-qr-code-generator",
label: "Create a trackable campaign QR",
}}
secondaryCta={{
href: "/use-cases",
label: "Browse use-case workflows",
}}
workflowTitle="What strong campaign QR workflows look like"
workflowIntro="Campaign QR strategy becomes more useful when creative, placement, and destination are treated as a system rather than a single link printed everywhere."
workflowCards={[
{
title: "Placement-aware routing",
description: "Keep banner, flyer, packaging, and in-store placements comparable by using distinct destinations or campaign tags.",
},
{
title: "Post-print flexibility",
description: "Adjust the landing page, offer, or CTA destination after print when the campaign learns something or needs a fast update.",
},
{
title: "Measurement-ready handoff",
description: "Push campaign scans toward signup, booking, or lead-gen paths so the QR is tied to a business outcome instead of a vanity click.",
},
]}
checklistTitle="Campaign QR checklist"
checklist={[
"Match each QR code to one campaign purpose and one primary CTA.",
"Differentiate placements with clean naming or URL tagging before the assets go to print.",
"Use a destination you can update when the promotion, offer, or landing page changes.",
"Link the campaign flow back to a measured CTA path instead of stopping at raw scan counts.",
]}
supportLinks={[
{
href: "/qr-code-tracking",
title: "QR Code Tracking",
description: "Use when the real priority is measuring placement and scanner context.",
},
{
href: "/custom-qr-code-generator",
title: "Custom QR Code Generator",
description: "Useful when brand fit and print creative need more control.",
},
{
href: "/blog/utm-parameter-qr-codes",
title: "UTM Parameters with QR Codes",
description: "Support article for placement naming and campaign attribution strategy.",
},
]}
faq={[
{
question: "Why use QR codes in marketing campaigns?",
answer: "Campaign QR codes help move offline audiences into a measurable online path. They are most useful when the destination and tracking setup are planned before the assets go live.",
},
{
question: "Should campaign QR codes be dynamic?",
answer: "Yes, when the destination, offer, or campaign landing page may change after print. That avoids replacing materials just because the target page changes.",
},
{
question: "How do I track different QR placements in one campaign?",
answer: "Use distinct destinations or tagged URLs for each placement so flyers, posters, booth signs, and packaging inserts can be compared cleanly.",
},
]}
/>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,143 +1,143 @@
import React from 'react';
import Link from 'next/link';
import { Metadata } from 'next';
import { Button } from '@/components/ui/Button';
import SeoJsonLd from '@/components/SeoJsonLd';
import { organizationSchema, reviewSchema, aggregateRatingSchema } from '@/lib/schema';
import { testimonials, getAggregateRating } from '@/lib/testimonial-data';
import { Testimonials } from '@/components/marketing/Testimonials';
import { Star } from 'lucide-react';
function truncateAtWord(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
const truncated = text.slice(0, maxLength);
const lastSpace = truncated.lastIndexOf(' ');
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
}
export async function generateMetadata(): Promise<Metadata> {
const title = truncateAtWord('Customer Testimonials | QR Master Reviews', 60);
const description = truncateAtWord(
'Read what our customers say about QR Master. Real reviews from businesses using dynamic QR codes for restaurants, pottery, retail, events, and more.',
160
);
return {
title,
description,
keywords: ['qr master reviews', 'qr code testimonials', 'customer reviews', 'qr code generator reviews', 'dynamic qr code reviews'],
alternates: {
canonical: 'https://www.qrmaster.net/testimonials',
},
openGraph: {
title,
description,
url: 'https://www.qrmaster.net/testimonials',
type: 'website',
images: [
{
url: 'https://www.qrmaster.net/og-image.png',
width: 1200,
height: 630,
alt: 'QR Master Customer Testimonials',
},
],
},
twitter: {
title,
description,
},
};
}
export default function TestimonialsPage() {
const aggregateRating = getAggregateRating();
const reviewSchemas = testimonials.map(t => reviewSchema(t));
return (
<>
<SeoJsonLd data={[
organizationSchema(),
aggregateRatingSchema(aggregateRating),
...reviewSchemas
]} />
<div className="bg-white">
{/* Hero Section with Aggregate Rating */}
<section className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-white to-purple-50 py-20 sm:py-24">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-5xl text-center">
<h1 className="text-4xl sm:text-5xl font-bold text-gray-900 leading-tight mb-6">
Customer <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-purple-600">Testimonials</span>
</h1>
<p className="text-xl text-gray-600 max-w-2xl mx-auto mb-8 leading-relaxed">
Real experiences from businesses using QR Master to create dynamic QR codes
</p>
{/* Aggregate Rating Display */}
<div className="flex flex-col items-center justify-center gap-3 mb-10">
<div className="flex gap-1" aria-label={`${aggregateRating.ratingValue} out of 5 stars`}>
{[...Array(5)].map((_, index) => (
<Star
key={index}
className={`w-8 h-8 ${index < aggregateRating.ratingValue
? 'fill-yellow-400 text-yellow-400'
: 'fill-gray-200 text-gray-200'
}`}
/>
))}
</div>
<p className="text-lg text-gray-700">
<span className="font-bold text-2xl">{aggregateRating.ratingValue}</span> out of 5 stars
</p>
<p className="text-sm text-gray-500">
Based on {aggregateRating.reviewCount} {aggregateRating.reviewCount === 1 ? 'review' : 'reviews'}
</p>
</div>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<Link href="/signup">
<Button size="lg" className="text-lg px-8 py-6 shadow-lg shadow-blue-500/25">
Get Started Free
</Button>
</Link>
</div>
</div>
</section>
{/* Testimonials Grid */}
<Testimonials
testimonials={testimonials}
showAll={true}
title="What Our Customers Are Saying"
subtitle="Discover how businesses use QR Master for their unique needs"
/>
{/* CTA Section */}
<section className="py-20 bg-gradient-to-br from-blue-50 via-white to-purple-50">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl text-center">
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-6">
Ready to create your own QR codes?
</h2>
<p className="text-xl text-gray-600 mb-10 max-w-2xl mx-auto">
Join businesses using QR Master to create dynamic, trackable QR codes for their products, menus, events, and campaigns.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<Link href="/signup">
<Button size="lg" className="text-lg px-8 py-6">
Start Free Today
</Button>
</Link>
<Link href="/pricing">
<Button variant="outline" size="lg" className="text-lg px-8 py-6">
View Pricing
</Button>
</Link>
</div>
</div>
</section>
</div>
</>
);
}
import React from 'react';
import Link from 'next/link';
import { Metadata } from 'next';
import { Button } from '@/components/ui/Button';
import SeoJsonLd from '@/components/SeoJsonLd';
import { organizationSchema, reviewSchema, aggregateRatingSchema } from '@/lib/schema';
import { testimonials, getAggregateRating } from '@/lib/testimonial-data';
import { Testimonials } from '@/components/marketing/Testimonials';
import { Star } from 'lucide-react';
function truncateAtWord(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
const truncated = text.slice(0, maxLength);
const lastSpace = truncated.lastIndexOf(' ');
return lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated;
}
export async function generateMetadata(): Promise<Metadata> {
const title = truncateAtWord('Customer Testimonials | QR Master Reviews', 60);
const description = truncateAtWord(
'Read what our customers say about QR Master. Real reviews from businesses using dynamic QR codes for restaurants, pottery, retail, events, and more.',
160
);
return {
title,
description,
keywords: ['qr master reviews', 'qr code testimonials', 'customer reviews', 'qr code generator reviews', 'dynamic qr code reviews'],
alternates: {
canonical: 'https://www.qrmaster.net/testimonials',
},
openGraph: {
title,
description,
url: 'https://www.qrmaster.net/testimonials',
type: 'website',
images: [
{
url: 'https://www.qrmaster.net/og-image.png',
width: 1200,
height: 630,
alt: 'QR Master Customer Testimonials',
},
],
},
twitter: {
title,
description,
},
};
}
export default function TestimonialsPage() {
const aggregateRating = getAggregateRating();
const reviewSchemas = testimonials.map(t => reviewSchema(t));
return (
<>
<SeoJsonLd data={[
organizationSchema(),
aggregateRatingSchema(aggregateRating),
...reviewSchemas
]} />
<div className="bg-white">
{/* Hero Section with Aggregate Rating */}
<section className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-white to-purple-50 py-20 sm:py-24">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-5xl text-center">
<h1 className="text-4xl sm:text-5xl font-bold text-gray-900 leading-tight mb-6">
Customer <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-purple-600">Testimonials</span>
</h1>
<p className="text-xl text-gray-600 max-w-2xl mx-auto mb-8 leading-relaxed">
Real experiences from businesses using QR Master to create dynamic QR codes
</p>
{/* Aggregate Rating Display */}
<div className="flex flex-col items-center justify-center gap-3 mb-10">
<div className="flex gap-1" aria-label={`${aggregateRating.ratingValue} out of 5 stars`}>
{[...Array(5)].map((_, index) => (
<Star
key={index}
className={`w-8 h-8 ${index < aggregateRating.ratingValue
? 'fill-yellow-400 text-yellow-400'
: 'fill-gray-200 text-gray-200'
}`}
/>
))}
</div>
<p className="text-lg text-gray-700">
<span className="font-bold text-2xl">{aggregateRating.ratingValue}</span> out of 5 stars
</p>
<p className="text-sm text-gray-500">
Based on {aggregateRating.reviewCount} {aggregateRating.reviewCount === 1 ? 'review' : 'reviews'}
</p>
</div>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<Link href="/signup">
<Button size="lg" className="text-lg px-8 py-6 shadow-lg shadow-blue-500/25">
Get Started Free
</Button>
</Link>
</div>
</div>
</section>
{/* Testimonials Grid */}
<Testimonials
testimonials={testimonials}
showAll={true}
title="What Our Customers Are Saying"
subtitle="Discover how businesses use QR Master for their unique needs"
/>
{/* CTA Section */}
<section className="py-20 bg-gradient-to-br from-blue-50 via-white to-purple-50">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl text-center">
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-6">
Ready to create your own QR codes?
</h2>
<p className="text-xl text-gray-600 mb-10 max-w-2xl mx-auto">
Join businesses using QR Master to create dynamic, trackable QR codes for their products, menus, events, and campaigns.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<Link href="/signup">
<Button size="lg" className="text-lg px-8 py-6">
Start Free Today
</Button>
</Link>
<Link href="/pricing">
<Button variant="outline" size="lg" className="text-lg px-8 py-6">
View Pricing
</Button>
</Link>
</div>
</div>
</section>
</div>
</>
);
}

View File

@@ -0,0 +1,179 @@
"use client";
import { useState } from 'react';
type Step = 'use-case' | 'region' | 'result';
type Result = {
format: string;
label: string;
description: string;
example: string;
color: string;
};
const RESULTS: Record<string, Result> = {
'ean13': {
format: 'EAN-13',
label: 'EAN-13',
description: 'The global retail standard. Used on consumer products sold in supermarkets, pharmacies, and online shops worldwide.',
example: '4006381333931 (a common product barcode)',
color: 'blue',
},
'upca': {
format: '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.',
example: '012345678905',
color: 'indigo',
},
'code128': {
format: '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.',
example: 'SHIP-2026-ABC-001',
color: 'emerald',
},
'code39': {
format: '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.',
example: 'PART-7734-A',
color: 'orange',
},
'msi': {
format: 'MSI',
label: 'MSI',
description: 'Designed for inventory and shelf labeling in retail warehouses. Numeric-only. Used for bin locations, shelf tags, and stockroom management.',
example: '123456',
color: 'purple',
},
'pharmacode': {
format: 'Pharmacode',
label: 'Pharmacode',
description: 'A pharmaceutical packaging standard used to verify correct product packaging. Encodes a single numeric value (3131071).',
example: '12345',
color: 'red',
},
};
const colorMap: Record<string, string> = {
blue: 'bg-blue-50 border-blue-300 text-blue-900',
indigo: 'bg-indigo-50 border-indigo-300 text-indigo-900',
emerald: 'bg-emerald-50 border-emerald-300 text-emerald-900',
orange: 'bg-orange-50 border-orange-300 text-orange-900',
purple: 'bg-purple-50 border-purple-300 text-purple-900',
red: 'bg-red-50 border-red-300 text-red-900',
};
const badgeMap: Record<string, string> = {
blue: 'bg-blue-100 text-blue-800',
indigo: 'bg-indigo-100 text-indigo-800',
emerald: 'bg-emerald-100 text-emerald-800',
orange: 'bg-orange-100 text-orange-800',
purple: 'bg-purple-100 text-purple-800',
red: 'bg-red-100 text-red-800',
};
export function BarcodeFormatPicker() {
const [step, setStep] = useState<Step>('use-case');
const [useCase, setUseCase] = useState<string>('');
const [result, setResult] = useState<string>('');
const selectUseCase = (value: string) => {
setUseCase(value);
if (value === 'retail') {
setStep('region');
} else {
const map: Record<string, string> = {
logistics: 'code128',
inventory: 'code128',
industrial: 'code39',
warehouse: 'msi',
pharma: 'pharmacode',
};
setResult(map[value] ?? 'code128');
setStep('result');
}
};
const selectRegion = (region: string) => {
setResult(region === 'us' ? 'upca' : 'ean13');
setStep('result');
};
const reset = () => {
setStep('use-case');
setUseCase('');
setResult('');
};
const res = result ? RESULTS[result] : null;
return (
<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>
<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' && (
<div>
<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">
{[
{ value: 'retail', label: 'Retail products', sub: 'Selling in stores or online' },
{ value: 'logistics', label: 'Shipping & logistics', sub: 'Parcel labels, supply chain' },
{ value: 'inventory', label: 'Inventory tracking', sub: 'Internal stock management' },
{ value: 'industrial', label: 'Industrial / automotive', sub: 'Manufacturing, defense' },
{ value: 'warehouse', label: 'Shelf & bin labeling', sub: 'Warehouse locations' },
{ value: 'pharma', label: 'Pharmaceutical packaging', sub: 'Medication packaging control' },
].map((opt) => (
<button
key={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"
>
<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>
</button>
))}
</div>
</div>
)}
{step === 'region' && (
<div>
<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">
<button
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"
>
<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>
</button>
<button
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"
>
<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>
</button>
</div>
<button onClick={reset} className="mt-3 text-xs text-slate-400 hover:text-slate-600 underline"> Start over</button>
</div>
)}
{step === 'result' && res && (
<div className={`rounded-xl border-2 p-5 ${colorMap[res.color]}`}>
<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="font-bold text-xl">{res.label}</span>
</div>
<p className="text-sm mb-2">{res.description}</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>
</div>
)}
</div>
);
}

View File

@@ -1,5 +1,7 @@
import { BookOpen, CheckCircle, HelpCircle, Layers, Settings, ShoppingCart, Tag, Activity, Factory } from 'lucide-react';
import { BookOpen, CheckCircle, Layers, Settings, ShoppingCart, Tag, Activity, Factory } from 'lucide-react';
import Link from 'next/link';
import { FAQSection } from '@/components/aeo/FAQSection';
import { BarcodeFormatPicker } from './BarcodeFormatPicker';
export function BarcodeGuide() {
return (
@@ -87,100 +89,59 @@ export function BarcodeGuide() {
Different barcode formats are used for different purposes. Choosing the right one is important for compatibility and scanning accuracy.
</p>
<div className="grid md:grid-cols-2 gap-6 not-prose my-8">
{/* EAN-13 Card */}
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-center gap-3 mb-3">
<Tag className="w-5 h-5 text-blue-500" />
<h4 className="text-lg font-bold text-slate-900 m-0">EAN-13</h4>
</div>
<div className="text-xs font-mono bg-slate-100 inline-block px-2 py-1 rounded text-slate-500 mb-3">Retail Europe</div>
<div className="mb-3 bg-slate-50 rounded border border-slate-100 p-2 flex justify-center">
<img src="/barcode-generator-preview.png" alt="EAN-13 Barcode Sample for International Products" className="h-10 object-contain opacity-75 grayscale" width="200" height="40" />
</div>
<p className="text-sm text-slate-600 m-0">
EAN-13 is widely used in retail, especially in Europe. It is designed for consumer products sold in stores and supermarkets.
</p>
</div>
{/* UPC-A Card */}
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-center gap-3 mb-3">
<ShoppingCart className="w-5 h-5 text-indigo-500" />
<h4 className="text-lg font-bold text-slate-900 m-0">UPC-A</h4>
</div>
<div className="text-xs font-mono bg-slate-100 inline-block px-2 py-1 rounded text-slate-500 mb-3">Retail USA/Canada</div>
<div className="mb-3 bg-slate-50 rounded border border-slate-100 p-2 flex justify-center">
<img src="/barcode-generator-preview.png" alt="UPC-A Barcode Example for Retail Products in USA" className="h-10 object-contain opacity-75 grayscale" width="200" height="40" />
</div>
<p className="text-sm text-slate-600 m-0">
UPC-A is similar to EAN-13 but is mainly used in the United States and Canada for retail products.
</p>
</div>
{/* Code 128 Card */}
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-center gap-3 mb-3">
<Settings className="w-5 h-5 text-emerald-500" />
<h4 className="text-lg font-bold text-slate-900 m-0">Code 128</h4>
</div>
<div className="text-xs font-mono bg-slate-100 inline-block px-2 py-1 rounded text-slate-500 mb-3">Logistics Universal</div>
<div className="mb-3 bg-slate-50 rounded border border-slate-100 p-2 flex justify-center">
<img src="/barcode-generator-preview.png" alt="Code 128 Barcode for Inventory and Shipping Labels" className="h-10 object-contain opacity-75 grayscale" width="200" height="40" />
</div>
<p className="text-sm text-slate-600 m-0">
Code 128 is a flexible barcode format that supports letters and numbers. It is commonly used in logistics, shipping, and internal tracking systems.
</p>
</div>
{/* Code 39 Card */}
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-center gap-3 mb-3">
<Factory className="w-5 h-5 text-orange-500" />
<h4 className="text-lg font-bold text-slate-900 m-0">Code 39</h4>
</div>
<div className="text-xs font-mono bg-slate-100 inline-block px-2 py-1 rounded text-slate-500 mb-3">Industrial Military</div>
<div className="mb-3 bg-slate-50 rounded border border-slate-100 p-2 flex justify-center">
<img src="/barcode-generator-preview.png" alt="Code 39 Barcode for Industrial Use" className="h-10 object-contain opacity-75 grayscale" width="200" height="40" />
</div>
<p className="text-sm text-slate-600 m-0">
The first alphanumeric barcode, Code 39 is still widely used in automotive and defense industries. It supports numbers and uppercase letters.
</p>
</div>
{/* MSI Card */}
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-center gap-3 mb-3">
<Layers className="w-5 h-5 text-purple-500" />
<h4 className="text-lg font-bold text-slate-900 m-0">MSI</h4>
</div>
<div className="text-xs font-mono bg-slate-100 inline-block px-2 py-1 rounded text-slate-500 mb-3">Inventory Shelves</div>
<div className="mb-3 bg-slate-50 rounded border border-slate-100 p-2 flex justify-center">
<img src="/barcode-generator-preview.png" alt="MSI Barcode for Inventory Management" className="h-10 object-contain opacity-75 grayscale" width="200" height="40" />
</div>
<p className="text-sm text-slate-600 m-0">
MSI (Modified Plessey) is often used for inventory control in retail environments, such as labeling shelves in supermarkets and warehouses.
</p>
</div>
{/* Pharmacode Card */}
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-center gap-3 mb-3">
<Activity className="w-5 h-5 text-red-500" />
<h4 className="text-lg font-bold text-slate-900 m-0">Pharmacode</h4>
</div>
<div className="text-xs font-mono bg-slate-100 inline-block px-2 py-1 rounded text-slate-500 mb-3">Pharma Packaging</div>
<div className="mb-3 bg-slate-50 rounded border border-slate-100 p-2 flex justify-center">
<img src="/barcode-generator-preview.png" alt="Pharmacode for Pharmaceutical Packaging" className="h-10 object-contain opacity-75 grayscale" width="200" height="40" />
</div>
<p className="text-sm text-slate-600 m-0">
Pharmacode is a specialized barcode standard used in the pharmaceutical industry for packaging control to prevent medication errors.
</p>
</div>
<div className="not-prose overflow-x-auto my-8">
<table className="w-full text-sm border-collapse rounded-xl overflow-hidden border border-slate-200">
<thead>
<tr className="bg-slate-900 text-white">
<th className="text-left p-3 font-semibold">Format</th>
<th className="text-left p-3 font-semibold">Use Case</th>
<th className="text-left p-3 font-semibold">Digits / Chars</th>
<th className="text-left p-3 font-semibold">Region</th>
</tr>
</thead>
<tbody>
<tr className="border-t border-slate-100 bg-white">
<td className="p-3 font-bold text-blue-700">EAN-13</td>
<td className="p-3 text-slate-600">Retail products, supermarkets</td>
<td className="p-3 text-slate-500 font-mono text-xs">13 numeric</td>
<td className="p-3 text-slate-600">Europe / Global</td>
</tr>
<tr className="border-t border-slate-100 bg-slate-50">
<td className="p-3 font-bold text-indigo-700">UPC-A</td>
<td className="p-3 text-slate-600">Retail products (North America)</td>
<td className="p-3 text-slate-500 font-mono text-xs">12 numeric</td>
<td className="p-3 text-slate-600">USA / Canada</td>
</tr>
<tr className="border-t border-slate-100 bg-white">
<td className="p-3 font-bold text-emerald-700">Code 128</td>
<td className="p-3 text-slate-600">Shipping, logistics, inventory</td>
<td className="p-3 text-slate-500 font-mono text-xs">Variable alphanumeric</td>
<td className="p-3 text-slate-600">Universal</td>
</tr>
<tr className="border-t border-slate-100 bg-slate-50">
<td className="p-3 font-bold text-orange-700">Code 39</td>
<td className="p-3 text-slate-600">Industrial, automotive, defense</td>
<td className="p-3 text-slate-500 font-mono text-xs">Variable alphanumeric</td>
<td className="p-3 text-slate-600">Industrial</td>
</tr>
<tr className="border-t border-slate-100 bg-white">
<td className="p-3 font-bold text-purple-700">MSI</td>
<td className="p-3 text-slate-600">Shelf / bin labeling, warehouse</td>
<td className="p-3 text-slate-500 font-mono text-xs">Variable numeric</td>
<td className="p-3 text-slate-600">Retail / Warehouse</td>
</tr>
<tr className="border-t border-slate-100 bg-slate-50">
<td className="p-3 font-bold text-red-700">Pharmacode</td>
<td className="p-3 text-slate-600">Pharmaceutical packaging</td>
<td className="p-3 text-slate-500 font-mono text-xs">3131071 numeric</td>
<td className="p-3 text-slate-600">Pharma</td>
</tr>
</tbody>
</table>
</div>
<BarcodeFormatPicker />
<h2>Why Use a Barcode Generator?</h2>
<p>Using a Barcode Generator offers several advantages:</p>
<div className="not-prose grid gap-4 mb-8">
@@ -244,8 +205,20 @@ export function BarcodeGuide() {
</ul>
<p>
A reliable <strong>Barcode Generator</strong> helps streamline these processes and improves efficiency.
For tracking QR codes alongside your barcodes, see our <Link href="/blog/qr-code-tracking-guide-2025" className="text-blue-600 hover:underline">QR code tracking guide</Link>.
</p>
<h2>Barcode Generator for Amazon Sellers</h2>
<p>
If you sell on Amazon, you will encounter two types of barcodes: <strong>GTINs</strong> (Global Trade Item Numbers, such as EAN-13 or UPC-A) required by Amazon to list products, and <strong>FNSKU</strong> barcodes that Amazon assigns to your specific seller account for FBA fulfillment.
</p>
<p>
Our barcode generator can create the <em>image</em> of an EAN-13 or UPC-A barcode if you already have a valid number. However, it cannot issue official GS1-registered numbers. To sell on Amazon with retail barcodes, you need to obtain a legitimate EAN or UPC number from <strong>GS1</strong> (the official barcode standards organization). Purchasing unofficial or recycled barcodes from third-party resellers often leads to listing suppression on Amazon.
</p>
<div className="not-prose bg-amber-50 border border-amber-200 rounded-xl p-4 my-4 text-sm text-amber-900">
<strong>Important for Amazon sellers:</strong> GS1 is the only authorized source for EAN/UPC numbers recognized by Amazon. Visit <strong>gs1.org</strong> to purchase official barcodes for your products. Use this generator to create barcode images once you have a valid number.
</div>
<h2>Understanding Check Digits</h2>
<p>
Most barcodes (like EAN and UPC) include a "Check Digit"the last number in the sequence. This digit is calculated mathematically from the other numbers to ensure the barcode is scanned correctly. Even if a barcode is slightly damaged or scratched, the scanner uses the check digit to verify the integrity of the data.
@@ -264,48 +237,44 @@ export function BarcodeGuide() {
<hr className="my-12 border-slate-200" />
<div className="flex items-center gap-3 mb-6 not-prose">
<HelpCircle className="w-6 h-6 text-blue-500" />
<h2 className="text-2xl font-bold text-slate-900 m-0">Frequently Asked Questions (FAQ)</h2>
</div>
<div className="not-prose space-y-8">
<div>
<h5 className="font-bold text-slate-900 text-lg mb-2"> What is a Barcode Generator?</h5>
<p className="text-slate-600">A Barcode Generator is an online tool that converts numbers or text into scannable barcode images that can be used for products, labels, and inventory systems.</p>
</div>
<div>
<h5 className="font-bold text-slate-900 text-lg mb-2"> Is this barcode generator free to use?</h5>
<p className="text-slate-600">Yes, our online barcode generator is completely free to use with no hidden costs or sign-ups required. You can generate, download, and print barcodes instantly.</p>
</div>
<div>
<h5 className="font-bold text-slate-900 text-lg mb-2"> Which barcode format should I use?</h5>
<p className="text-slate-600">
<strong>EAN-13:</strong> Standard for retail products in Europe and globally.<br />
<strong>UPC-A:</strong> Standard for retail products in USA/Canada.<br />
<strong>Code 128:</strong> Best for logistics, shipping, and internal tracking (supports letters & numbers).
</p>
</div>
<div>
<h5 className="font-bold text-slate-900 text-lg mb-2"> Can I download barcodes in vector format (SVG)?</h5>
<p className="text-slate-600">Yes! We offer <strong>SVG downloads</strong>. SVG files are vector-based, meaning they can be scaled to any size without losing qualityperfect for professional product packaging.</p>
</div>
<div>
<h5 className="font-bold text-slate-900 text-lg mb-2"> How do I generate a barcode online?</h5>
<p className="text-slate-600">To generate a barcode online, enter your product number or text, select the desired barcode format (such as EAN-13 or Code 128), and click the generate button. The barcode will be created instantly.</p>
</div>
<div>
<h5 className="font-bold text-slate-900 text-lg mb-2"> Are generated barcodes scannable?</h5>
<p className="text-slate-600">Yes, barcodes generated with a proper barcode generator are fully scannable. We generate standard-compliant barcodes readable by any standard optical or laser barcode scanner.</p>
</div>
<div>
<h5 className="font-bold text-slate-900 text-lg mb-2"> Can I use these barcodes for Amazon (EAN/UPC)?</h5>
<p className="text-slate-600">You can generate the <em>image</em> for Amazon here if you already have your EAN/UPC number. However, you cannot "create" a valid global EAN number hereyou must purchase those official numbers from GS1 to sell on major platforms like Amazon.</p>
</div>
<div>
<h5 className="font-bold text-slate-900 text-lg mb-2"> What is the difference between a barcode and a QR code?</h5>
<p className="text-slate-600">A barcode stores data horizontally (1D) and is mainly used for product IDs. A QR code stores data in 2D (matrix) and can hold much more information, such as URLs, vCards, or WiFi credentials.</p>
</div>
<div className="not-prose">
<FAQSection
title="Frequently Asked Questions"
items={[
{
question: 'What is a Barcode Generator?',
answer: 'A Barcode Generator is an online tool that converts numbers or text into scannable barcode images that can be used for products, labels, and inventory systems.',
},
{
question: 'Is this barcode generator free to use?',
answer: 'Yes, our online barcode generator is completely free to use with no hidden costs or sign-ups required. You can generate, download, and print barcodes instantly.',
},
{
question: 'Which barcode format should I use?',
answer: '<strong>EAN-13</strong> is the standard for retail products in Europe and globally. <strong>UPC-A</strong> is the standard for retail products in USA/Canada. <strong>Code 128</strong> is best for logistics, shipping, and internal tracking as it supports both letters and numbers. Use the format picker above to find the right one for your use case.',
},
{
question: 'Can I download barcodes in vector format (SVG)?',
answer: 'Yes — SVG downloads are available. SVG files are vector-based, meaning they can be scaled to any size without losing quality. This is ideal for professional product packaging and labels.',
},
{
question: 'How do I generate a barcode online?',
answer: 'Enter your product number or text, select the desired barcode format (such as EAN-13 or Code 128), and the barcode is generated instantly. You can then download it as PNG or SVG.',
},
{
question: 'Are generated barcodes scannable?',
answer: 'Yes. We generate standard-compliant barcodes that are readable by any standard optical or laser barcode scanner, including smartphone camera apps.',
},
{
question: 'Can I use these barcodes for Amazon (EAN/UPC)?',
answer: 'You can generate the barcode <em>image</em> here if you already have a valid EAN/UPC number. However, you cannot create a globally registered EAN/UPC number here — you must purchase official numbers from GS1 to list products on Amazon or in major retail systems.',
},
{
question: 'What is the difference between a barcode and a QR code?',
answer: 'A barcode stores data in one dimension (horizontal bars) and is mainly used for product identification. A QR code stores data in two dimensions (a matrix) and can hold much more information — URLs, contact details, WiFi credentials, and more.',
},
]}
/>
</div>
<div className="mt-12 p-6 bg-slate-900 rounded-xl text-white not-prose">

View File

@@ -11,9 +11,9 @@ import { generateSoftwareAppSchema, generateFaqSchema } from '@/lib/schema-utils
// SEO Optimized Metadata
export const metadata: Metadata = {
title: {
absolute: 'Barcode Generator Create Barcodes Online for Free',
absolute: 'Free Barcode Generator Online EAN, UPC, Code 128',
},
description: 'Use a free Barcode Generator to create scannable barcodes online. Supports EAN, UPC and Code 128 for products, labels and inventory.',
description: 'Free online barcode generator. Create EAN-13, UPC-A and Code 128 barcodes instantly. Download PNG or SVG. No signup required.',
keywords: ['barcode generator', 'online barcode maker', 'create barcode free', 'ean-13 generator', 'upc-a generator', 'code 128 generator', 'barcode creator', 'printable barcodes'],
alternates: {
canonical: 'https://www.qrmaster.net/tools/barcode-generator',

View File

@@ -1,78 +1,78 @@
import { notFound } from "next/navigation";
import {
buildUseCaseMetadata,
UseCasePageTemplate,
} from "@/components/marketing/UseCasePageTemplate";
import {
allUseCases,
getUseCasePage,
} from "@/lib/growth-pages";
export function generateStaticParams() {
return allUseCases.map((item) => ({
slug: item.slug,
}));
}
export function generateMetadata({ params }: { params: { slug: string } }) {
const page = getUseCasePage(params.slug);
if (!page) {
return {};
}
return buildUseCaseMetadata({
title: page.title,
description: page.metaDescription,
canonicalPath: page.href,
});
}
export default function UseCaseDetailPage({
params,
}: {
params: { slug: string };
}) {
const page = getUseCasePage(params.slug);
if (!page) {
notFound();
}
return (
<UseCasePageTemplate
title={page.title}
description={page.metaDescription}
eyebrow={page.eyebrow}
intro={page.intro}
pageType="use_case"
cluster={page.cluster}
useCase={page.slug}
breadcrumbs={[
{ name: "Home", url: "/" },
{ name: "Use Cases", url: "/use-cases" },
{ name: page.title, url: page.href },
]}
answer={page.answer}
whenToUse={page.whenToUse}
comparisonItems={page.comparisonItems}
howToSteps={page.howToSteps}
primaryCta={{
href: page.parentHref,
label: page.ctaLabel,
}}
secondaryCta={{
href: "/use-cases",
label: "Explore more use cases",
}}
workflowTitle={page.workflowTitle}
workflowIntro={page.workflowIntro}
workflowCards={page.workflowCards}
checklistTitle={page.checklistTitle}
checklist={page.checklist}
supportLinks={page.supportLinks}
faq={page.faq}
/>
);
}
import { notFound } from "next/navigation";
import {
buildUseCaseMetadata,
UseCasePageTemplate,
} from "@/components/marketing/UseCasePageTemplate";
import {
allUseCases,
getUseCasePage,
} from "@/lib/growth-pages";
export function generateStaticParams() {
return allUseCases.map((item) => ({
slug: item.slug,
}));
}
export function generateMetadata({ params }: { params: { slug: string } }) {
const page = getUseCasePage(params.slug);
if (!page) {
return {};
}
return buildUseCaseMetadata({
title: page.title,
description: page.metaDescription,
canonicalPath: page.href,
});
}
export default function UseCaseDetailPage({
params,
}: {
params: { slug: string };
}) {
const page = getUseCasePage(params.slug);
if (!page) {
notFound();
}
return (
<UseCasePageTemplate
title={page.title}
description={page.metaDescription}
eyebrow={page.eyebrow}
intro={page.intro}
pageType="use_case"
cluster={page.cluster}
useCase={page.slug}
breadcrumbs={[
{ name: "Home", url: "/" },
{ name: "Use Cases", url: "/use-cases" },
{ name: page.title, url: page.href },
]}
answer={page.answer}
whenToUse={page.whenToUse}
comparisonItems={page.comparisonItems}
howToSteps={page.howToSteps}
primaryCta={{
href: page.parentHref,
label: page.ctaLabel,
}}
secondaryCta={{
href: "/use-cases",
label: "Explore more use cases",
}}
workflowTitle={page.workflowTitle}
workflowIntro={page.workflowIntro}
workflowCards={page.workflowCards}
checklistTitle={page.checklistTitle}
checklist={page.checklist}
supportLinks={page.supportLinks}
faq={page.faq}
/>
);
}

View File

@@ -1,304 +1,304 @@
import type { Metadata } from "next";
import Link from "next/link";
import {
ArrowRight,
Compass,
LibraryBig,
Link2,
Route,
Sparkles,
} from "lucide-react";
import Breadcrumbs, { BreadcrumbItem } from "@/components/Breadcrumbs";
import SeoJsonLd from "@/components/SeoJsonLd";
import {
MarketingPageTracker,
TrackedCtaLink,
} from "@/components/marketing/MarketingAnalytics";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import {
allUseCases,
commercialPages,
featuredUseCases,
supportResources,
upcomingUseCaseIdeas,
} from "@/lib/growth-pages";
import { breadcrumbSchema } from "@/lib/schema";
export const metadata: Metadata = {
title: {
absolute: "QR Code Use Cases for Business | QR Master",
},
description:
"Explore QR code use cases for restaurants, events, business cards, and campaign workflows built around dynamic updates and tracking.",
alternates: {
canonical: "https://www.qrmaster.net/use-cases",
languages: {
"x-default": "https://www.qrmaster.net/use-cases",
en: "https://www.qrmaster.net/use-cases",
},
},
openGraph: {
title: "QR Code Use Cases for Business | QR Master",
description:
"Explore QR code use cases for restaurants, events, business cards, and campaign workflows built around dynamic updates and tracking.",
url: "https://www.qrmaster.net/use-cases",
type: "website",
images: ["/og-image.png"],
},
twitter: {
title: "QR Code Use Cases for Business | QR Master",
description:
"Explore QR code use cases for restaurants, events, business cards, and campaign workflows built around dynamic updates and tracking.",
},
};
export default function UseCasesHubPage() {
const breadcrumbItems: BreadcrumbItem[] = [
{ name: "Home", url: "/" },
{ name: "Use Cases", url: "/use-cases" },
];
return (
<>
<SeoJsonLd data={[breadcrumbSchema(breadcrumbItems)]} />
<MarketingPageTracker pageType="use_case_hub" cluster="all-use-cases" />
<div className="min-h-screen bg-white">
<section className="relative overflow-hidden bg-gradient-to-br from-slate-950 via-blue-950 to-cyan-950 text-white">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.18),transparent_34%),radial-gradient(circle_at_right,rgba(255,255,255,0.06),transparent_28%)]" />
<div className="relative container mx-auto max-w-7xl px-4 py-20 sm:px-6 lg:px-8">
<Breadcrumbs
items={breadcrumbItems}
className="[&_a]:text-blue-100/80 [&_a:hover]:text-white [&_span]:text-blue-100/80 [&_[aria-current=page]]:text-white"
/>
<div className="grid gap-12 lg:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)] lg:items-center">
<div className="space-y-8">
<div className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-semibold text-cyan-100 shadow-lg shadow-cyan-950/30 backdrop-blur">
<Sparkles className="h-4 w-4" />
<span>Commercial use-case hub</span>
</div>
<div className="space-y-5">
<h1 className="max-w-4xl text-4xl font-bold tracking-tight text-white md:text-5xl lg:text-6xl">
QR code use cases that fit real business workflows
</h1>
<p className="max-w-3xl text-lg leading-8 text-blue-50/88 md:text-xl">
This hub focuses on workflows where dynamic updates and
measurement matter. It is not a list of random QR ideas. It
is the commercial layer between QR Master's product pages,
tools, and editorial content.
</p>
</div>
<div className="grid gap-3 text-sm text-blue-50/80 sm:grid-cols-2">
{[
"Use-case pages map back to a clear commercial parent.",
"Each workflow is written for practical deployment, not filler traffic.",
"Support resources reinforce the wedge around dynamic and trackable QR flows.",
"The next cluster expansion will build on measurable routing and internal links.",
].map((line) => (
<div
key={line}
className="flex items-start gap-3 rounded-2xl border border-white/10 bg-white/5 px-4 py-3 backdrop-blur-sm"
>
<Route className="mt-0.5 h-4 w-4 shrink-0 text-cyan-300" />
<span>{line}</span>
</div>
))}
</div>
<div className="flex flex-col gap-4 sm:flex-row">
<TrackedCtaLink
href={featuredUseCases[0].href}
ctaLabel="Explore restaurant menu QR codes"
ctaLocation="hero_primary"
pageType="use_case_hub"
cluster="all-use-cases"
>
<Button size="lg" className="w-full bg-white px-8 py-4 text-slate-950 hover:bg-slate-100 sm:w-auto">
Explore featured workflows
</Button>
</TrackedCtaLink>
<TrackedCtaLink
href="/qr-code-for-marketing-campaigns"
ctaLabel="View marketing campaign QR page"
ctaLocation="hero_secondary"
pageType="use_case_hub"
cluster="all-use-cases"
>
<Button
variant="outline"
size="lg"
className="w-full border-white/30 bg-white/5 px-8 py-4 text-white hover:bg-white/10 sm:w-auto"
>
See campaign workflows
</Button>
</TrackedCtaLink>
</div>
</div>
<Card className="border-white/10 bg-white/10 p-8 text-white shadow-2xl shadow-slate-950/30 backdrop-blur">
<div className="space-y-5">
<div className="flex items-center gap-3">
<Compass className="h-5 w-5 text-cyan-300" />
<h2 className="text-2xl font-bold">How to use this hub</h2>
</div>
<div className="space-y-4 text-sm leading-6 text-blue-50/82">
<p>
Start with the workflow problem, not the QR format. If the
printed code needs to survive destination changes or you
need proof of performance, begin with the use case that
matches that job.
</p>
<p>
Each page below links back to the best product parent,
forward to related workflows, and sideways to educational
resources that help you deploy the QR well.
</p>
</div>
</div>
</Card>
</div>
</div>
</section>
<section className="py-16">
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="mb-10 max-w-3xl">
<div className="text-sm font-semibold uppercase tracking-[0.22em] text-blue-700">
All live use cases
</div>
<h2 className="mt-3 text-3xl font-bold text-slate-900">
Every currently published workflow route
</h2>
<p className="mt-4 text-lg leading-8 text-slate-600">
These are all currently published use-case routes in the growth
layer. Each one maps back to a clear commercial parent and a
measurable print or post-scan workflow.
</p>
</div>
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{allUseCases.map((page) => (
<Link key={page.slug} href={page.href} className="group block">
<Card className="flex h-full flex-col rounded-3xl border-slate-200 bg-white p-7 shadow-sm transition-all hover:-translate-y-1 hover:shadow-lg">
<div className="text-sm font-semibold uppercase tracking-[0.18em] text-blue-700">
{page.cluster}
</div>
<h3 className="mt-4 text-2xl font-bold text-slate-900">
{page.title}
</h3>
<p className="mt-4 flex-1 text-base leading-7 text-slate-600">
{page.summary}
</p>
<div className="mt-6 flex items-center justify-between rounded-2xl bg-slate-50 px-4 py-3 text-sm text-slate-600">
<span>Primary parent: {page.parentTitle}</span>
<ArrowRight className="h-4 w-4 text-blue-700 transition-transform group-hover:translate-x-1" />
</div>
</Card>
</Link>
))}
</div>
</div>
</section>
<section className="bg-slate-50 py-16">
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,0.95fr)]">
<Card className="rounded-3xl border-slate-200 bg-white p-8 shadow-sm">
<div className="flex items-center gap-3">
<LibraryBig className="h-5 w-5 text-blue-700" />
<h2 className="text-2xl font-bold text-slate-900">
Commercial pages that anchor the hub
</h2>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2">
{commercialPages.map((page) => (
<Link
key={page.href}
href={page.href}
className="rounded-2xl border border-slate-200 p-4 transition-colors hover:border-blue-200 hover:bg-blue-50/60"
>
<div className={`h-1.5 rounded-full bg-gradient-to-r ${page.accent}`} />
<div className="mt-4 text-lg font-semibold text-slate-900">
{page.title}
</div>
<p className="mt-2 text-sm leading-6 text-slate-600">
{page.description}
</p>
</Link>
))}
</div>
</Card>
<Card className="rounded-3xl border-slate-200 bg-slate-950 p-8 text-white shadow-xl shadow-slate-200">
<div className="flex items-center gap-3">
<Link2 className="h-5 w-5 text-cyan-300" />
<h2 className="text-2xl font-bold">Support resources</h2>
</div>
<div className="mt-6 space-y-4">
{supportResources.map((resource) => (
<Link
key={resource.href}
href={resource.href}
className="block rounded-2xl border border-white/10 bg-white/5 p-4 transition-colors hover:bg-white/10"
>
<div className="text-lg font-semibold text-white">
{resource.title}
</div>
<p className="mt-2 text-sm leading-6 text-blue-50/78">
{resource.description}
</p>
</Link>
))}
</div>
</Card>
</div>
</div>
</section>
<section className="py-16">
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="mb-10 max-w-3xl">
<div className="text-sm font-semibold uppercase tracking-[0.22em] text-blue-700">
Next cluster candidates
</div>
<h2 className="mt-3 text-3xl font-bold text-slate-900">
What follows after the first use-case wave
</h2>
<p className="mt-4 text-lg leading-8 text-slate-600">
These are not published use-case routes yet. They are the next
practical cluster expansions once the first hub and CTA layer are
established.
</p>
</div>
<div className="grid gap-6 md:grid-cols-3">
{upcomingUseCaseIdeas.map((item) => (
<Card
key={item.title}
className="rounded-3xl border-dashed border-slate-300 bg-slate-50 p-7"
>
<div className="text-xl font-semibold text-slate-900">
{item.title}
</div>
<p className="mt-3 text-base leading-7 text-slate-600">
{item.description}
</p>
<div className="mt-5 text-sm font-semibold text-blue-700">
Anchored by {item.href.replace("/", "")}
</div>
</Card>
))}
</div>
</div>
</section>
</div>
</>
);
}
import type { Metadata } from "next";
import Link from "next/link";
import {
ArrowRight,
Compass,
LibraryBig,
Link2,
Route,
Sparkles,
} from "lucide-react";
import Breadcrumbs, { BreadcrumbItem } from "@/components/Breadcrumbs";
import SeoJsonLd from "@/components/SeoJsonLd";
import {
MarketingPageTracker,
TrackedCtaLink,
} from "@/components/marketing/MarketingAnalytics";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import {
allUseCases,
commercialPages,
featuredUseCases,
supportResources,
upcomingUseCaseIdeas,
} from "@/lib/growth-pages";
import { breadcrumbSchema } from "@/lib/schema";
export const metadata: Metadata = {
title: {
absolute: "QR Code Use Cases for Business | QR Master",
},
description:
"Explore QR code use cases for restaurants, events, business cards, and campaign workflows built around dynamic updates and tracking.",
alternates: {
canonical: "https://www.qrmaster.net/use-cases",
languages: {
"x-default": "https://www.qrmaster.net/use-cases",
en: "https://www.qrmaster.net/use-cases",
},
},
openGraph: {
title: "QR Code Use Cases for Business | QR Master",
description:
"Explore QR code use cases for restaurants, events, business cards, and campaign workflows built around dynamic updates and tracking.",
url: "https://www.qrmaster.net/use-cases",
type: "website",
images: ["/og-image.png"],
},
twitter: {
title: "QR Code Use Cases for Business | QR Master",
description:
"Explore QR code use cases for restaurants, events, business cards, and campaign workflows built around dynamic updates and tracking.",
},
};
export default function UseCasesHubPage() {
const breadcrumbItems: BreadcrumbItem[] = [
{ name: "Home", url: "/" },
{ name: "Use Cases", url: "/use-cases" },
];
return (
<>
<SeoJsonLd data={[breadcrumbSchema(breadcrumbItems)]} />
<MarketingPageTracker pageType="use_case_hub" cluster="all-use-cases" />
<div className="min-h-screen bg-white">
<section className="relative overflow-hidden bg-gradient-to-br from-slate-950 via-blue-950 to-cyan-950 text-white">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.18),transparent_34%),radial-gradient(circle_at_right,rgba(255,255,255,0.06),transparent_28%)]" />
<div className="relative container mx-auto max-w-7xl px-4 py-20 sm:px-6 lg:px-8">
<Breadcrumbs
items={breadcrumbItems}
className="[&_a]:text-blue-100/80 [&_a:hover]:text-white [&_span]:text-blue-100/80 [&_[aria-current=page]]:text-white"
/>
<div className="grid gap-12 lg:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)] lg:items-center">
<div className="space-y-8">
<div className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-semibold text-cyan-100 shadow-lg shadow-cyan-950/30 backdrop-blur">
<Sparkles className="h-4 w-4" />
<span>Commercial use-case hub</span>
</div>
<div className="space-y-5">
<h1 className="max-w-4xl text-4xl font-bold tracking-tight text-white md:text-5xl lg:text-6xl">
QR code use cases that fit real business workflows
</h1>
<p className="max-w-3xl text-lg leading-8 text-blue-50/88 md:text-xl">
This hub focuses on workflows where dynamic updates and
measurement matter. It is not a list of random QR ideas. It
is the commercial layer between QR Master's product pages,
tools, and editorial content.
</p>
</div>
<div className="grid gap-3 text-sm text-blue-50/80 sm:grid-cols-2">
{[
"Use-case pages map back to a clear commercial parent.",
"Each workflow is written for practical deployment, not filler traffic.",
"Support resources reinforce the wedge around dynamic and trackable QR flows.",
"The next cluster expansion will build on measurable routing and internal links.",
].map((line) => (
<div
key={line}
className="flex items-start gap-3 rounded-2xl border border-white/10 bg-white/5 px-4 py-3 backdrop-blur-sm"
>
<Route className="mt-0.5 h-4 w-4 shrink-0 text-cyan-300" />
<span>{line}</span>
</div>
))}
</div>
<div className="flex flex-col gap-4 sm:flex-row">
<TrackedCtaLink
href={featuredUseCases[0].href}
ctaLabel="Explore restaurant menu QR codes"
ctaLocation="hero_primary"
pageType="use_case_hub"
cluster="all-use-cases"
>
<Button size="lg" className="w-full bg-white px-8 py-4 text-slate-950 hover:bg-slate-100 sm:w-auto">
Explore featured workflows
</Button>
</TrackedCtaLink>
<TrackedCtaLink
href="/qr-code-for-marketing-campaigns"
ctaLabel="View marketing campaign QR page"
ctaLocation="hero_secondary"
pageType="use_case_hub"
cluster="all-use-cases"
>
<Button
variant="outline"
size="lg"
className="w-full border-white/30 bg-white/5 px-8 py-4 text-white hover:bg-white/10 sm:w-auto"
>
See campaign workflows
</Button>
</TrackedCtaLink>
</div>
</div>
<Card className="border-white/10 bg-white/10 p-8 text-white shadow-2xl shadow-slate-950/30 backdrop-blur">
<div className="space-y-5">
<div className="flex items-center gap-3">
<Compass className="h-5 w-5 text-cyan-300" />
<h2 className="text-2xl font-bold">How to use this hub</h2>
</div>
<div className="space-y-4 text-sm leading-6 text-blue-50/82">
<p>
Start with the workflow problem, not the QR format. If the
printed code needs to survive destination changes or you
need proof of performance, begin with the use case that
matches that job.
</p>
<p>
Each page below links back to the best product parent,
forward to related workflows, and sideways to educational
resources that help you deploy the QR well.
</p>
</div>
</div>
</Card>
</div>
</div>
</section>
<section className="py-16">
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="mb-10 max-w-3xl">
<div className="text-sm font-semibold uppercase tracking-[0.22em] text-blue-700">
All live use cases
</div>
<h2 className="mt-3 text-3xl font-bold text-slate-900">
Every currently published workflow route
</h2>
<p className="mt-4 text-lg leading-8 text-slate-600">
These are all currently published use-case routes in the growth
layer. Each one maps back to a clear commercial parent and a
measurable print or post-scan workflow.
</p>
</div>
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{allUseCases.map((page) => (
<Link key={page.slug} href={page.href} className="group block">
<Card className="flex h-full flex-col rounded-3xl border-slate-200 bg-white p-7 shadow-sm transition-all hover:-translate-y-1 hover:shadow-lg">
<div className="text-sm font-semibold uppercase tracking-[0.18em] text-blue-700">
{page.cluster}
</div>
<h3 className="mt-4 text-2xl font-bold text-slate-900">
{page.title}
</h3>
<p className="mt-4 flex-1 text-base leading-7 text-slate-600">
{page.summary}
</p>
<div className="mt-6 flex items-center justify-between rounded-2xl bg-slate-50 px-4 py-3 text-sm text-slate-600">
<span>Primary parent: {page.parentTitle}</span>
<ArrowRight className="h-4 w-4 text-blue-700 transition-transform group-hover:translate-x-1" />
</div>
</Card>
</Link>
))}
</div>
</div>
</section>
<section className="bg-slate-50 py-16">
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,0.95fr)]">
<Card className="rounded-3xl border-slate-200 bg-white p-8 shadow-sm">
<div className="flex items-center gap-3">
<LibraryBig className="h-5 w-5 text-blue-700" />
<h2 className="text-2xl font-bold text-slate-900">
Commercial pages that anchor the hub
</h2>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2">
{commercialPages.map((page) => (
<Link
key={page.href}
href={page.href}
className="rounded-2xl border border-slate-200 p-4 transition-colors hover:border-blue-200 hover:bg-blue-50/60"
>
<div className={`h-1.5 rounded-full bg-gradient-to-r ${page.accent}`} />
<div className="mt-4 text-lg font-semibold text-slate-900">
{page.title}
</div>
<p className="mt-2 text-sm leading-6 text-slate-600">
{page.description}
</p>
</Link>
))}
</div>
</Card>
<Card className="rounded-3xl border-slate-200 bg-slate-950 p-8 text-white shadow-xl shadow-slate-200">
<div className="flex items-center gap-3">
<Link2 className="h-5 w-5 text-cyan-300" />
<h2 className="text-2xl font-bold">Support resources</h2>
</div>
<div className="mt-6 space-y-4">
{supportResources.map((resource) => (
<Link
key={resource.href}
href={resource.href}
className="block rounded-2xl border border-white/10 bg-white/5 p-4 transition-colors hover:bg-white/10"
>
<div className="text-lg font-semibold text-white">
{resource.title}
</div>
<p className="mt-2 text-sm leading-6 text-blue-50/78">
{resource.description}
</p>
</Link>
))}
</div>
</Card>
</div>
</div>
</section>
<section className="py-16">
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="mb-10 max-w-3xl">
<div className="text-sm font-semibold uppercase tracking-[0.22em] text-blue-700">
Next cluster candidates
</div>
<h2 className="mt-3 text-3xl font-bold text-slate-900">
What follows after the first use-case wave
</h2>
<p className="mt-4 text-lg leading-8 text-slate-600">
These are not published use-case routes yet. They are the next
practical cluster expansions once the first hub and CTA layer are
established.
</p>
</div>
<div className="grid gap-6 md:grid-cols-3">
{upcomingUseCaseIdeas.map((item) => (
<Card
key={item.title}
className="rounded-3xl border-dashed border-slate-300 bg-slate-50 p-7"
>
<div className="text-xl font-semibold text-slate-900">
{item.title}
</div>
<p className="mt-3 text-base leading-7 text-slate-600">
{item.description}
</p>
<div className="mt-5 text-sm font-semibold text-blue-700">
Anchored by {item.href.replace("/", "")}
</div>
</Card>
))}
</div>
</div>
</section>
</div>
</>
);
}

View File

@@ -1,6 +1,6 @@
import NextAuth from 'next-auth';
import { authOptions } from '@/lib/auth';
const handler = NextAuth(authOptions);
import NextAuth from 'next-auth';
import { authOptions } from '@/lib/auth';
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@@ -1,89 +1,89 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { csrfProtection } from '@/lib/csrf';
import { sendPasswordResetEmail } from '@/lib/email';
import crypto from 'crypto';
export async function POST(req: NextRequest) {
try {
// Verify CSRF token
const csrfCheck = csrfProtection(req);
if (!csrfCheck.valid) {
return NextResponse.json(
{ error: csrfCheck.error || 'Invalid CSRF token' },
{ status: 403 }
);
}
const body = await req.json();
const { email } = body;
if (!email) {
return NextResponse.json(
{ error: 'Email is required' },
{ status: 400 }
);
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: 'Invalid email format' },
{ status: 400 }
);
}
// Find user by email
const user = await db.user.findUnique({
where: { email: email.toLowerCase() },
});
// For security, always return success even if email doesn't exist
// This prevents email enumeration attacks
if (!user) {
console.log('Password reset requested for non-existent email:', email);
return NextResponse.json(
{ message: 'If an account with that email exists, a password reset link has been sent.' },
{ status: 200 }
);
}
// Generate secure random token
const resetToken = crypto.randomBytes(32).toString('hex');
// Set token expiration to 1 hour from now
const resetExpires = new Date(Date.now() + 3600000); // 1 hour
// Save token and expiration to database
await db.user.update({
where: { id: user.id },
data: {
resetPasswordToken: resetToken,
resetPasswordExpires: resetExpires,
},
});
// Send password reset email
try {
await sendPasswordResetEmail(email, resetToken);
} catch (emailError) {
console.error('Error sending password reset email:', emailError);
return NextResponse.json(
{ error: 'Failed to send reset email. Please try again later.' },
{ status: 500 }
);
}
return NextResponse.json(
{ message: 'Password reset email sent successfully' },
{ status: 200 }
);
} catch (error) {
console.error('Error in forgot-password route:', error);
return NextResponse.json(
{ error: 'An error occurred. Please try again.' },
{ status: 500 }
);
}
}
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { csrfProtection } from '@/lib/csrf';
import { sendPasswordResetEmail } from '@/lib/email';
import crypto from 'crypto';
export async function POST(req: NextRequest) {
try {
// Verify CSRF token
const csrfCheck = csrfProtection(req);
if (!csrfCheck.valid) {
return NextResponse.json(
{ error: csrfCheck.error || 'Invalid CSRF token' },
{ status: 403 }
);
}
const body = await req.json();
const { email } = body;
if (!email) {
return NextResponse.json(
{ error: 'Email is required' },
{ status: 400 }
);
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: 'Invalid email format' },
{ status: 400 }
);
}
// Find user by email
const user = await db.user.findUnique({
where: { email: email.toLowerCase() },
});
// For security, always return success even if email doesn't exist
// This prevents email enumeration attacks
if (!user) {
console.log('Password reset requested for non-existent email:', email);
return NextResponse.json(
{ message: 'If an account with that email exists, a password reset link has been sent.' },
{ status: 200 }
);
}
// Generate secure random token
const resetToken = crypto.randomBytes(32).toString('hex');
// Set token expiration to 1 hour from now
const resetExpires = new Date(Date.now() + 3600000); // 1 hour
// Save token and expiration to database
await db.user.update({
where: { id: user.id },
data: {
resetPasswordToken: resetToken,
resetPasswordExpires: resetExpires,
},
});
// Send password reset email
try {
await sendPasswordResetEmail(email, resetToken);
} catch (emailError) {
console.error('Error sending password reset email:', emailError);
return NextResponse.json(
{ error: 'Failed to send reset email. Please try again later.' },
{ status: 500 }
);
}
return NextResponse.json(
{ message: 'Password reset email sent successfully' },
{ status: 200 }
);
} catch (error) {
console.error('Error in forgot-password route:', error);
return NextResponse.json(
{ error: 'An error occurred. Please try again.' },
{ status: 500 }
);
}
}

View File

@@ -1,165 +1,165 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { cookies } from 'next/headers';
import { getAuthCookieOptions } from '@/lib/cookieConfig';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
// If no code, redirect to Google OAuth
if (!code) {
const googleClientId = process.env.GOOGLE_CLIENT_ID;
if (!googleClientId) {
return NextResponse.json(
{ error: 'Google Client ID not configured' },
{ status: 500 }
);
}
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
const scope = 'openid email profile';
const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${googleClientId}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}`;
return NextResponse.redirect(googleAuthUrl);
}
// Handle callback with code
try {
const googleClientId = process.env.GOOGLE_CLIENT_ID;
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
if (!googleClientId || !googleClientSecret) {
return NextResponse.json(
{ error: 'Google OAuth not configured' },
{ status: 500 }
);
}
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
// Exchange code for tokens
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
code,
client_id: googleClientId,
client_secret: googleClientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
}),
});
if (!tokenResponse.ok) {
throw new Error('Failed to exchange code for tokens');
}
const tokens = await tokenResponse.json();
// Get user info from Google
const userInfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
},
});
if (!userInfoResponse.ok) {
throw new Error('Failed to get user info');
}
const userInfo = await userInfoResponse.json();
// Check if user exists in database
let user = await db.user.findUnique({
where: { email: userInfo.email },
});
const isNewUser = !user;
// Create user if they don't exist
if (!user) {
user = await db.user.create({
data: {
email: userInfo.email,
name: userInfo.name || userInfo.email.split('@')[0],
image: userInfo.picture,
emailVerified: new Date(), // Google already verified the email
password: null, // OAuth users don't need a password
},
});
// Create Account entry for the OAuth provider
await db.account.create({
data: {
userId: user.id,
type: 'oauth',
provider: 'google',
providerAccountId: userInfo.sub || userInfo.id,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
token_type: tokens.token_type,
scope: tokens.scope,
id_token: tokens.id_token,
},
});
} else {
// Update existing account tokens
const existingAccount = await db.account.findUnique({
where: {
provider_providerAccountId: {
provider: 'google',
providerAccountId: userInfo.sub || userInfo.id,
},
},
});
if (existingAccount) {
await db.account.update({
where: { id: existingAccount.id },
data: {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
},
});
} else {
// Create Account entry if it doesn't exist
await db.account.create({
data: {
userId: user.id,
type: 'oauth',
provider: 'google',
providerAccountId: userInfo.sub || userInfo.id,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
token_type: tokens.token_type,
scope: tokens.scope,
id_token: tokens.id_token,
},
});
}
}
// Set authentication cookie
cookies().set('userId', user.id, getAuthCookieOptions());
// Redirect to dashboard with tracking params
const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}/dashboard`);
redirectUrl.searchParams.set('authMethod', 'google');
redirectUrl.searchParams.set('isNewUser', isNewUser.toString());
return NextResponse.redirect(redirectUrl.toString());
} catch (error) {
console.error('Google OAuth error:', error);
return NextResponse.redirect(
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-signin-failed`
);
}
}
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { cookies } from 'next/headers';
import { getAuthCookieOptions } from '@/lib/cookieConfig';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
// If no code, redirect to Google OAuth
if (!code) {
const googleClientId = process.env.GOOGLE_CLIENT_ID;
if (!googleClientId) {
return NextResponse.json(
{ error: 'Google Client ID not configured' },
{ status: 500 }
);
}
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
const scope = 'openid email profile';
const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${googleClientId}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}`;
return NextResponse.redirect(googleAuthUrl);
}
// Handle callback with code
try {
const googleClientId = process.env.GOOGLE_CLIENT_ID;
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
if (!googleClientId || !googleClientSecret) {
return NextResponse.json(
{ error: 'Google OAuth not configured' },
{ status: 500 }
);
}
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
// Exchange code for tokens
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
code,
client_id: googleClientId,
client_secret: googleClientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
}),
});
if (!tokenResponse.ok) {
throw new Error('Failed to exchange code for tokens');
}
const tokens = await tokenResponse.json();
// Get user info from Google
const userInfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
},
});
if (!userInfoResponse.ok) {
throw new Error('Failed to get user info');
}
const userInfo = await userInfoResponse.json();
// Check if user exists in database
let user = await db.user.findUnique({
where: { email: userInfo.email },
});
const isNewUser = !user;
// Create user if they don't exist
if (!user) {
user = await db.user.create({
data: {
email: userInfo.email,
name: userInfo.name || userInfo.email.split('@')[0],
image: userInfo.picture,
emailVerified: new Date(), // Google already verified the email
password: null, // OAuth users don't need a password
},
});
// Create Account entry for the OAuth provider
await db.account.create({
data: {
userId: user.id,
type: 'oauth',
provider: 'google',
providerAccountId: userInfo.sub || userInfo.id,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
token_type: tokens.token_type,
scope: tokens.scope,
id_token: tokens.id_token,
},
});
} else {
// Update existing account tokens
const existingAccount = await db.account.findUnique({
where: {
provider_providerAccountId: {
provider: 'google',
providerAccountId: userInfo.sub || userInfo.id,
},
},
});
if (existingAccount) {
await db.account.update({
where: { id: existingAccount.id },
data: {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
},
});
} else {
// Create Account entry if it doesn't exist
await db.account.create({
data: {
userId: user.id,
type: 'oauth',
provider: 'google',
providerAccountId: userInfo.sub || userInfo.id,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
token_type: tokens.token_type,
scope: tokens.scope,
id_token: tokens.id_token,
},
});
}
}
// Set authentication cookie
cookies().set('userId', user.id, getAuthCookieOptions());
// Redirect to dashboard with tracking params
const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}/dashboard`);
redirectUrl.searchParams.set('authMethod', 'google');
redirectUrl.searchParams.set('isNewUser', isNewUser.toString());
return NextResponse.redirect(redirectUrl.toString());
} catch (error) {
console.error('Google OAuth error:', error);
return NextResponse.redirect(
`${process.env.NEXT_PUBLIC_APP_URL}/login?error=google-signin-failed`
);
}
}

View File

@@ -1,90 +1,90 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { csrfProtection } from '@/lib/csrf';
import bcrypt from 'bcryptjs';
export async function POST(req: NextRequest) {
try {
// Verify CSRF token
const csrfCheck = csrfProtection(req);
if (!csrfCheck.valid) {
return NextResponse.json(
{ error: csrfCheck.error || 'Invalid CSRF token' },
{ status: 403 }
);
}
const body = await req.json();
const { token, password } = body;
if (!token || !password) {
return NextResponse.json(
{ error: 'Token and password are required' },
{ status: 400 }
);
}
// Validate password length
if (password.length < 8) {
return NextResponse.json(
{ error: 'Password must be at least 8 characters long' },
{ status: 400 }
);
}
// Find user with this reset token
const user = await db.user.findUnique({
where: { resetPasswordToken: token },
});
if (!user) {
return NextResponse.json(
{ error: 'Invalid or expired reset token' },
{ status: 400 }
);
}
// Check if token has expired
if (!user.resetPasswordExpires || user.resetPasswordExpires < new Date()) {
// Clear expired token
await db.user.update({
where: { id: user.id },
data: {
resetPasswordToken: null,
resetPasswordExpires: null,
},
});
return NextResponse.json(
{ error: 'Reset token has expired. Please request a new password reset link.' },
{ status: 400 }
);
}
// Hash the new password
const hashedPassword = await bcrypt.hash(password, 10);
// Update user's password and clear reset token
await db.user.update({
where: { id: user.id },
data: {
password: hashedPassword,
resetPasswordToken: null,
resetPasswordExpires: null,
},
});
console.log('Password successfully reset for user:', user.email);
return NextResponse.json(
{ message: 'Password reset successfully' },
{ status: 200 }
);
} catch (error) {
console.error('Error in reset-password route:', error);
return NextResponse.json(
{ error: 'An error occurred. Please try again.' },
{ status: 500 }
);
}
}
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { csrfProtection } from '@/lib/csrf';
import bcrypt from 'bcryptjs';
export async function POST(req: NextRequest) {
try {
// Verify CSRF token
const csrfCheck = csrfProtection(req);
if (!csrfCheck.valid) {
return NextResponse.json(
{ error: csrfCheck.error || 'Invalid CSRF token' },
{ status: 403 }
);
}
const body = await req.json();
const { token, password } = body;
if (!token || !password) {
return NextResponse.json(
{ error: 'Token and password are required' },
{ status: 400 }
);
}
// Validate password length
if (password.length < 8) {
return NextResponse.json(
{ error: 'Password must be at least 8 characters long' },
{ status: 400 }
);
}
// Find user with this reset token
const user = await db.user.findUnique({
where: { resetPasswordToken: token },
});
if (!user) {
return NextResponse.json(
{ error: 'Invalid or expired reset token' },
{ status: 400 }
);
}
// Check if token has expired
if (!user.resetPasswordExpires || user.resetPasswordExpires < new Date()) {
// Clear expired token
await db.user.update({
where: { id: user.id },
data: {
resetPasswordToken: null,
resetPasswordExpires: null,
},
});
return NextResponse.json(
{ error: 'Reset token has expired. Please request a new password reset link.' },
{ status: 400 }
);
}
// Hash the new password
const hashedPassword = await bcrypt.hash(password, 10);
// Update user's password and clear reset token
await db.user.update({
where: { id: user.id },
data: {
password: hashedPassword,
resetPasswordToken: null,
resetPasswordExpires: null,
},
});
console.log('Password successfully reset for user:', user.email);
return NextResponse.json(
{ message: 'Password reset successfully' },
{ status: 200 }
);
} catch (error) {
console.error('Error in reset-password route:', error);
return NextResponse.json(
{ error: 'An error occurred. Please try again.' },
{ status: 500 }
);
}
}

View File

@@ -1,85 +1,85 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import bcrypt from 'bcryptjs';
import { cookies } from 'next/headers';
import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { getAuthCookieOptions } from '@/lib/cookieConfig';
import { loginSchema, validateRequest } from '@/lib/validationSchemas';
export async function POST(request: NextRequest) {
try {
// CSRF Protection
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json(
{ error: csrfCheck.error },
{ status: 403 }
);
}
// Rate Limiting
const clientId = getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.LOGIN);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many login attempts. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
const body = await request.json();
// Validate request body
const validation = await validateRequest(loginSchema, body);
if (!validation.success) {
return NextResponse.json(validation.error, { status: 400 });
}
const { email, password } = validation.data;
// Find user
const user = await db.user.findUnique({
where: { email },
});
if (!user) {
return NextResponse.json(
{ error: 'Invalid email or password' },
{ status: 401 }
);
}
// Verify password
const isValid = await bcrypt.compare(password, user.password || '');
if (!isValid) {
return NextResponse.json(
{ error: 'Invalid email or password' },
{ status: 401 }
);
}
// Set cookie
cookies().set('userId', user.id, getAuthCookieOptions());
return NextResponse.json({
success: true,
user: { id: user.id, email: user.email, name: user.name, plan: user.plan || 'FREE' }
});
} catch (error) {
console.error('Login error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import bcrypt from 'bcryptjs';
import { cookies } from 'next/headers';
import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { getAuthCookieOptions } from '@/lib/cookieConfig';
import { loginSchema, validateRequest } from '@/lib/validationSchemas';
export async function POST(request: NextRequest) {
try {
// CSRF Protection
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json(
{ error: csrfCheck.error },
{ status: 403 }
);
}
// Rate Limiting
const clientId = getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.LOGIN);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many login attempts. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
const body = await request.json();
// Validate request body
const validation = await validateRequest(loginSchema, body);
if (!validation.success) {
return NextResponse.json(validation.error, { status: 400 });
}
const { email, password } = validation.data;
// Find user
const user = await db.user.findUnique({
where: { email },
});
if (!user) {
return NextResponse.json(
{ error: 'Invalid email or password' },
{ status: 401 }
);
}
// Verify password
const isValid = await bcrypt.compare(password, user.password || '');
if (!isValid) {
return NextResponse.json(
{ error: 'Invalid email or password' },
{ status: 401 }
);
}
// Set cookie
cookies().set('userId', user.id, getAuthCookieOptions());
return NextResponse.json({
success: true,
user: { id: user.id, email: user.email, name: user.name, plan: user.plan || 'FREE' }
});
} catch (error) {
console.error('Login error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -1,14 +1,14 @@
import { NextResponse } from 'next/server';
import { getOrCreateCsrfToken } from '@/lib/csrf';
export const dynamic = 'force-dynamic';
/**
* GET /api/csrf
* Returns a CSRF token for the current session
*/
export async function GET() {
const token = getOrCreateCsrfToken();
return NextResponse.json({ csrfToken: token });
}
import { NextResponse } from 'next/server';
import { getOrCreateCsrfToken } from '@/lib/csrf';
export const dynamic = 'force-dynamic';
/**
* GET /api/csrf
* Returns a CSRF token for the current session
*/
export async function GET() {
const token = getOrCreateCsrfToken();
return NextResponse.json({ csrfToken: token });
}

View File

@@ -1,59 +1,59 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import bcrypt from 'bcryptjs';
import { db } from '@/lib/db';
import { getAuthCookieOptions } from '@/lib/cookieConfig';
/**
* POST /api/newsletter/admin-login
* Simple admin login for newsletter management (no CSRF required)
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email, password } = body;
// Validate input
if (!email || !password) {
return NextResponse.json(
{ error: 'Email and password are required' },
{ status: 400 }
);
}
// SECURITY: Only allow support@qrmaster.net to access newsletter admin
const ALLOWED_ADMIN_EMAIL = 'support@qrmaster.net';
const ALLOWED_ADMIN_PASSWORD = 'Timo.16092005';
if (email.toLowerCase() !== ALLOWED_ADMIN_EMAIL) {
return NextResponse.json(
{ error: 'Access denied. Only authorized accounts can access this area.' },
{ status: 403 }
);
}
// Verify password with hardcoded value
if (password !== ALLOWED_ADMIN_PASSWORD) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}
// Set auth cookie with a simple session identifier
const response = NextResponse.json({
success: true,
message: 'Login successful',
});
response.cookies.set('newsletter-admin', 'authenticated', getAuthCookieOptions());
return response;
} catch (error) {
console.error('Newsletter admin login error:', error);
return NextResponse.json(
{ error: 'Login failed. Please try again.' },
{ status: 500 }
);
}
}
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import bcrypt from 'bcryptjs';
import { db } from '@/lib/db';
import { getAuthCookieOptions } from '@/lib/cookieConfig';
/**
* POST /api/newsletter/admin-login
* Simple admin login for newsletter management (no CSRF required)
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email, password } = body;
// Validate input
if (!email || !password) {
return NextResponse.json(
{ error: 'Email and password are required' },
{ status: 400 }
);
}
// SECURITY: Only allow support@qrmaster.net to access newsletter admin
const ALLOWED_ADMIN_EMAIL = 'support@qrmaster.net';
const ALLOWED_ADMIN_PASSWORD = 'Timo.16092005';
if (email.toLowerCase() !== ALLOWED_ADMIN_EMAIL) {
return NextResponse.json(
{ error: 'Access denied. Only authorized accounts can access this area.' },
{ status: 403 }
);
}
// Verify password with hardcoded value
if (password !== ALLOWED_ADMIN_PASSWORD) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}
// Set auth cookie with a simple session identifier
const response = NextResponse.json({
success: true,
message: 'Login successful',
});
response.cookies.set('newsletter-admin', 'authenticated', getAuthCookieOptions());
return response;
} catch (error) {
console.error('Newsletter admin login error:', error);
return NextResponse.json(
{ error: 'Login failed. Please try again.' },
{ status: 500 }
);
}
}

View File

@@ -1,163 +1,163 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { sendAIFeatureLaunchEmail } from '@/lib/email';
import { rateLimit, RateLimits } from '@/lib/rateLimit';
/**
* POST /api/newsletter/broadcast
* Send AI feature launch email to all subscribed users
* PROTECTED: Only authenticated users can access (you may want to add admin check)
*/
export async function POST(request: NextRequest) {
try {
// Check authentication using newsletter-admin cookie
const adminCookie = cookies().get('newsletter-admin')?.value;
if (adminCookie !== 'authenticated') {
return NextResponse.json(
{ error: 'Unauthorized. Please log in.' },
{ status: 401 }
);
}
// Optional: Add admin check here
// const user = await db.user.findUnique({ where: { id: userId } });
// if (user?.role !== 'ADMIN') {
// return NextResponse.json({ error: 'Forbidden. Admin access required.' }, { status: 403 });
// }
// Rate limiting (prevent accidental spam)
const rateLimitResult = rateLimit('newsletter-admin', {
name: 'newsletter-broadcast',
maxRequests: 2, // Only 2 broadcasts per hour
windowSeconds: 60 * 60,
});
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many broadcast attempts. Please wait before trying again.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000),
},
{ status: 429 }
);
}
// Get all subscribed users
const subscribers = await db.newsletterSubscription.findMany({
where: {
status: 'subscribed',
},
select: {
email: true,
},
});
if (subscribers.length === 0) {
return NextResponse.json({
success: true,
message: 'No subscribers found',
sent: 0,
});
}
// Send emails in batches to avoid overwhelming Resend
const batchSize = 10;
const results = {
sent: 0,
failed: 0,
errors: [] as string[],
};
for (let i = 0; i < subscribers.length; i += batchSize) {
const batch = subscribers.slice(i, i + batchSize);
// Send emails in parallel within batch
const promises = batch.map(async (subscriber) => {
try {
await sendAIFeatureLaunchEmail(subscriber.email);
results.sent++;
} catch (error) {
results.failed++;
results.errors.push(`Failed to send to ${subscriber.email}`);
console.error(`Failed to send to ${subscriber.email}:`, error);
}
});
await Promise.allSettled(promises);
// Small delay between batches to be nice to the email service
if (i + batchSize < subscribers.length) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
return NextResponse.json({
success: true,
message: `Broadcast completed. Sent to ${results.sent} subscribers.`,
sent: results.sent,
failed: results.failed,
total: subscribers.length,
errors: results.errors.length > 0 ? results.errors : undefined,
});
} catch (error) {
console.error('Newsletter broadcast error:', error);
return NextResponse.json(
{
error: 'Failed to send broadcast emails. Please try again.',
},
{ status: 500 }
);
}
}
/**
* GET /api/newsletter/broadcast
* Get subscriber count and preview
* PROTECTED: Only authenticated users
*/
export async function GET(request: NextRequest) {
try {
// Check authentication using newsletter-admin cookie
const adminCookie = cookies().get('newsletter-admin')?.value;
if (adminCookie !== 'authenticated') {
return NextResponse.json(
{ error: 'Unauthorized. Please log in.' },
{ status: 401 }
);
}
const subscriberCount = await db.newsletterSubscription.count({
where: {
status: 'subscribed',
},
});
const recentSubscribers = await db.newsletterSubscription.findMany({
where: {
status: 'subscribed',
},
select: {
email: true,
createdAt: true,
},
orderBy: {
createdAt: 'desc',
},
take: 5,
});
return NextResponse.json({
total: subscriberCount,
recent: recentSubscribers,
});
} catch (error) {
console.error('Error fetching subscriber info:', error);
return NextResponse.json(
{ error: 'Failed to fetch subscriber information' },
{ status: 500 }
);
}
}
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { sendAIFeatureLaunchEmail } from '@/lib/email';
import { rateLimit, RateLimits } from '@/lib/rateLimit';
/**
* POST /api/newsletter/broadcast
* Send AI feature launch email to all subscribed users
* PROTECTED: Only authenticated users can access (you may want to add admin check)
*/
export async function POST(request: NextRequest) {
try {
// Check authentication using newsletter-admin cookie
const adminCookie = cookies().get('newsletter-admin')?.value;
if (adminCookie !== 'authenticated') {
return NextResponse.json(
{ error: 'Unauthorized. Please log in.' },
{ status: 401 }
);
}
// Optional: Add admin check here
// const user = await db.user.findUnique({ where: { id: userId } });
// if (user?.role !== 'ADMIN') {
// return NextResponse.json({ error: 'Forbidden. Admin access required.' }, { status: 403 });
// }
// Rate limiting (prevent accidental spam)
const rateLimitResult = rateLimit('newsletter-admin', {
name: 'newsletter-broadcast',
maxRequests: 2, // Only 2 broadcasts per hour
windowSeconds: 60 * 60,
});
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many broadcast attempts. Please wait before trying again.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000),
},
{ status: 429 }
);
}
// Get all subscribed users
const subscribers = await db.newsletterSubscription.findMany({
where: {
status: 'subscribed',
},
select: {
email: true,
},
});
if (subscribers.length === 0) {
return NextResponse.json({
success: true,
message: 'No subscribers found',
sent: 0,
});
}
// Send emails in batches to avoid overwhelming Resend
const batchSize = 10;
const results = {
sent: 0,
failed: 0,
errors: [] as string[],
};
for (let i = 0; i < subscribers.length; i += batchSize) {
const batch = subscribers.slice(i, i + batchSize);
// Send emails in parallel within batch
const promises = batch.map(async (subscriber) => {
try {
await sendAIFeatureLaunchEmail(subscriber.email);
results.sent++;
} catch (error) {
results.failed++;
results.errors.push(`Failed to send to ${subscriber.email}`);
console.error(`Failed to send to ${subscriber.email}:`, error);
}
});
await Promise.allSettled(promises);
// Small delay between batches to be nice to the email service
if (i + batchSize < subscribers.length) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
return NextResponse.json({
success: true,
message: `Broadcast completed. Sent to ${results.sent} subscribers.`,
sent: results.sent,
failed: results.failed,
total: subscribers.length,
errors: results.errors.length > 0 ? results.errors : undefined,
});
} catch (error) {
console.error('Newsletter broadcast error:', error);
return NextResponse.json(
{
error: 'Failed to send broadcast emails. Please try again.',
},
{ status: 500 }
);
}
}
/**
* GET /api/newsletter/broadcast
* Get subscriber count and preview
* PROTECTED: Only authenticated users
*/
export async function GET(request: NextRequest) {
try {
// Check authentication using newsletter-admin cookie
const adminCookie = cookies().get('newsletter-admin')?.value;
if (adminCookie !== 'authenticated') {
return NextResponse.json(
{ error: 'Unauthorized. Please log in.' },
{ status: 401 }
);
}
const subscriberCount = await db.newsletterSubscription.count({
where: {
status: 'subscribed',
},
});
const recentSubscribers = await db.newsletterSubscription.findMany({
where: {
status: 'subscribed',
},
select: {
email: true,
createdAt: true,
},
orderBy: {
createdAt: 'desc',
},
take: 5,
});
return NextResponse.json({
total: subscriberCount,
recent: recentSubscribers,
});
} catch (error) {
console.error('Error fetching subscriber info:', error);
return NextResponse.json(
{ error: 'Failed to fetch subscriber information' },
{ status: 500 }
);
}
}

View File

@@ -1,91 +1,91 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { newsletterSubscribeSchema, validateRequest } from '@/lib/validationSchemas';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { sendNewsletterWelcomeEmail } from '@/lib/email';
/**
* POST /api/newsletter/subscribe
* Subscribe to AI features newsletter
* Public endpoint - no authentication required
*/
export async function POST(request: NextRequest) {
try {
// Get client identifier for rate limiting
const clientId = getClientIdentifier(request);
// Apply rate limiting (5 per hour)
const rateLimitResult = rateLimit(clientId, RateLimits.NEWSLETTER_SUBSCRIBE);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many subscription attempts. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000),
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
'Retry-After': Math.ceil((rateLimitResult.reset - Date.now()) / 1000).toString(),
},
}
);
}
// Parse and validate request body
const body = await request.json();
const validation = await validateRequest(newsletterSubscribeSchema, body);
if (!validation.success) {
return NextResponse.json(validation.error, { status: 400 });
}
const { email } = validation.data;
// Check if email already subscribed
const existing = await db.newsletterSubscription.findUnique({
where: { email },
});
if (existing) {
// If already subscribed, return success (idempotent)
// Don't reveal if email exists for privacy
return NextResponse.json({
success: true,
message: 'Successfully subscribed to AI features newsletter!',
alreadySubscribed: true,
});
}
// Create new subscription
await db.newsletterSubscription.create({
data: {
email,
source: 'ai-coming-soon',
status: 'subscribed',
},
});
// Send welcome email (don't block response)
sendNewsletterWelcomeEmail(email).catch((error) => {
console.error('Failed to send welcome email (non-blocking):', error);
});
return NextResponse.json({
success: true,
message: 'Successfully subscribed to AI features newsletter!',
alreadySubscribed: false,
});
} catch (error) {
console.error('Newsletter subscription error:', error);
return NextResponse.json(
{
error: 'Failed to subscribe to newsletter. Please try again.',
},
{ status: 500 }
);
}
}
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { newsletterSubscribeSchema, validateRequest } from '@/lib/validationSchemas';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { sendNewsletterWelcomeEmail } from '@/lib/email';
/**
* POST /api/newsletter/subscribe
* Subscribe to AI features newsletter
* Public endpoint - no authentication required
*/
export async function POST(request: NextRequest) {
try {
// Get client identifier for rate limiting
const clientId = getClientIdentifier(request);
// Apply rate limiting (5 per hour)
const rateLimitResult = rateLimit(clientId, RateLimits.NEWSLETTER_SUBSCRIBE);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many subscription attempts. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000),
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
'Retry-After': Math.ceil((rateLimitResult.reset - Date.now()) / 1000).toString(),
},
}
);
}
// Parse and validate request body
const body = await request.json();
const validation = await validateRequest(newsletterSubscribeSchema, body);
if (!validation.success) {
return NextResponse.json(validation.error, { status: 400 });
}
const { email } = validation.data;
// Check if email already subscribed
const existing = await db.newsletterSubscription.findUnique({
where: { email },
});
if (existing) {
// If already subscribed, return success (idempotent)
// Don't reveal if email exists for privacy
return NextResponse.json({
success: true,
message: 'Successfully subscribed to AI features newsletter!',
alreadySubscribed: true,
});
}
// Create new subscription
await db.newsletterSubscription.create({
data: {
email,
source: 'ai-coming-soon',
status: 'subscribed',
},
});
// Send welcome email (don't block response)
sendNewsletterWelcomeEmail(email).catch((error) => {
console.error('Failed to send welcome email (non-blocking):', error);
});
return NextResponse.json({
success: true,
message: 'Successfully subscribed to AI features newsletter!',
alreadySubscribed: false,
});
} catch (error) {
console.error('Newsletter subscription error:', error);
return NextResponse.json(
{
error: 'Failed to subscribe to newsletter. Please try again.',
},
{ status: 500 }
);
}
}

View File

@@ -1,205 +1,205 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { z } from 'zod';
import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
const updateQRSchema = z.object({
title: z.string().min(1).optional(),
content: z.any().optional(),
tags: z.array(z.string()).optional(),
style: z.any().optional(),
});
// GET /api/qrs/[id] - Get a single QR code
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const qrCode = await db.qRCode.findFirst({
where: {
id: params.id,
userId,
},
include: {
scans: {
orderBy: { ts: 'desc' },
take: 100,
},
},
});
if (!qrCode) {
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
}
return NextResponse.json(qrCode);
} catch (error) {
console.error('Error fetching QR code:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// PATCH /api/qrs/[id] - Update a QR code
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
// CSRF Protection
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
}
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.QR_MODIFY);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const data = updateQRSchema.parse(body);
// Check ownership
const existing = await db.qRCode.findFirst({
where: {
id: params.id,
userId,
},
});
if (!existing) {
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
}
// Static QR codes cannot be edited
if (existing.type === 'STATIC' && data.content) {
return NextResponse.json(
{ error: 'Static QR codes cannot be edited' },
{ status: 400 }
);
}
// Update QR code
const updated = await db.qRCode.update({
where: { id: params.id },
data: {
...(data.title && { title: data.title }),
...(data.content && { content: data.content }),
...(data.tags && { tags: data.tags }),
...(data.style && { style: data.style }),
},
});
return NextResponse.json(updated);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid input', details: error.errors },
{ status: 400 }
);
}
console.error('Error updating QR code:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// DELETE /api/qrs/[id] - Delete a QR code
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
// CSRF Protection
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
}
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.QR_MODIFY);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Check ownership
const existing = await db.qRCode.findFirst({
where: {
id: params.id,
userId,
},
});
if (!existing) {
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
}
// Delete QR code (cascades to scans)
await db.qRCode.delete({
where: { id: params.id },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting QR code:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { z } from 'zod';
import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
const updateQRSchema = z.object({
title: z.string().min(1).optional(),
content: z.any().optional(),
tags: z.array(z.string()).optional(),
style: z.any().optional(),
});
// GET /api/qrs/[id] - Get a single QR code
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const qrCode = await db.qRCode.findFirst({
where: {
id: params.id,
userId,
},
include: {
scans: {
orderBy: { ts: 'desc' },
take: 100,
},
},
});
if (!qrCode) {
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
}
return NextResponse.json(qrCode);
} catch (error) {
console.error('Error fetching QR code:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// PATCH /api/qrs/[id] - Update a QR code
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
// CSRF Protection
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
}
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.QR_MODIFY);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const data = updateQRSchema.parse(body);
// Check ownership
const existing = await db.qRCode.findFirst({
where: {
id: params.id,
userId,
},
});
if (!existing) {
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
}
// Static QR codes cannot be edited
if (existing.type === 'STATIC' && data.content) {
return NextResponse.json(
{ error: 'Static QR codes cannot be edited' },
{ status: 400 }
);
}
// Update QR code
const updated = await db.qRCode.update({
where: { id: params.id },
data: {
...(data.title && { title: data.title }),
...(data.content && { content: data.content }),
...(data.tags && { tags: data.tags }),
...(data.style && { style: data.style }),
},
});
return NextResponse.json(updated);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid input', details: error.errors },
{ status: 400 }
);
}
console.error('Error updating QR code:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// DELETE /api/qrs/[id] - Delete a QR code
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
// CSRF Protection
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
}
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.QR_MODIFY);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Check ownership
const existing = await db.qRCode.findFirst({
where: {
id: params.id,
userId,
},
});
if (!existing) {
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
}
// Delete QR code (cascades to scans)
await db.qRCode.delete({
where: { id: params.id },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting QR code:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -1,60 +1,60 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
export async function DELETE(request: NextRequest) {
try {
// CSRF Protection
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json(
{ error: csrfCheck.error },
{ status: 403 }
);
}
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.QR_DELETE_ALL);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Delete all QR codes for this user
const result = await db.qRCode.deleteMany({
where: { userId },
});
return NextResponse.json({
success: true,
deletedCount: result.count,
});
} catch (error) {
console.error('Error deleting all QR codes:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
export async function DELETE(request: NextRequest) {
try {
// CSRF Protection
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json(
{ error: csrfCheck.error },
{ status: 403 }
);
}
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.QR_DELETE_ALL);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Delete all QR codes for this user
const result = await db.qRCode.deleteMany({
where: { userId },
});
return NextResponse.json({
success: true,
deletedCount: result.count,
});
} catch (error) {
console.error('Error deleting all QR codes:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -1,93 +1,93 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { generateSlug } from '@/lib/hash';
// POST /api/qrs/static - Create a STATIC QR code that contains the direct URL
export async function POST(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { title, contentType, content, tags, style } = body;
// Generate the actual QR content based on type
let qrContent = '';
switch (contentType) {
case 'URL':
qrContent = content.url;
break;
case 'PHONE':
qrContent = `tel:${content.phone}`;
break;
case 'SMS':
qrContent = `sms:${content.phone}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`;
break;
case 'VCARD':
qrContent = `BEGIN:VCARD
VERSION:3.0
FN:${content.firstName || ''} ${content.lastName || ''}
N:${content.lastName || ''};${content.firstName || ''};;;
${content.organization ? `ORG:${content.organization}` : ''}
${content.title ? `TITLE:${content.title}` : ''}
${content.email ? `EMAIL:${content.email}` : ''}
${content.phone ? `TEL:${content.phone}` : ''}
END:VCARD`;
break;
case 'GEO':
const lat = content.latitude || 0;
const lon = content.longitude || 0;
const label = content.label ? `?q=${encodeURIComponent(content.label)}` : '';
qrContent = `geo:${lat},${lon}${label}`;
break;
case 'TEXT':
qrContent = content.text;
break;
case 'WHATSAPP':
qrContent = `https://wa.me/${content.phone}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`;
break;
default:
qrContent = content.url || 'https://example.com';
}
// Store the QR content in a special field
const enrichedContent = {
...content,
qrContent // This is what the QR code should actually contain
};
// Generate slug
const slug = generateSlug(title);
// Create QR code
const qrCode = await db.qRCode.create({
data: {
userId,
title,
type: 'STATIC',
contentType,
content: enrichedContent,
tags: tags || [],
style: style || {
foregroundColor: '#000000',
backgroundColor: '#FFFFFF',
cornerStyle: 'square',
size: 200,
},
slug,
status: 'ACTIVE',
},
});
return NextResponse.json(qrCode);
} catch (error) {
console.error('Error creating static QR code:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { generateSlug } from '@/lib/hash';
// POST /api/qrs/static - Create a STATIC QR code that contains the direct URL
export async function POST(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { title, contentType, content, tags, style } = body;
// Generate the actual QR content based on type
let qrContent = '';
switch (contentType) {
case 'URL':
qrContent = content.url;
break;
case 'PHONE':
qrContent = `tel:${content.phone}`;
break;
case 'SMS':
qrContent = `sms:${content.phone}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`;
break;
case 'VCARD':
qrContent = `BEGIN:VCARD
VERSION:3.0
FN:${content.firstName || ''} ${content.lastName || ''}
N:${content.lastName || ''};${content.firstName || ''};;;
${content.organization ? `ORG:${content.organization}` : ''}
${content.title ? `TITLE:${content.title}` : ''}
${content.email ? `EMAIL:${content.email}` : ''}
${content.phone ? `TEL:${content.phone}` : ''}
END:VCARD`;
break;
case 'GEO':
const lat = content.latitude || 0;
const lon = content.longitude || 0;
const label = content.label ? `?q=${encodeURIComponent(content.label)}` : '';
qrContent = `geo:${lat},${lon}${label}`;
break;
case 'TEXT':
qrContent = content.text;
break;
case 'WHATSAPP':
qrContent = `https://wa.me/${content.phone}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`;
break;
default:
qrContent = content.url || 'https://example.com';
}
// Store the QR content in a special field
const enrichedContent = {
...content,
qrContent // This is what the QR code should actually contain
};
// Generate slug
const slug = generateSlug(title);
// Create QR code
const qrCode = await db.qRCode.create({
data: {
userId,
title,
type: 'STATIC',
contentType,
content: enrichedContent,
tags: tags || [],
style: style || {
foregroundColor: '#000000',
backgroundColor: '#FFFFFF',
cornerStyle: 'square',
size: 200,
},
slug,
status: 'ACTIVE',
},
});
return NextResponse.json(qrCode);
} catch (error) {
console.error('Error creating static QR code:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -1,89 +1,89 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
export async function POST(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.STRIPE_CANCEL);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get user with subscription info
const user = await db.user.findUnique({
where: { id: userId },
select: {
stripeSubscriptionId: true,
plan: true,
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Already on free plan
if (user.plan === 'FREE') {
return NextResponse.json({ error: 'Already on free plan' }, { status: 400 });
}
// No active subscription
if (!user.stripeSubscriptionId) {
// Just update plan to FREE if somehow plan is not FREE but no subscription
await db.user.update({
where: { id: userId },
data: {
plan: 'FREE',
stripePriceId: null,
stripeCurrentPeriodEnd: null,
},
});
return NextResponse.json({ success: true });
}
// Cancel the Stripe subscription
await stripe.subscriptions.cancel(user.stripeSubscriptionId);
// Update user plan to FREE
await db.user.update({
where: { id: userId },
data: {
plan: 'FREE',
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: null,
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error canceling subscription:', error);
return NextResponse.json(
{ error: 'Failed to cancel subscription' },
{ status: 500 }
);
}
}
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
export async function POST(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.STRIPE_CANCEL);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get user with subscription info
const user = await db.user.findUnique({
where: { id: userId },
select: {
stripeSubscriptionId: true,
plan: true,
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Already on free plan
if (user.plan === 'FREE') {
return NextResponse.json({ error: 'Already on free plan' }, { status: 400 });
}
// No active subscription
if (!user.stripeSubscriptionId) {
// Just update plan to FREE if somehow plan is not FREE but no subscription
await db.user.update({
where: { id: userId },
data: {
plan: 'FREE',
stripePriceId: null,
stripeCurrentPeriodEnd: null,
},
});
return NextResponse.json({ success: true });
}
// Cancel the Stripe subscription
await stripe.subscriptions.cancel(user.stripeSubscriptionId);
// Update user plan to FREE
await db.user.update({
where: { id: userId },
data: {
plan: 'FREE',
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: null,
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error canceling subscription:', error);
return NextResponse.json(
{ error: 'Failed to cancel subscription' },
{ status: 500 }
);
}
}

View File

@@ -1,78 +1,78 @@
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import { cookies } from 'next/headers';
export async function POST(request: NextRequest) {
try {
// Get user email from request body (since we're using simple auth, not NextAuth)
const { priceId, plan, userEmail } = await request.json();
if (!userEmail) {
return NextResponse.json({ error: 'Unauthorized - No user email provided' }, { status: 401 });
}
if (!priceId || !plan) {
return NextResponse.json(
{ error: 'Missing priceId or plan' },
{ status: 400 }
);
}
// Get user from database
const user = await db.user.findUnique({
where: { email: userEmail },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Create or get Stripe customer
let customerId = user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: {
userId: user.id,
},
});
customerId = customer.id;
// Update user with Stripe customer ID
await db.user.update({
where: { id: user.id },
data: { stripeCustomerId: customerId },
});
}
// Create Stripe Checkout Session
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
metadata: {
userId: user.id,
plan,
},
});
return NextResponse.json({ url: checkoutSession.url });
} catch (error) {
console.error('Error creating checkout session:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import { cookies } from 'next/headers';
export async function POST(request: NextRequest) {
try {
// Get user email from request body (since we're using simple auth, not NextAuth)
const { priceId, plan, userEmail } = await request.json();
if (!userEmail) {
return NextResponse.json({ error: 'Unauthorized - No user email provided' }, { status: 401 });
}
if (!priceId || !plan) {
return NextResponse.json(
{ error: 'Missing priceId or plan' },
{ status: 400 }
);
}
// Get user from database
const user = await db.user.findUnique({
where: { email: userEmail },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Create or get Stripe customer
let customerId = user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: {
userId: user.id,
},
});
customerId = customer.id;
// Update user with Stripe customer ID
await db.user.update({
where: { id: user.id },
data: { stripeCustomerId: customerId },
});
}
// Create Stripe Checkout Session
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
metadata: {
userId: user.id,
plan,
},
});
return NextResponse.json({ url: checkoutSession.url });
} catch (error) {
console.error('Error creating checkout session:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -1,115 +1,115 @@
import { NextRequest, NextResponse } from 'next/server';
import { stripe, STRIPE_PLANS } from '@/lib/stripe';
import { db } from '@/lib/db';
import { cookies } from 'next/headers';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
export async function POST(request: NextRequest) {
try {
// Get user from cookie (using userId like other routes)
const cookieStore = await cookies();
const userId = cookieStore.get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.STRIPE_CHECKOUT);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized - Please log in' }, { status: 401 });
}
// Get plan and billing interval from request
const { plan, billingInterval = 'month' } = await request.json();
if (!plan || !['PRO', 'BUSINESS'].includes(plan)) {
return NextResponse.json(
{ error: 'Invalid plan. Must be PRO or BUSINESS' },
{ status: 400 }
);
}
// Get the Stripe price ID for the plan
const planConfig = STRIPE_PLANS[plan as 'PRO' | 'BUSINESS'];
const priceId = billingInterval === 'year' ? planConfig.priceIdYearly : planConfig.priceId;
if (!priceId) {
return NextResponse.json(
{ error: 'Stripe price ID not configured for this plan' },
{ status: 500 }
);
}
// Get user from database
const user = await db.user.findUnique({
where: { id: userId },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Create or get Stripe customer
let customerId = user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: {
userId: user.id,
},
});
customerId = customer.id;
// Update user with Stripe customer ID
await db.user.update({
where: { id: user.id },
data: { stripeCustomerId: customerId },
});
}
// Create Stripe Checkout Session
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
metadata: {
userId: user.id,
plan,
},
});
return NextResponse.json({ url: checkoutSession.url });
} catch (error) {
console.error('Error creating checkout session:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
import { NextRequest, NextResponse } from 'next/server';
import { stripe, STRIPE_PLANS } from '@/lib/stripe';
import { db } from '@/lib/db';
import { cookies } from 'next/headers';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
export async function POST(request: NextRequest) {
try {
// Get user from cookie (using userId like other routes)
const cookieStore = await cookies();
const userId = cookieStore.get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.STRIPE_CHECKOUT);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized - Please log in' }, { status: 401 });
}
// Get plan and billing interval from request
const { plan, billingInterval = 'month' } = await request.json();
if (!plan || !['PRO', 'BUSINESS'].includes(plan)) {
return NextResponse.json(
{ error: 'Invalid plan. Must be PRO or BUSINESS' },
{ status: 400 }
);
}
// Get the Stripe price ID for the plan
const planConfig = STRIPE_PLANS[plan as 'PRO' | 'BUSINESS'];
const priceId = billingInterval === 'year' ? planConfig.priceIdYearly : planConfig.priceId;
if (!priceId) {
return NextResponse.json(
{ error: 'Stripe price ID not configured for this plan' },
{ status: 500 }
);
}
// Get user from database
const user = await db.user.findUnique({
where: { id: userId },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Create or get Stripe customer
let customerId = user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: {
userId: user.id,
},
});
customerId = customer.id;
// Update user with Stripe customer ID
await db.user.update({
where: { id: user.id },
data: { stripeCustomerId: customerId },
});
}
// Create Stripe Checkout Session
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
metadata: {
userId: user.id,
plan,
},
});
return NextResponse.json({ url: checkoutSession.url });
} catch (error) {
console.error('Error creating checkout session:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -1,70 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
export async function POST(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.STRIPE_PORTAL);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get user with Stripe customer ID
const user = await db.user.findUnique({
where: { id: userId },
select: {
stripeCustomerId: true,
email: true,
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// If user doesn't have a Stripe customer ID, they can't access the portal
if (!user.stripeCustomerId) {
return NextResponse.json(
{ error: 'No active subscription found' },
{ status: 400 }
);
}
// Create Stripe Customer Portal session
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/settings`,
});
return NextResponse.json({ url: portalSession.url });
} catch (error) {
console.error('Error creating portal session:', error);
return NextResponse.json(
{ error: 'Failed to create portal session' },
{ status: 500 }
);
}
}
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
export async function POST(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.STRIPE_PORTAL);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get user with Stripe customer ID
const user = await db.user.findUnique({
where: { id: userId },
select: {
stripeCustomerId: true,
email: true,
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// If user doesn't have a Stripe customer ID, they can't access the portal
if (!user.stripeCustomerId) {
return NextResponse.json(
{ error: 'No active subscription found' },
{ status: 400 }
);
}
// Create Stripe Customer Portal session
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/settings`,
});
return NextResponse.json({ url: portalSession.url });
} catch (error) {
console.error('Error creating portal session:', error);
return NextResponse.json(
{ error: 'Failed to create portal session' },
{ status: 500 }
);
}
}

View File

@@ -1,113 +1,113 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
/**
* Manual sync endpoint to update user subscription from Stripe
* Use this if the automatic webhook/verify failed
*/
export async function POST(request: NextRequest) {
try {
// Use cookie-based auth
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: userId },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (!user.stripeCustomerId) {
return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 });
}
// Get all subscriptions for this customer
const subscriptions = await stripe.subscriptions.list({
customer: user.stripeCustomerId,
status: 'active',
limit: 1,
});
if (subscriptions.data.length === 0) {
// No active subscription - set to FREE
await db.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: null,
plan: 'FREE',
},
});
return NextResponse.json({
success: true,
plan: 'FREE',
message: 'No active subscription found, set to FREE plan',
});
}
const subscription: any = subscriptions.data[0];
// Determine plan from price ID
const priceId = subscription.items.data[0]?.price?.id;
let plan = 'PRO'; // default
// Check against known price IDs
if (priceId === process.env.STRIPE_PRICE_ID_BUSINESS_MONTHLY ||
priceId === process.env.STRIPE_PRICE_ID_BUSINESS_YEARLY) {
plan = 'BUSINESS';
} else if (priceId === process.env.STRIPE_PRICE_ID_PRO_MONTHLY ||
priceId === process.env.STRIPE_PRICE_ID_PRO_YEARLY) {
plan = 'PRO';
}
// Get current_period_end
const periodEndTimestamp = subscription.current_period_end
|| subscription.currentPeriodEnd
|| subscription.billing_cycle_anchor;
const currentPeriodEnd = periodEndTimestamp
? new Date(periodEndTimestamp * 1000)
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
console.log('Syncing subscription:', {
subscriptionId: subscription.id,
priceId,
plan,
periodEndTimestamp,
currentPeriodEnd,
});
// Update user in database
await db.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: priceId,
stripeCurrentPeriodEnd: currentPeriodEnd,
plan: plan as any,
},
});
return NextResponse.json({
success: true,
plan,
subscriptionId: subscription.id,
currentPeriodEnd,
});
} catch (error) {
console.error('Error syncing subscription:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
/**
* Manual sync endpoint to update user subscription from Stripe
* Use this if the automatic webhook/verify failed
*/
export async function POST(request: NextRequest) {
try {
// Use cookie-based auth
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: userId },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (!user.stripeCustomerId) {
return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 });
}
// Get all subscriptions for this customer
const subscriptions = await stripe.subscriptions.list({
customer: user.stripeCustomerId,
status: 'active',
limit: 1,
});
if (subscriptions.data.length === 0) {
// No active subscription - set to FREE
await db.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: null,
plan: 'FREE',
},
});
return NextResponse.json({
success: true,
plan: 'FREE',
message: 'No active subscription found, set to FREE plan',
});
}
const subscription: any = subscriptions.data[0];
// Determine plan from price ID
const priceId = subscription.items.data[0]?.price?.id;
let plan = 'PRO'; // default
// Check against known price IDs
if (priceId === process.env.STRIPE_PRICE_ID_BUSINESS_MONTHLY ||
priceId === process.env.STRIPE_PRICE_ID_BUSINESS_YEARLY) {
plan = 'BUSINESS';
} else if (priceId === process.env.STRIPE_PRICE_ID_PRO_MONTHLY ||
priceId === process.env.STRIPE_PRICE_ID_PRO_YEARLY) {
plan = 'PRO';
}
// Get current_period_end
const periodEndTimestamp = subscription.current_period_end
|| subscription.currentPeriodEnd
|| subscription.billing_cycle_anchor;
const currentPeriodEnd = periodEndTimestamp
? new Date(periodEndTimestamp * 1000)
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
console.log('Syncing subscription:', {
subscriptionId: subscription.id,
priceId,
plan,
periodEndTimestamp,
currentPeriodEnd,
});
// Update user in database
await db.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: priceId,
stripeCurrentPeriodEnd: currentPeriodEnd,
plan: plan as any,
},
});
return NextResponse.json({
success: true,
plan,
subscriptionId: subscription.id,
currentPeriodEnd,
});
} catch (error) {
console.error('Error syncing subscription:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -1,97 +1,97 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
export async function POST(request: NextRequest) {
try {
// Use cookie-based auth instead of NextAuth
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: userId },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (!user.stripeCustomerId) {
return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 });
}
// Get the most recent checkout session for this customer
const checkoutSessions = await stripe.checkout.sessions.list({
customer: user.stripeCustomerId,
limit: 1,
});
if (checkoutSessions.data.length === 0) {
return NextResponse.json({ error: 'No checkout session found' }, { status: 404 });
}
const checkoutSession = checkoutSessions.data[0];
// Only process if payment was successful
if (checkoutSession.payment_status === 'paid' && checkoutSession.subscription) {
const subscriptionId = typeof checkoutSession.subscription === 'string'
? checkoutSession.subscription
: checkoutSession.subscription.id;
// Retrieve the full subscription object
const subscription: any = await stripe.subscriptions.retrieve(subscriptionId);
// Determine plan from metadata or price ID
const plan = checkoutSession.metadata?.plan || 'PRO';
// Debug log to see the subscription structure
console.log('Full subscription object:', JSON.stringify(subscription, null, 2));
// Get current_period_end - Stripe returns it as a Unix timestamp
// Try different possible field names
const periodEndTimestamp = subscription.current_period_end
|| subscription.currentPeriodEnd
|| subscription.billing_cycle_anchor;
const currentPeriodEnd = periodEndTimestamp
? new Date(periodEndTimestamp * 1000)
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // Default to 30 days from now
console.log('Subscription data:', {
id: subscription.id,
periodEndTimestamp,
currentPeriodEnd,
priceId: subscription.items?.data?.[0]?.price?.id,
});
// Update user in database
await db.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: currentPeriodEnd,
plan: plan as any,
},
});
return NextResponse.json({
success: true,
plan,
subscriptionId: subscription.id,
});
}
return NextResponse.json({ error: 'Payment not completed' }, { status: 400 });
} catch (error) {
console.error('Error verifying session:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
export async function POST(request: NextRequest) {
try {
// Use cookie-based auth instead of NextAuth
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: userId },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (!user.stripeCustomerId) {
return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 });
}
// Get the most recent checkout session for this customer
const checkoutSessions = await stripe.checkout.sessions.list({
customer: user.stripeCustomerId,
limit: 1,
});
if (checkoutSessions.data.length === 0) {
return NextResponse.json({ error: 'No checkout session found' }, { status: 404 });
}
const checkoutSession = checkoutSessions.data[0];
// Only process if payment was successful
if (checkoutSession.payment_status === 'paid' && checkoutSession.subscription) {
const subscriptionId = typeof checkoutSession.subscription === 'string'
? checkoutSession.subscription
: checkoutSession.subscription.id;
// Retrieve the full subscription object
const subscription: any = await stripe.subscriptions.retrieve(subscriptionId);
// Determine plan from metadata or price ID
const plan = checkoutSession.metadata?.plan || 'PRO';
// Debug log to see the subscription structure
console.log('Full subscription object:', JSON.stringify(subscription, null, 2));
// Get current_period_end - Stripe returns it as a Unix timestamp
// Try different possible field names
const periodEndTimestamp = subscription.current_period_end
|| subscription.currentPeriodEnd
|| subscription.billing_cycle_anchor;
const currentPeriodEnd = periodEndTimestamp
? new Date(periodEndTimestamp * 1000)
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // Default to 30 days from now
console.log('Subscription data:', {
id: subscription.id,
periodEndTimestamp,
currentPeriodEnd,
priceId: subscription.items?.data?.[0]?.price?.id,
});
// Update user in database
await db.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: currentPeriodEnd,
plan: plan as any,
},
});
return NextResponse.json({
success: true,
plan,
subscriptionId: subscription.id,
});
}
return NextResponse.json({ error: 'Payment not completed' }, { status: 400 });
} catch (error) {
console.error('Error verifying session:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -1,116 +1,116 @@
import { NextRequest, NextResponse } from 'next/server';
import { headers } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import Stripe from 'stripe';
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = headers().get('stripe-signature');
if (!signature) {
return NextResponse.json(
{ error: 'No signature' },
{ status: 400 }
);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (error) {
console.error('Webhook signature verification failed:', error);
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 400 }
);
}
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
if (session.mode === 'subscription') {
const subscription: any = await stripe.subscriptions.retrieve(
session.subscription as string
);
const periodEndTimestamp = subscription.current_period_end
|| subscription.currentPeriodEnd
|| subscription.billing_cycle_anchor;
const currentPeriodEnd = periodEndTimestamp
? new Date(periodEndTimestamp * 1000)
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await db.user.update({
where: {
stripeCustomerId: session.customer as string,
},
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: currentPeriodEnd,
plan: (session.metadata?.plan || 'FREE') as any,
},
});
}
break;
}
case 'customer.subscription.updated': {
const subscription: any = event.data.object as Stripe.Subscription;
const periodEndTimestamp = subscription.current_period_end
|| subscription.currentPeriodEnd
|| subscription.billing_cycle_anchor;
const currentPeriodEnd = periodEndTimestamp
? new Date(periodEndTimestamp * 1000)
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await db.user.update({
where: {
stripeSubscriptionId: subscription.id,
},
data: {
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: currentPeriodEnd,
},
});
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await db.user.update({
where: {
stripeSubscriptionId: subscription.id,
},
data: {
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: null,
plan: 'FREE',
},
});
break;
}
}
return NextResponse.json({ received: true });
} catch (error) {
console.error('Error processing webhook:', error);
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 500 }
);
}
}
import { NextRequest, NextResponse } from 'next/server';
import { headers } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import Stripe from 'stripe';
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = headers().get('stripe-signature');
if (!signature) {
return NextResponse.json(
{ error: 'No signature' },
{ status: 400 }
);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (error) {
console.error('Webhook signature verification failed:', error);
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 400 }
);
}
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
if (session.mode === 'subscription') {
const subscription: any = await stripe.subscriptions.retrieve(
session.subscription as string
);
const periodEndTimestamp = subscription.current_period_end
|| subscription.currentPeriodEnd
|| subscription.billing_cycle_anchor;
const currentPeriodEnd = periodEndTimestamp
? new Date(periodEndTimestamp * 1000)
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await db.user.update({
where: {
stripeCustomerId: session.customer as string,
},
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: currentPeriodEnd,
plan: (session.metadata?.plan || 'FREE') as any,
},
});
}
break;
}
case 'customer.subscription.updated': {
const subscription: any = event.data.object as Stripe.Subscription;
const periodEndTimestamp = subscription.current_period_end
|| subscription.currentPeriodEnd
|| subscription.billing_cycle_anchor;
const currentPeriodEnd = periodEndTimestamp
? new Date(periodEndTimestamp * 1000)
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await db.user.update({
where: {
stripeSubscriptionId: subscription.id,
},
data: {
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: currentPeriodEnd,
},
});
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await db.user.update({
where: {
stripeSubscriptionId: subscription.id,
},
data: {
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: null,
plan: 'FREE',
},
});
break;
}
}
return NextResponse.json({ received: true });
} catch (error) {
console.error('Error processing webhook:', error);
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 500 }
);
}
}

View File

@@ -1,41 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get user from database
const user = await db.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
createdAt: true,
updatedAt: true,
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json({
database: user,
localStorage: 'Check in browser console',
});
} catch (error) {
console.error('Debug error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get user from database
const user = await db.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
createdAt: true,
updatedAt: true,
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json({
database: user,
localStorage: 'Check in browser console',
});
} catch (error) {
console.error('Debug error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -1,86 +1,86 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { stripe } from '@/lib/stripe';
import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
export async function DELETE(request: NextRequest) {
try {
// CSRF Protection
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json(
{ error: csrfCheck.error },
{ status: 403 }
);
}
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.ACCOUNT_DELETE);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get user data including Stripe information
const user = await db.user.findUnique({
where: { id: userId },
select: {
id: true,
stripeSubscriptionId: true,
stripeCustomerId: true,
plan: true,
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Cancel Stripe subscription if user has one
if (user.stripeSubscriptionId && user.plan !== 'FREE') {
try {
await stripe.subscriptions.cancel(user.stripeSubscriptionId);
} catch (stripeError) {
console.error('Error canceling Stripe subscription:', stripeError);
// Continue with deletion even if Stripe cancellation fails
}
}
// Delete user and all related data (cascading deletes should handle QR codes, scans, etc.)
await db.user.delete({
where: { id: userId },
});
// Clear auth cookie
cookies().delete('userId');
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting account:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { stripe } from '@/lib/stripe';
import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
export async function DELETE(request: NextRequest) {
try {
// CSRF Protection
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json(
{ error: csrfCheck.error },
{ status: 403 }
);
}
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.ACCOUNT_DELETE);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get user data including Stripe information
const user = await db.user.findUnique({
where: { id: userId },
select: {
id: true,
stripeSubscriptionId: true,
stripeCustomerId: true,
plan: true,
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Cancel Stripe subscription if user has one
if (user.stripeSubscriptionId && user.plan !== 'FREE') {
try {
await stripe.subscriptions.cancel(user.stripeSubscriptionId);
} catch (stripeError) {
console.error('Error canceling Stripe subscription:', stripeError);
// Continue with deletion even if Stripe cancellation fails
}
}
// Delete user and all related data (cascading deletes should handle QR codes, scans, etc.)
await db.user.delete({
where: { id: userId },
});
// Clear auth cookie
cookies().delete('userId');
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting account:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -1,103 +1,103 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import bcrypt from 'bcryptjs';
import { csrfProtection } from '@/lib/csrf';
import { changePasswordSchema, validateRequest } from '@/lib/validationSchemas';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
export async function PATCH(request: NextRequest) {
try {
// CSRF Protection
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json(
{ error: csrfCheck.error },
{ status: 403 }
);
}
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.PASSWORD_CHANGE);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
// Validate request body
const validation = await validateRequest(changePasswordSchema, body);
if (!validation.success) {
return NextResponse.json(validation.error, { status: 400 });
}
const { currentPassword, newPassword } = validation.data;
// Get user with password
const user = await db.user.findUnique({
where: { id: userId },
select: {
id: true,
password: true,
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Check if user has a password (OAuth users don't have passwords)
if (!user.password) {
return NextResponse.json(
{ error: 'Cannot change password for OAuth accounts' },
{ status: 400 }
);
}
// Verify current password
const isPasswordValid = await bcrypt.compare(currentPassword, user.password);
if (!isPasswordValid) {
return NextResponse.json(
{ error: 'Current password is incorrect' },
{ status: 400 }
);
}
// Hash new password
const hashedPassword = await bcrypt.hash(newPassword, 10);
// Update password
await db.user.update({
where: { id: userId },
data: { password: hashedPassword },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error changing password:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import bcrypt from 'bcryptjs';
import { csrfProtection } from '@/lib/csrf';
import { changePasswordSchema, validateRequest } from '@/lib/validationSchemas';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
export async function PATCH(request: NextRequest) {
try {
// CSRF Protection
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json(
{ error: csrfCheck.error },
{ status: 403 }
);
}
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.PASSWORD_CHANGE);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
// Validate request body
const validation = await validateRequest(changePasswordSchema, body);
if (!validation.success) {
return NextResponse.json(validation.error, { status: 400 });
}
const { currentPassword, newPassword } = validation.data;
// Get user with password
const user = await db.user.findUnique({
where: { id: userId },
select: {
id: true,
password: true,
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Check if user has a password (OAuth users don't have passwords)
if (!user.password) {
return NextResponse.json(
{ error: 'Cannot change password for OAuth accounts' },
{ status: 400 }
);
}
// Verify current password
const isPasswordValid = await bcrypt.compare(currentPassword, user.password);
if (!isPasswordValid) {
return NextResponse.json(
{ error: 'Current password is incorrect' },
{ status: 400 }
);
}
// Hash new password
const hashedPassword = await bcrypt.hash(newPassword, 10);
// Update password
await db.user.update({
where: { id: userId },
data: { password: hashedPassword },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error changing password:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -1,59 +1,59 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { STRIPE_PLANS } from '@/lib/stripe';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
try {
// Use cookie-based auth instead of NextAuth
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: userId },
select: {
plan: true,
stripeCurrentPeriodEnd: true,
stripePriceId: true,
stripeCustomerId: true,
stripeSubscriptionId: true,
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Determine billing interval from stripePriceId
let interval: 'month' | 'year' | null = null;
if (user.stripePriceId) {
// Check if the current price ID matches any yearly price ID
const isYearly =
user.stripePriceId === STRIPE_PLANS.PRO.priceIdYearly ||
user.stripePriceId === STRIPE_PLANS.BUSINESS.priceIdYearly;
interval = isYearly ? 'year' : 'month';
}
return NextResponse.json({
plan: user.plan || 'FREE',
interval,
currentPeriodEnd: user.stripeCurrentPeriodEnd,
priceId: user.stripePriceId,
stripeCustomerId: user.stripeCustomerId,
stripeSubscriptionId: user.stripeSubscriptionId,
});
} catch (error) {
console.error('Error fetching user plan:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { STRIPE_PLANS } from '@/lib/stripe';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
try {
// Use cookie-based auth instead of NextAuth
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: userId },
select: {
plan: true,
stripeCurrentPeriodEnd: true,
stripePriceId: true,
stripeCustomerId: true,
stripeSubscriptionId: true,
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Determine billing interval from stripePriceId
let interval: 'month' | 'year' | null = null;
if (user.stripePriceId) {
// Check if the current price ID matches any yearly price ID
const isYearly =
user.stripePriceId === STRIPE_PLANS.PRO.priceIdYearly ||
user.stripePriceId === STRIPE_PLANS.BUSINESS.priceIdYearly;
interval = isYearly ? 'year' : 'month';
}
return NextResponse.json({
plan: user.plan || 'FREE',
interval,
currentPeriodEnd: user.stripeCurrentPeriodEnd,
priceId: user.stripePriceId,
stripeCustomerId: user.stripeCustomerId,
stripeSubscriptionId: user.stripeSubscriptionId,
});
} catch (error) {
console.error('Error fetching user plan:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -1,74 +1,74 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { csrfProtection } from '@/lib/csrf';
import { updateProfileSchema, validateRequest } from '@/lib/validationSchemas';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
export async function PATCH(request: NextRequest) {
try {
// CSRF Protection
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
}
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.PROFILE_UPDATE);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
// Validate request body
const validation = await validateRequest(updateProfileSchema, body);
if (!validation.success) {
return NextResponse.json(validation.error, { status: 400 });
}
const { name } = validation.data;
// Update user name in database
const updatedUser = await db.user.update({
where: { id: userId },
data: { name: name.trim() },
select: {
id: true,
name: true,
email: true,
},
});
return NextResponse.json({
success: true,
user: updatedUser,
});
} catch (error) {
console.error('Error updating profile:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { csrfProtection } from '@/lib/csrf';
import { updateProfileSchema, validateRequest } from '@/lib/validationSchemas';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
export async function PATCH(request: NextRequest) {
try {
// CSRF Protection
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
}
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.PROFILE_UPDATE);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
// Validate request body
const validation = await validateRequest(updateProfileSchema, body);
if (!validation.success) {
return NextResponse.json(validation.error, { status: 400 });
}
const { name } = validation.data;
// Update user name in database
const updatedUser = await db.user.update({
where: { id: userId },
data: { name: name.trim() },
select: {
id: true,
name: true,
email: true,
},
});
return NextResponse.json({
success: true,
user: updatedUser,
});
} catch (error) {
console.error('Error updating profile:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

Some files were not shown because too many files have changed in this diff Show More